# Unfold: Imwald

Imwald

A Symfony + FrankenPHP site that **reads Nostr long-form articles (kind 30023)** and related data from relays, and serves pages with Twig. ### Where data lives | Data | Storage | |------|---------| | Published articles (30023/24) | **MySQL** `article` table (from `articles:get` / relay sync) | | Magazine index (30040), kind-0 **profiles**, NIP-65 **relay lists** (10002) | **MySQL** `event` table with stable `core_row_key` (filled by `app:prewarm` and on-demand fetches) | | Comment / reply / thread **UI** (fetched thread HTML, etc.) | **Filesystem cache** pool `cache.replies` (not the DB) | | Unpublished **editor preview** payloads | **Filesystem cache** pool `cache.drafts` | | Generic Symfony `cache.app` | Other app caches; **not** used for long-term profile or magazine index storage | NIP-09 kind-5 deletions that target stored kinds are applied to **MySQL** (articles + `event` rows). Relays are expected to handle ephemeral thread data. --- ## Requirements | Requirement | Version / notes | |------------|-----------------| | PHP | **≥ 8.3.13** (see `composer.json`) | | Docker | Optional; recommended for local dev and production images | | Database | MySQL **8.0** (configurable) | --- ## Local development (Docker) 1. **Env:** copy `.env.dist` to `.env` and adjust if needed (especially `APP_SECRET` outside dev). 2. **Start stack** ```bash docker compose up -d ``` 3. **App URL (default):** [http://127.0.0.1:9080](http://127.0.0.1:9080) Port comes from `HTTP_PORT` in `.env` and `compose.override.yaml` (loopback only). 4. **First-time DB:** migrations run on **php** container start when `migrations/` contains PHP files (see `frankenphp/docker-entrypoint.sh`). | Service | Role | |--------|------| | `php` | FrankenPHP + Caddy, Symfony app, console | | `database` | MySQL; dev exposes `127.0.0.1:3307 → 3306` for local clients | | `cron` | Runs full **`app:prewarm` every 10 minutes**; repo bind-mounted at `/var/www/html` (see `docker/cron/`) | --- ## Backfill articles + prewarm (recommended) To **migrate**, **import articles from Nostr** for a time window, then run **prewarm** (magazine + profiles + deletions + comment cache): ```bash make prewarm ``` | Step (script order) | Command / effect | |---------------------|------------------| | 1 | `docker compose up -d --wait` — starts **php**, **database**, and **cron** (the `cron` image runs a full `app:prewarm` on a 10 min schedule) | | 2 | `doctrine:migrations:migrate` — applies schema (including `event` columns for core Nostr rows) | | 3 | `articles:get -- '-2 month' 'now'` — sync long-form into the `article` table | | 4 | `app:prewarm` — NIP-09 kind-5 sync (for stored kinds), magazine **30040** → `event`, kind-0 **profiles** (and relay lists on demand) → `event`, **comment** thread cache → `cache.replies` (default **`--comments-max=10`**, newest by `createdAt`) | `make prewarm` brings the stack (including `cron`) up so scheduled prewarm is active. **Optional** extra arguments for the **cron**-scheduled `app:prewarm` go in **`.env`** as **`PREWARM_FLAGS`** (same as you might pass to `php bin/console app:prewarm …`); Compose passes them into the `cron` container. Example: `PREWARM_FLAGS="--metadata-limit=50 --no-magazine"`. **Restart** the `cron` service after changing `PREWARM_FLAGS` so the container reloads the env. On the **hub** stack, the `prewarm` service reads the same `PREWARM_FLAGS`; use `docker compose -f compose.hub.yaml up -d --force-recreate prewarm` after changing it. **Fresh database or major upgrade:** after schema changes, run **`articles:get`** + **`app:prewarm`** (or `make prewarm`) so `article` and `event` are repopulated from relays. There is no automatic migration of old PSR **profile** cache into MySQL. --- ## Console commands (overview) | Command | Purpose | |---------|---------| | `articles:get ` | Pull long-form articles from Nostr for the time range, persist to `article` | | `app:prewarm` | Magazine 30040 + kind-0 profile prewarm (→ `event`), NIP-09 deletions, comment thread warm (→ `cache.replies`) | | `doctrine:migrations:migrate` | Apply SQL migrations | | `user:elevate` | (If used) user elevation helper | `php bin/console list` and `… -h` for full options. ### `app:prewarm` (notable options) | Option | Default | Meaning | |--------|---------|--------| | `--no-magazine` | off | Skip magazine 30040 index fetch / `event` update | | `--no-metadata` | off | Skip batched kind-0 profile prewarm (writes to `event`) | | `--no-deletions` | off | Skip NIP-09 kind-5 fetch and application (articles + `event` index/profile rows) | | `--deletion-since` | `-2 month` | `strtotime()` lower bound for kind-5 author-scoped fetch | | `--no-comments` | off | Skip comment thread prewarm (`cache.replies`) | | `--metadata-limit` | `0` (all authors) | Max distinct author pubkeys for the metadata phase | | `--metadata-batch` | `50` | Pubkeys per batched kind-0 Nostr `REQ` | | `--comments-max` | `10` | Newest **N** articles (by `createdAt` **DESC**); `0` = all (still bounded by budget) | | `--comments-budget` | `600` | Max wall seconds for the whole comments phase (Nostr is slow; raise e.g. `1200` if you need more articles in one run) | | `--magazine-budget` | `90` | Max wall seconds for magazine **per-category** 30040 fetches (root is separate; cap 600s in code). If you have many categories, a **low** budget can stop before the last slug is refreshed. Set `MAGAZINE_PREWARM_PREFER_SLUGS` (comma-separated category `#d` slugs) to fetch those first after the root. | Prewarm clears the PHP **CLI** execution time limit for that run; relay work can be slow. ### `PREWARM_ON_START` (optional) | Variable | Set where | Effect | |----------|------------|--------| | `PREWARM_ON_START=1` | **Compose `environment` on the `php` service** (not only Symfony `.env` inside the container) | After DB is up and migrations run, executes **`app:prewarm` once** on start. **Does not** run `articles:get`. | For a full **Nostr backfill** + one-shot prewarm, use **`make prewarm`** (or a host **cron** / **systemd** timer) instead of relying on **`PREWARM_ON_START` alone**. --- ## Configuration | What | File | |------|------| | Site title, `npub`, `d_tag`, **relays** (`default_relay`, `article_relays`, `profile_relays`), theme | `config/unfold.yaml` (imported as Symfony parameters) | | `MAGAZINE_PREWARM_PREFER_SLUGS` | `.env` / `.env.local` — optional comma-separated category slugs to prioritize in `app:prewarm` magazine phase (after the root). Use when the relay time budget would otherwise skip your updated category. | | `DATABASE_URL`, `APP_SECRET`, `HTTP_PORT`, `MYSQL_*`, optional **`PREWARM_FLAGS`** (for the Docker `cron` service) | `.env` / `.env.local` (see `.env.dist`) | | Cache pool definitions (`cache.replies`, `cache.drafts`, `cache.app`) | `config/packages/cache.yaml` | | Service wiring (e.g. which pool comment loaders use) | `config/services.yaml` | **Relays (short):** `default_relay` and `article_relays` drive article sync and many queries; `profile_relays` are used **first** for kind-0 / profile fetches, then the merged default + article set (see `NostrClient`). --- ## Production / Hub (remote server) The app runs as a **pre-built** image (no app source on the server). The server only needs `compose.hub.yaml`, a `.env`, and Docker. Default image: `silberengel/unfold:latest`; override with **`UNFOLD_DOCKER_IMAGE`**. | Topic | Notes | |-------|--------| | `compose.hub.yaml` | Defines **`php`** (FrankenPHP) + **`database`** (MySQL) + **`prewarm`** (same app image: **`app:prewarm` every 10 minutes**, like dev’s `docker/cron`). Optional: disable `prewarm` in Compose if you prefer a host `cron` only. | | HTTP | **`HTTP_PUBLISH`** in `.env` maps **host** port → container **80** (default **9080**). Put a reverse proxy (e.g. Apache) in front; set **`TRUSTED_PROXIES`** to match your proxy (often include `127.0.0.0/8` and the Docker bridge CIDR, e.g. `172.16.0.0/12`). | | Secrets | Real **`APP_SECRET`** and **`MYSQL_*`** (or external DB via `DATABASE_URL` if you change the file). Do not commit production `.env`. | | `PREWARM_FLAGS` | Optional extra CLI args for the hub **`prewarm`** service (and dev **`cron`**). After editing `.env`, run `docker compose -f compose.hub.yaml up -d --force-recreate prewarm`. | ### Build, tag, and push (on your machine or CI) From the **repository root** (same `Dockerfile` as local prod): ```bash # Production image docker build --platform linux/amd64 --target frankenphp_prod -t YOUR_REGISTRY/unfold:latest . # Optional: immutable tag for rollbacks docker build --platform linux/amd64 --target frankenphp_prod -t YOUR_REGISTRY/unfold:1.0.0 . # Push what you use on the server docker push YOUR_REGISTRY/unfold:latest docker push YOUR_REGISTRY/unfold:1.0.0 ``` - Use **`linux/amd64`** if the server is amd64; use **`arm64`** (or a matching `--platform`) for arm servers. - The image name must match what the server will pull: either keep **`UNFOLD_DOCKER_IMAGE=YOUR_REGISTRY/unfold:TAG`** in server `.env`, or push to the default name **`silberengel/unfold:latest`**. ### Deploy on the server (pull, up, migrate) In a directory that contains **only** `compose.hub.yaml` and your **`.env`** (e.g. `~/tmp/unfold`): ```bash cd /path/to/deploy docker compose -f compose.hub.yaml pull docker compose -f compose.hub.yaml up -d docker compose -f compose.hub.yaml exec php php bin/console doctrine:migrations:migrate --no-interaction ``` After code changes: **`pull` → `up -d`**; run **migrations** when the repo added new migration files. ### `Makefile.hub` (on the server) Copy **`Makefile.hub`** into the same directory as **`compose.hub.yaml`** and **`.env`** (no full clone required). You get short commands like the dev **`Makefile`**, all using `docker compose -f compose.hub.yaml` under the hood: ```bash make -f Makefile.hub help # list targets make -f Makefile.hub pull make -f Makefile.hub up make -f Makefile.hub migrate make -f Makefile.hub prewarm-once make -f Makefile.hub articles-get # optional: ARTICLES_FROM='-1 year' ARTICLES_TO=now make -f Makefile.hub backfill # up + migrate + articles-get + prewarm-once (closest to dev `make prewarm`) ``` **Optional image / tag** (in `.env` or one-shot): ```bash export UNFOLD_DOCKER_IMAGE=YOUR_REGISTRY/unfold:1.0.0 docker compose -f compose.hub.yaml up -d ``` ### One-time Nostr backfill (equivalent to `make prewarm` on dev) Use **`make -f Makefile.hub backfill`**, or run the same **inside the `php` container**: ```bash docker compose -f compose.hub.yaml exec -T php php bin/console articles:get -- '-2 month' 'now' docker compose -f compose.hub.yaml exec -T php php bin/console app:prewarm ``` Adjust the **articles:get** window as needed (see **`Makefile.hub`** / `ARTICLES_FROM` / `ARTICLES_TO`). ### Scheduled `app:prewarm` on hub The **`prewarm`** service uses the **same** image as `php` and runs **`app:prewarm` every 10 minutes** (same cadence as dev’s `docker/cron`). It waits for **MySQL** and the **`doctrine_migration_versions`** table (so the `php` entrypoint has run migrations) — it does **not** use `curl` to the `php` service, which can fail if HTTP is only bound for loopback inside that container. **Optional** `PREWARM_FLAGS` in `.env` is passed into that container; after changing it, run: ```bash docker compose -f compose.hub.yaml up -d --force-recreate prewarm ``` **If you do not** want a Compose sidecar (e.g. to save RAM), stop and disable the `prewarm` service and use **host** `cron` or **systemd** instead: ```text */10 * * * * cd /path/to/deploy && docker compose -f compose.hub.yaml exec -T php php bin/console app:prewarm ``` **`PREWARM_ON_START=1`** on the `php` service only warms **once** at container start, not on a schedule. The file `compose.hub.yaml` in the repo repeats minimal pull/migrate/build one-liners in its header for quick copy-paste. --- ## License **MIT** — see [`LICENSE`](LICENSE). --- ## Project links (example) Configurable under `parameters.external_links` in `config/unfold.yaml` (e.g. Unfold on GitHub, Decent Newsroom). Adjust for your deployment.