# Symfony Blog CMS

Branch: `feat/symfony-blog-api`.

Ported from `/var/www/html/Laravel/1140-secret-text-laravel`, branch
`feat/laravel-blog-api`, inspected at commit `0c49c5d8`. That implementation is
an API, not an admin website. This port preserves its API URLs, request names,
public presentation, import format, and editorial workflows. Public encryption
uses this Symfony app's existing OpenSSL AES-256-CBC format, not the reference
application's encryption format.

## Isolation

- Only new `blog_*` tables are used. App users, purchased numbers, calling,
  recordings, SMS, subscriptions, and provider credentials are not changed.
- Blog users are separate `ADMIN`/`EDITOR` accounts. Laravel Sanctum is replaced
  by hashed, revocable bearer tokens in `blog_admin_tokens` (seven-day default).
  Tokens from the reference application are not portable.
- Route names start with `wsqr_blog`; URLs deliberately match the reference CMS.
- Only routes marked `_blog` bypass the existing app AES listener and Vonage
  normalizer. Blog public responses call the same `OpenSslApiCryptor` exactly
  once; Blog admin requests keep bearer-authenticated JSON. All other routes
  retain their previous behavior.
- Doctrine DBAL manages the isolated CMS schema; no existing Doctrine entities
  or repositories were modified. Do not run an ORM schema diff to deploy it.
- A DB-backed cache queue replaces Laravel jobs. No network call occurs inside
  the post-saving request. The cache command retries delivery three times.

## Deployment

Back up the target database first. Do not run `migrate:fresh`, `schema:update`,
or all pending application migrations just to enable this module.

Use the PHP version supported by this project's dependencies. This workspace
was verified with PHP 7.4; the existing root constraint is `php: ^7.1.3`.
Do not change that constraint or upgrade unrelated packages for this port.

There is no additional encryption package to install. Install the existing
locked dependencies using the established deployment procedure:

```bash
composer install --no-dev --prefer-dist --optimize-autoloader --no-scripts
```

Keep dev dependencies when the deployed application uses `APP_ENV=dev`.
Composer reports existing legacy dependency advisories; this branch does not
upgrade those dependencies. Required extensions include OpenSSL, DOM, Fileinfo,
and Mbstring.

Set values from [blog.env.example](blog.env.example) in the appropriate untracked
environment file. Public Blog responses and the Blog crypto tools reuse
`API_ENCRYPTION_KEY`, `API_ENCRYPTION_KEY_OFFSET`, `API_ENCRYPTION_KEY_LENGTH`, and
`API_ENCRYPTION_IV` through the unchanged `OpenSslApiCryptor`. These are read
from the current environment's General Settings first, then the existing
environment/default fallback. No separate Blog encryption password is required.
Do not put actual keys in docs or shared Postman values. Always use HTTPS;
this compatibility encryption is not authorization or authenticated encryption.
Only public articles are exposed through the unauthenticated Blog endpoints.
Set `BLOG_SITE_REVALIDATE_SECRET` to the website's revalidation bearer secret.
Preview signing defaults to the backend `APP_SECRET` if no override is supplied.

Run only this migration after deploying the reviewed branch:

```bash
APP_ENV=prod APP_DEBUG=0 php bin/console doctrine:migrations:execute \
  'DoctrineMigrations\Version20261104000000' --up --env=prod --no-interaction
APP_ENV=prod APP_DEBUG=0 php bin/console cache:clear --env=prod --no-debug
php bin/console blog:create-admin admin@example.com 'Blog Administrator' --env=prod
```

The last command securely prompts for a 12-72 byte password. It never updates
an existing account or supplies a default password. Use `--role=EDITOR` for an
editor. No public registration endpoint exists.

S3 storage reuses `AWS_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`,
`AWS_S3_BUCKET`, and `CLOUDFRONT_DOMAIN`; IAM role credentials also work when
explicit AWS credentials are absent. Only the `blog/media/` prefix is written
by uploads. Configure bucket policy/CloudFront access as appropriate.
Imported media outside that prefix remains externally managed: deleting its
object through this module is blocked so shared recordings/uploads stay intact.

For local storage set `BLOG_MEDIA_DISK=local`, make `public/uploads` writable
by the web user, and serve it as static media. Do not allow PHP execution in
that upload directory. Supported uploads are verified JPEG, PNG, and WebP only.
Adjust PHP/web-server request size limits for the intended Base64 uploads.

Run every minute, replacing the directory with the deployment location:

```cron
* * * * * cd /path/to/project && APP_ENV=prod APP_DEBUG=0 php bin/console blog:publish-scheduled --env=prod --no-debug >> var/log/blog-cron.log 2>&1
* * * * * cd /path/to/project && APP_ENV=prod APP_DEBUG=0 php bin/console blog:refresh-cache --env=prod --no-debug >> var/log/blog-cron.log 2>&1
```

Ensure `var/cache/blog-rate-limits` is writable by the web user. This port's
limits are atomic but local to a single filesystem; multi-node deployments
must share that directory or enforce equivalent limits at the reverse proxy.
Prune stale files during routine cache maintenance. Configure trusted proxies
correctly so client IPs cannot be spoofed and visitors are not all one IP.

## API Contract

Public endpoints require no authorization. Responses, including JSON errors,
are raw Base64 OpenSSL AES-256-CBC ciphertext with `Content-Type: application/json`
(matching the existing app API) and `X-Blog-Encryption: aes-256-cbc`. Decrypt the
body using the existing app decryptor before parsing JSON. Key derivation is
SHA-256 of the configured secret substring; the IV is binary MD5 of the
configured IV value. The existing app's fallback behavior is unchanged.
An encryption failure returns a generic JSON 503, never plaintext Blog data.

| Method | URL | Purpose |
| --- | --- | --- |
| GET | `/api/v1/posts?page=1&perPage=12&category=calling` | Published/due active posts, pagination, categories |
| GET | `/api/v1/posts/{slug}` | Full post, SEO, FAQs, CTA, related posts; old-slug redirect metadata on 404 |
| GET | `/api/v1/sitemap` | Indexable live post paths and timestamps |
| GET | `/api/v1/preview/{token}` | Signed preview; private/no-store; one-hour validity |

Post summaries contain `id`, `slug`, `path`, `title`, `excerpt`, `featuredImage`,
`category`, `author`, `publishedAt`, `updatedAt`, and `readingMinutes`. Detail adds
`contentHtml`, `tags`, `faqs`, `faqSchema`, `cta`, `related`, and `seo`.
Published inactive posts are hidden; active scheduled posts become public only
when their schedule is due. Categories/authors can be `ACTIVE` or `INACTIVE`.

Admin requests and responses are plain JSON over HTTPS, not the mobile app AES
envelope. Except login, all require `Authorization: Bearer <blog-token>`.
Blog-only CORS/preflight support matches the reference's wildcard default;
restrict `BLOG_ALLOWED_ORIGINS` to comma-separated website/CMS origins in
production. Bearer authentication is required regardless of allowed origins.

| Method | URL | Purpose |
| --- | --- | --- |
| POST | `/api/admin/auth/login` | `{ "email": "...", "password": "..." }` -> `{ "ok": true, "token": "...", "user": {...} }` |
| GET | `/api/admin/auth/me` | `{ "ok": true, "user": {...} }` |
| POST | `/api/admin/auth/logout` | Revoke current token |
| GET, POST | `/api/admin/blogs` | Paginated list/create |
| GET, PUT, DELETE | `/api/admin/blogs/{id}` | Read/update/permanent ADMIN-only deletion |
| POST | `/api/admin/blogs/{id}/publish` | Publish stored content |
| POST | `/api/admin/blogs/{id}/unpublish` | Return to draft |
| POST | `/api/admin/blogs/{id}/inactive` | Hide without changing publication status |
| POST | `/api/admin/blogs/{id}/active` | Restore saved publication status |
| GET, POST | `/api/admin/categories` | Paginated picker/create |
| PUT, DELETE | `/api/admin/categories/{id}` | Update/ADMIN-only delete; posts retained |
| GET, POST | `/api/admin/authors` | Paginated picker/create |
| PUT, DELETE | `/api/admin/authors/{id}` | Update/ADMIN-only delete; posts retained |
| GET, POST | `/api/blog/admin/media` | Paginated image library/Base64 upload |
| DELETE | `/api/blog/admin/media/{id}` | ADMIN or uploader; refuses images still in use |
| POST | `/api/blog/admin/blogs/import-json` | ADMIN-only multipart JSON import |
| POST | `/api/blog/admin/crypto/encrypt` | ADMIN-only raw text -> ciphertext |
| POST | `/api/blog/admin/crypto/decrypt` | ADMIN-only ciphertext -> raw text |

List responses use `{ "ok": true, "data": { "data": [...], "current_page": 1,
"per_page": 15, "total": 0, "last_page": 1, ... } }`. Auth tokens are never
included in post or public data. Standard errors use `ok: false`, `error`, and
HTTP 401/403/404/409/422/429/503 as appropriate.

Create/update example:

```json
{
  "intent": "draft",
  "version": 1,
  "values": {
    "title": "Phone Privacy",
    "slug": "phone-privacy",
    "excerpt": "A short summary.",
    "content": "<p>Article content.</p>",
    "categoryId": null,
    "authorId": null,
    "tags": ["Privacy"],
    "ctaEnabled": true,
    "ctaTitle": "Download the App",
    "ctaButtonText": "Get the App",
    "ctaButtonUrl": "https://apps.apple.com/app/id123",
    "ctaButtonStyle": "APP_STORE"
  }
}
```

`intent` is `draft`, `publish`, `schedule`, or `update`. Omit `version` on create;
send the last returned version on update for optimistic locking. Schedule uses
`values.publishedAt`, at least one minute in the future. Updates use full editor
values, like the reference, rather than PATCH semantics. CTA style is `BUTTON`
or `APP_STORE`; App Store badges require an Apple App Store URL.

Images accept URL strings, `featuredMediaId`, or
`{ "filename": "cover.png", "data": "data:image/png;base64,...", "alt": "..." }`.
Inline `contentImages` match `<img data-blog-image-id="client-id">` placeholders.
Do not send a featured image and a featured-media selection simultaneously.
Up to five inline images are allowed. Post deletion intentionally retains media.

## Imports and Preview

The `secondline-blog-export` version 1 format remains unchanged so existing
exports work. Includes categories, authors, tags, FAQs, related picks, redirects,
revision snapshots, image metadata, and CTA button style. Exported images are
registered/reused, not fetched remotely. Copy files into local `public/uploads`
or upload the exported keys to S3 first. Dry runs do not write rows or files.

```bash
php bin/console blog:import-json /path/to/export.json --dry-run --report=/tmp/report.json
php bin/console blog:import-json /path/to/export.json
php bin/console blog:import-json /path/to/export.json --update
php bin/console blog:preview POST_ULID
```

The multipart API accepts `file` (up to 4 MB), `existing=skip|update`, and
`dryRun=1|0` (default 1). Validation rejects malformed exports before writes;
individual database failures roll back that post and are reported as `fail`.

## Verification

Import [the Postman collection](../postman/WifiCalls-Blog-CMS.postman_collection.json)
and set `baseUrl`, email, and password as local values. It carries the reference
requests and assertions; do not run write/delete examples against live content.

Standalone tests run real Symfony kernel requests, MySQL, and temporary local
media without calling Vonage, Twilio, Apple, S3, FCM, or the live website:

```bash
BLOG_TEST_DATABASE_URL='mysql://USER:PASSWORD@127.0.0.1/blog_test_cms' \
  php tests/Blog/run.php
```

The suite also runs a local HTTP website stand-in to verify cache request
headers/body, successful delivery, retry scheduling, and the three-attempt cap.
The test database must exist, be empty, and be named `blog_test_*`. The suite
refuses other names and never falls back to the app's configured database.
Tests leave their DB rows for inspection; remove only that disposable database
before repeating. Cache and media fixtures are isolated from application data.
Live S3/CloudFront and website revalidation still need deployment smoke tests.
