Compare commits

...

5 Commits

  1. 2
      .dockerignore
  2. 12
      .env.dist
  3. 5
      Dockerfile
  4. 7
      Makefile
  5. 149
      README.md
  6. 6
      assets/bootstrap.js
  7. 49
      assets/controllers/magazine_sync_controller.js
  8. 61
      compose.hub.yaml
  9. 5
      compose.override.yaml
  10. 6
      compose.yaml
  11. 4
      composer.json
  12. 131
      composer.lock
  13. 4
      config/packages/asset_mapper.yaml
  14. 5
      config/packages/framework.yaml
  15. 14
      config/services.yaml
  16. 12
      config/unfold.yaml
  17. 35
      docker/cron/Dockerfile
  18. 95
      docker/cron/README.md
  19. 2
      docker/cron/crontab
  20. 6
      docker/cron/entry-cron.sh
  21. 5
      docker/cron/index_articles.sh
  22. 9
      docker/cron/prewarm_cron.sh
  23. 2
      frankenphp/conf.d/10-app.ini
  24. 2
      frankenphp/conf.d/20-app.dev.ini
  25. 12
      frankenphp/docker-entrypoint.sh
  26. 3
      importmap.php
  27. 22
      scripts/docker-prewarm.sh
  28. 250
      src/Command/PrewarmCommand.php
  29. 52
      src/Controller/ArticleController.php
  30. 128
      src/Controller/DefaultController.php
  31. 103
      src/Controller/MagazineSyncController.php
  32. 1
      src/Enum/KindsEnum.php
  33. 61
      src/Repository/ArticleRepository.php
  34. 13
      src/Service/ArticleCommentThreadLoader.php
  35. 74
      src/Service/CacheService.php
  36. 177
      src/Service/MagazineContentService.php
  37. 116
      src/Service/MagazineIndexStore.php
  38. 177
      src/Service/MagazineRefresher.php
  39. 359
      src/Service/Nip09DeletionApplier.php
  40. 701
      src/Service/NostrClient.php
  41. 35
      src/Twig/Components/Header.php
  42. 32
      src/Twig/Components/Molecules/CategoryLink.php
  43. 32
      src/Twig/Components/Organisms/FeaturedList.php
  44. 7
      src/Util/CommonMark/NostrSchemeExtension/NostrSchemeParser.php
  45. 17
      templates/base.html.twig
  46. 4
      templates/components/Header.html.twig
  47. 2
      templates/components/Organisms/FeaturedList.html.twig
  48. 19
      templates/home.html.twig
  49. 61
      templates/pages/article.html.twig
  50. 30
      templates/pages/category.html.twig
  51. 3
      templates/ux/magazine/category_body.html.twig
  52. 10
      templates/ux/magazine/header_ul.html.twig
  53. 5
      templates/ux/magazine/home_body.html.twig

2
.dockerignore

@ -3,6 +3,7 @@
**/*.php~ **/*.php~
**/*.dist.php **/*.dist.php
**/*.dist **/*.dist
!.env.dist
**/*.cache **/*.cache
**/._* **/._*
**/.dockerignore **/.dockerignore
@ -28,6 +29,7 @@ tests/
var/ var/
vendor/ vendor/
.editorconfig .editorconfig
/.env
.env.*.local .env.*.local
.env.local .env.local
.env.local.php .env.local.php

12
.env.dist

@ -17,6 +17,8 @@
###> symfony/framework-bundle ### ###> symfony/framework-bundle ###
APP_ENV=dev APP_ENV=dev
APP_SECRET=9e287f1ad737386dde46d51e80487236 APP_SECRET=9e287f1ad737386dde46d51e80487236
# Comma-separated CIDRs for reverse proxies (used when APP_ENV=prod). Override on the server if needed.
# TRUSTED_PROXIES=127.0.0.0/8,10.0.0.0/8,::1
###< symfony/framework-bundle ### ###< symfony/framework-bundle ###
###> docker ### ###> docker ###
# Dev URL: http://127.0.0.1:${HTTP_PORT}/ (override HTTP_PORT/HTTPS_PORT if busy). # Dev URL: http://127.0.0.1:${HTTP_PORT}/ (override HTTP_PORT/HTTPS_PORT if busy).
@ -32,6 +34,16 @@ MYSQL_PASSWORD=password
# Root password is only used for the bundled database service, see compose.yaml # Root password is only used for the bundled database service, see compose.yaml
# skip it, if you use your own # skip it, if you use your own
MYSQL_ROOT_PASSWORD=root_password MYSQL_ROOT_PASSWORD=root_password
# Set to 1 in the php service environment to run `app:prewarm` once after migrations on container start
# (magazine + metadata + comment cache; not a substitute for `scripts/docker-prewarm.sh`, which also runs articles:get).
# PREWARM_ON_START=0
# Hub deploy: optional full image ref (default silberengel/unfold:latest in compose.hub.yaml).
# UNFOLD_DOCKER_IMAGE=silberengel/unfold:1.0.0
# Optional extra CLI args for the docker `cron` service (full `app:prewarm` every 10 min). Example: --metadata-limit=100 --no-magazine
# PREWARM_FLAGS=
# compose.hub.yaml: default host port is 9080. Use 80 only if nothing else binds it. Loopback-only example:
# HTTP_PUBLISH=127.0.0.1:9080
# HTTP_PUBLISH=80
###< docker ### ###< docker ###
###> doctrine/doctrine-bundle ### ###> doctrine/doctrine-bundle ###

5
Dockerfile

@ -61,7 +61,7 @@ CMD [ "frankenphp", "run", "--config", "/etc/caddy/Caddyfile" ]
# Dev FrankenPHP image # Dev FrankenPHP image
FROM frankenphp_base AS frankenphp_dev FROM frankenphp_base AS frankenphp_dev
ENV APP_ENV=dev XDEBUG_MODE=off ENV APP_ENV=dev XDEBUG_MODE=develop
RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini" RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini"
@ -96,7 +96,10 @@ RUN rm -Rf frankenphp/
RUN set -eux; \ RUN set -eux; \
mkdir -p var/cache var/log; \ mkdir -p var/cache var/log; \
cp .env.dist .env; \
composer dump-autoload --classmap-authoritative --no-dev; \ composer dump-autoload --classmap-authoritative --no-dev; \
composer dump-env prod; \ composer dump-env prod; \
rm -f .env; \
composer run-script --no-dev post-install-cmd; \ composer run-script --no-dev post-install-cmd; \
php bin/console asset-map:compile --no-debug; \
chmod +x bin/console; sync; chmod +x bin/console; sync;

7
Makefile

@ -0,0 +1,7 @@
# Default compose file from repo root; override with `make prewarm COMPOSE=...` if needed.
COMPOSE ?= docker compose
.PHONY: prewarm
prewarm:
@chmod +x scripts/docker-prewarm.sh 2>/dev/null || true
@./scripts/docker-prewarm.sh

149
README.md

@ -1,79 +1,130 @@
# Unfold # Unfold: Imwald
Unfold is a customizable framework for your Nostr-based magazine. <p align="center">
<img src="assets/laeserin_logo.png" alt="Imwald" width="150">
</p>
(This is the **Imwald** edition of Unfold.) A Symfony + FrankenPHP site that **reads Nostr long-form articles (kind 30023)** and related data from relays, stores articles in **MySQL**, and serves pages with Twig. **Comments and profile metadata** are **cache-backed** (not the full source of truth in the DB).
## Setup ---
### Clone the repository ## 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 + warm caches (recommended)
To **migrate**, **import articles from Nostr** for a time window, then **prewarm** magazine indices, author metadata, and comment caches:
```bash ```bash
git clone https://github.com/decent-newsroom/unfold.git make prewarm
cd unfold
``` ```
### Create the .env file | 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` |
| 3 | `articles:get -- '-2 month' 'now'` — sync long-form into MySQL for that window |
| 4 | `app:prewarm` — magazine **30040**, **kind-0** profiles, **comment** cache (default **`--comments-max=20`**, newest by `createdAt`) |
Copy the example file `.env.dist` and replace placeholders with your actual configuration. `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. Hub / `compose.hub.yaml` has no `cron` service; use a host timer or `exec` if you need the same there.
If you have your own MySQL database, comment out the database service in `compose.yaml` and skip root password in `.env`. ---
There are additional comments to that effect in the files.
### Configure `config/unfold.yaml` ## Console commands (overview)
Before running the application, review and update `config/unfold.yaml` to match your desired magazine settings, theme, and external links. This file controls: | Command | Purpose |
- Magazine name, short name, and description |---------|---------|
- Theme and color settings | `articles:get <from> <to>` | Pull long-form articles from Nostr for the time range, persist to DB |
- Community articles feature | `app:prewarm` | Magazine relay refresh + metadata cache + comment cache warm |
- External footer links | `doctrine:migrations:migrate` | Apply SQL migrations |
- Other project-specific configuration | `user:elevate` | (If used) user elevation helper |
Edit the values in `config/unfold.yaml` as needed for your deployment. `php bin/console list` and `… -h` for full options.
### Customizing Theme and Icons ### `app:prewarm` (notable options)
You can override the default theme and icons by adding your own files to `/assets/theme/local/`. To do this: | Option | Default | Meaning |
- Copy the structure and file names from `/assets/theme/default/`. |--------|---------|--------|
- Place your custom `theme.css` and icon files in your theme folder. | `--no-magazine` | off | Skip magazine 30040 index |
- Update your configuration in `config/unfold.yaml` to reference your custom theme if needed. | `--no-metadata` | off | Skip Nostr kind-0 / profile cache |
| `--no-comments` | off | Skip comment thread cache |
| `--metadata-limit` | `0` (all authors) | Cap distinct author pubkeys |
| `--metadata-batch` | `50` | Pubkeys per batched Nostr `REQ` |
| `--comments-max` | `20` | Newest **N** articles (by `createdAt` **DESC**); `0` = all (still bounded by budget) |
| `--comments-budget` | `120` | Max wall seconds for the comments phase |
| `--magazine-budget` | `30` | Max wall seconds for magazine refresh |
This allows you to easily switch or update the look and feel of your magazine without modifying the default assets. Prewarm clears the PHP **CLI** execution time limit for that run; relay work can be slow.
### `PREWARM_ON_START` (optional)
### Build the Docker containers | 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 development: 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**.
```bash
docker compose build
```
For production (using production overrides), set `APP_ENV=prod` in your `.env` file and run: ---
```bash
docker compose -f compose.yaml -f compose.prod.yaml build
```
## Configuration
### Start the Docker containers | What | File |
```bash |------|------|
docker compose up -d | Site title, `npub`, `d_tag`, **relays** (`default_relay`, `article_relays`, `profile_relays`), theme | `config/unfold.yaml` (imported as Symfony parameters) |
``` | `DATABASE_URL`, `APP_SECRET`, `HTTP_PORT`, `MYSQL_*`, optional **`PREWARM_FLAGS`** (for the Docker `cron` service) | `.env` / `.env.local` (see `.env.dist`) |
| Service wiring (e.g. cache, `NostrClient` args) | `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`).
### Run Database Migrations ---
Before fetching or displaying articles, make sure your database schema is up to date. Run: ## Production / Hub image
```bash | Topic | Notes |
docker compose exec php php bin/console doctrine:migrations:migrate --no-interaction |-------|--------|
``` | `compose.hub.yaml` | Runs a **pulled** image (default `silberengel/unfold:latest`), no local PHP app build. Override with `UNFOLD_DOCKER_IMAGE`. |
| HTTP publish | `HTTP_PUBLISH` in `.env` (default **9080** → container **80**). Set `TRUSTED_PROXIES` behind a reverse proxy. |
| Secrets | Set `APP_SECRET` and DB credentials in **real** env; do not commit production secrets. |
### Fetching Articles File header in `compose.hub.yaml` lists pull, migrate, and optional build/push one-liners.
To fetch articles from the default relay for the last two months, run: ---
```bash ## License
docker compose exec php php bin/console articles:get -- '-2 month' 'now'
``` **MIT** — see [`LICENSE`](LICENSE).
---
## Project links (example)
You can adjust the date range as needed. This command will import articles into the local database. Configurable under `parameters.external_links` in `config/unfold.yaml` (e.g. Unfold on GitHub, Decent Newsroom). Adjust for your deployment.

6
assets/bootstrap.js vendored

@ -1,5 +1,6 @@
import { startStimulusApp } from '@symfony/stimulus-bundle'; import { startStimulusApp } from '@symfony/stimulus-bundle';
import ArticleCommentsController from './controllers/article_comments_controller.js'; import ArticleCommentsController from './controllers/article_comments_controller.js';
import MagazineSyncController from './controllers/magazine_sync_controller.js';
const app = startStimulusApp(); const app = startStimulusApp();
@ -9,3 +10,8 @@ try {
} catch { } catch {
/* already registered by the bundle */ /* already registered by the bundle */
} }
try {
app.register('magazine-sync', MagazineSyncController);
} catch {
/* already registered by the bundle */
}

49
assets/controllers/magazine_sync_controller.js

@ -0,0 +1,49 @@
import { Controller } from "@hotwired/stimulus";
/**
* After first paint, refreshes Nostr magazine indices (server-side, 5s) and swaps header/body HTML.
*/
export default class extends Controller {
static targets = ["headerNav", "pageBody"];
static values = {
page: String,
slug: String,
url: String,
};
connect() {
this.sync();
}
async sync() {
const base = this.urlValue || "/ux/magazine-sync";
const params = new URLSearchParams();
params.set("page", this.pageValue || "article");
const slug = this.slugValue || "";
if (slug !== "") {
params.set("slug", slug);
}
const url = `${base}?${params.toString()}`;
try {
const res = await fetch(url, {
headers: { Accept: "application/json" },
credentials: "same-origin",
});
if (!res.ok) {
return;
}
const data = await res.json();
if (!data.ok) {
return;
}
if (this.hasHeaderNavTarget && data.header) {
this.headerNavTarget.outerHTML = data.header;
}
if (this.hasPageBodyTarget && data.body) {
this.pageBodyTarget.outerHTML = data.body;
}
} catch {
/* ignore network errors */
}
}
}

61
compose.hub.yaml

@ -0,0 +1,61 @@
# Run a pre-built production image from Docker Hub (no local PHP image build).
#
# Usage on the server (copy this file + your .env, no app source required):
# 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
#
# Required in .env: APP_SECRET. Set MYSQL_* (or replace DATABASE_URL after editing this file) if you
# use the bundled database. For TLS in front, set TRUSTED_PROXIES to include your reverse proxy CIDR.
#
# Host HTTP port defaults to 9080 (same idea as local dev) so Apache/nginx can keep :80. Override with
# HTTP_PUBLISH=80 or HTTP_PUBLISH=127.0.0.1:9080 in .env if needed.
#
# Build & push (on your machine or CI), e.g.:
# docker build --platform linux/amd64 --target frankenphp_prod -t silberengel/unfold:latest .
# docker push silberengel/unfold:latest
#
# Override image: UNFOLD_DOCKER_IMAGE=myregistry/unfold:1.0.0 docker compose -f compose.hub.yaml up -d
name: unfold
services:
php:
image: ${UNFOLD_DOCKER_IMAGE:-silberengel/unfold:latest}
pull_policy: always
restart: unless-stopped
environment:
APP_ENV: ${APP_ENV:-prod}
APP_SECRET: ${APP_SECRET}
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.0/8,10.0.0.0/8}
SERVER_NAME: ${SERVER_NAME:-:80}
DATABASE_URL: mysql://${MYSQL_USER:-unfold_user}:${MYSQL_PASSWORD:-password}@database:3306/${MYSQL_DATABASE:-unfold_db}?serverVersion=${MYSQL_VERSION:-8.0}&charset=${MYSQL_CHARSET:-utf8mb4}
volumes:
- caddy_data:/data
- caddy_config:/config
ports:
- "${HTTP_PUBLISH:-9080}:80/tcp"
depends_on:
database:
condition: service_healthy
database:
image: mysql:${MYSQL_VERSION:-8.0}
restart: unless-stopped
environment:
MYSQL_DATABASE: ${MYSQL_DATABASE:-unfold_db}
MYSQL_USER: ${MYSQL_USER:-unfold_user}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-password}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root_password}
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
timeout: 5s
retries: 5
start_period: 60s
volumes:
- database_data:/var/lib/mysql:rw
volumes:
caddy_data:
caddy_config:
database_data:

5
compose.override.yaml

@ -13,8 +13,11 @@ services:
# from the bind-mount for better performance by enabling the next line: # from the bind-mount for better performance by enabling the next line:
- /app/vendor - /app/vendor
environment: environment:
# Set to 1 to run `app:prewarm` after migrations on php container start (see README).
# PREWARM_ON_START: "0"
# develop: xdebug_info(), better stack traces, etc. Use debug,develop for step debugging (IDE).
# See https://xdebug.org/docs/all_settings#mode # See https://xdebug.org/docs/all_settings#mode
XDEBUG_MODE: "${XDEBUG_MODE:-off}" XDEBUG_MODE: "${XDEBUG_MODE:-develop}"
ports: ports:
# Defaults avoid crowded 8080/8443; override with HTTP_PORT / HTTPS_PORT in .env # Defaults avoid crowded 8080/8443; override with HTTP_PORT / HTTPS_PORT in .env
- "127.0.0.1:${HTTP_PORT:-9080}:80/tcp" - "127.0.0.1:${HTTP_PORT:-9080}:80/tcp"

6
compose.yaml

@ -44,8 +44,12 @@ services:
context: ./docker/cron context: ./docker/cron
volumes: volumes:
- .:/var/www/html - .:/var/www/html
environment:
# Passed through from the host .env (Compose substitution); crond jobs read /run/cron-prewarm.env at runtime.
PREWARM_FLAGS: ${PREWARM_FLAGS:-}
depends_on: depends_on:
- php database:
condition: service_healthy
volumes: volumes:
caddy_data: caddy_data:

4
composer.json

@ -22,7 +22,7 @@
"phpdocumentor/reflection-docblock": "^5.6", "phpdocumentor/reflection-docblock": "^5.6",
"phpstan/phpdoc-parser": "^2.0", "phpstan/phpdoc-parser": "^2.0",
"runtime/frankenphp-symfony": "^0.2.0", "runtime/frankenphp-symfony": "^0.2.0",
"swentel/nostr-php": "^1.5", "swentel/nostr-php": "^1.9.4",
"symfony/asset": "7.1.*", "symfony/asset": "7.1.*",
"symfony/asset-mapper": "7.1.*", "symfony/asset-mapper": "7.1.*",
"symfony/console": "7.1.*", "symfony/console": "7.1.*",
@ -102,7 +102,7 @@
"docker": true "docker": true
}, },
"runtime": { "runtime": {
"dotenv_overload": true "dotenv_overload": false
} }
}, },
"require-dev": { "require-dev": {

131
composer.lock generated

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "35730266c171fc5bbbe90ce2c0781509", "content-hash": "bd231af493534154dcbc0c3f059f329e",
"packages": [ "packages": [
{ {
"name": "bitwasp/bech32", "name": "bitwasp/bech32",
@ -1403,6 +1403,64 @@
}, },
"time": "2025-01-24T11:45:48+00:00" "time": "2025-01-24T11:45:48+00:00"
}, },
{
"name": "dsbaars/chacha20",
"version": "0.3.0",
"source": {
"type": "git",
"url": "https://github.com/dsbaars/PHP-ChaCha20.git",
"reference": "4edfef042c329313935a3dd516278e27c2939b5f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dsbaars/PHP-ChaCha20/zipball/4edfef042c329313935a3dd516278e27c2939b5f",
"reference": "4edfef042c329313935a3dd516278e27c2939b5f",
"shasum": ""
},
"require": {
"php": ">=8.1"
},
"require-dev": {
"phpstan/phpstan": "^2.1",
"phpstan/phpstan-deprecation-rules": "^2.0",
"phpunit/phpunit": "~12.4"
},
"type": "library",
"autoload": {
"files": [
"lib/functions.php"
],
"psr-4": {
"ChaCha20\\": "lib"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Leigh",
"homepage": "https://github.com/lt"
},
{
"name": "Djuri Baars",
"homepage": "https://github.com/dsbaars"
}
],
"description": "Pure PHP implementation of the ChaCha20 encryption algorithm.",
"homepage": "https://github.com/dsbaars/PHP-ChaCha20",
"keywords": [
"cipher",
"encryption",
"security",
"stream"
],
"support": {
"source": "https://github.com/dsbaars/PHP-ChaCha20/tree/0.3.0"
},
"time": "2025-11-28T16:51:46+00:00"
},
{ {
"name": "embed/embed", "name": "embed/embed",
"version": "v4.4.17", "version": "v4.4.17",
@ -2112,59 +2170,6 @@
], ],
"time": "2024-12-08T08:18:47+00:00" "time": "2024-12-08T08:18:47+00:00"
}, },
{
"name": "leigh/chacha20",
"version": "0.2.0",
"source": {
"type": "git",
"url": "https://github.com/lt/PHP-ChaCha20.git",
"reference": "7aeffd53228be384b4a8986c9a8d9578acb171a4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/lt/PHP-ChaCha20/zipball/7aeffd53228be384b4a8986c9a8d9578acb171a4",
"reference": "7aeffd53228be384b4a8986c9a8d9578acb171a4",
"shasum": ""
},
"require": {
"php": ">=7.0"
},
"require-dev": {
"phpunit/phpunit": "~5.0"
},
"type": "library",
"autoload": {
"files": [
"lib/functions.php"
],
"psr-4": {
"ChaCha20\\": "lib"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Leigh",
"homepage": "https://github.com/lt"
}
],
"description": "Pure PHP implementation of the ChaCha20 encryption algorithm.",
"homepage": "https://github.com/lt/PHP-ChaCha20",
"keywords": [
"cipher",
"encryption",
"security",
"stream"
],
"support": {
"issues": "https://github.com/lt/PHP-ChaCha20/issues",
"source": "https://github.com/lt/PHP-ChaCha20/tree/0.2.0"
},
"time": "2016-01-14T11:24:17+00:00"
},
{ {
"name": "masterminds/html5", "name": "masterminds/html5",
"version": "2.10.0", "version": "2.10.0",
@ -3888,25 +3893,25 @@
}, },
{ {
"name": "swentel/nostr-php", "name": "swentel/nostr-php",
"version": "1.9.2", "version": "1.9.4",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/nostrver-se/nostr-php.git", "url": "https://github.com/nostrver-se/nostr-php.git",
"reference": "8fb8337354b2e9d48a901276c7814d7fa7b25653" "reference": "e502540ea811199443e1fffcbdaef9048940399c"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/nostrver-se/nostr-php/zipball/8fb8337354b2e9d48a901276c7814d7fa7b25653", "url": "https://api.github.com/repos/nostrver-se/nostr-php/zipball/e502540ea811199443e1fffcbdaef9048940399c",
"reference": "8fb8337354b2e9d48a901276c7814d7fa7b25653", "reference": "e502540ea811199443e1fffcbdaef9048940399c",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"bitwasp/bech32": "^0.0.1", "bitwasp/bech32": "^0.0.1",
"dsbaars/chacha20": "^0.3.0",
"ext-gmp": "*", "ext-gmp": "*",
"ext-xml": "*", "ext-xml": "*",
"leigh/chacha20": "^0.2.0",
"paragonie/ecc": "^2.4", "paragonie/ecc": "^2.4",
"php": ">=8.1 <8.5", "php": ">=8.2 <8.6",
"phrity/websocket": "^3.0", "phrity/websocket": "^3.0",
"simplito/elliptic-php": "^1.0" "simplito/elliptic-php": "^1.0"
}, },
@ -3947,9 +3952,9 @@
"chat": "https://t.me/nostr_php", "chat": "https://t.me/nostr_php",
"issue": "https://github.com/swentel/nostr-php/issues", "issue": "https://github.com/swentel/nostr-php/issues",
"issues": "https://github.com/nostrver-se/nostr-php/issues", "issues": "https://github.com/nostrver-se/nostr-php/issues",
"source": "https://github.com/nostrver-se/nostr-php/tree/1.9.2" "source": "https://github.com/nostrver-se/nostr-php/tree/1.9.4"
}, },
"time": "2025-06-04T14:51:06+00:00" "time": "2026-02-03T11:13:00+00:00"
}, },
{ {
"name": "symfony/asset", "name": "symfony/asset",
@ -11316,5 +11321,5 @@
"ext-openssl": "*" "ext-openssl": "*"
}, },
"platform-dev": {}, "platform-dev": {},
"plugin-api-version": "2.6.0" "plugin-api-version": "2.9.0"
} }

4
config/packages/asset_mapper.yaml

@ -1,5 +1,9 @@
framework: framework:
asset_mapper: asset_mapper:
# es-module-shims + native import maps can trigger "Multiple import maps are not allowed"
# in current browsers; rely on native import map support (Chrome 89+, Firefox 108+, Safari 16.4+).
# Re-enable the polyfill for older clients: set to `es-module-shims` and add the package in importmap.php.
importmap_polyfill: false
# The paths to make available to the asset mapper. # The paths to make available to the asset mapper.
paths: paths:
- assets/theme/local # Highest priority (overrides) - assets/theme/local # Highest priority (overrides)

5
config/packages/framework.yaml

@ -10,11 +10,14 @@ framework:
cookie_samesite: lax cookie_samesite: lax
cookie_lifetime: 0 # integer, lifetime in seconds, 0 means 'valid for the length of the browser session' cookie_lifetime: 0 # integer, lifetime in seconds, 0 means 'valid for the length of the browser session'
trusted_headers: ['forwarded', 'x-forwarded-for', 'x-forwarded-proto'] trusted_headers: ['forwarded', 'x-forwarded-for', 'x-forwarded-proto']
# trusted_proxies: '%env(TRUSTED_PROXIES)%'
#trusted_proxies: 'symfony,REMOTE_ADDR' #trusted_proxies: 'symfony,REMOTE_ADDR'
#esi: true #esi: true
#fragments: true #fragments: true
when@prod:
framework:
trusted_proxies: '%env(TRUSTED_PROXIES)%'
when@test: when@test:
framework: framework:
test: true test: true

14
config/services.yaml

@ -8,6 +8,8 @@ imports:
parameters: parameters:
footer_links: '%kernel.project_dir%/config/unfold.yaml' footer_links: '%kernel.project_dir%/config/unfold.yaml'
# Default when TRUSTED_PROXIES is unset (override in .env / Compose for real deployments).
env(TRUSTED_PROXIES): '127.0.0.0/8,::1'
services: services:
# default configuration for services in *this* file # default configuration for services in *this* file
@ -32,7 +34,19 @@ services:
App\Service\NostrClient: App\Service\NostrClient:
arguments: arguments:
$defaultRelayUrl: '%default_relay%' $defaultRelayUrl: '%default_relay%'
$articleRelayUrls: '%article_relays%'
$profileRelayUrls: '%profile_relays%'
App\Twig\FooterLinksExtension: App\Twig\FooterLinksExtension:
arguments: arguments:
$footerLinksPath: '%footer_links%' $footerLinksPath: '%footer_links%'
tags: [ 'twig.extension' ] tags: [ 'twig.extension' ]
# Nostr index snapshots: distinct key prefix from other cache.app users.
App\Service\MagazineIndexStore:
arguments:
$pool: '@cache.app'
App\Service\MagazineRefresher:
arguments:
$appCache: '@cache.app'
App\Service\CacheService:
arguments:
$appCache: '@cache.app'

12
config/unfold.yaml

@ -9,6 +9,18 @@ parameters:
og_subheading: 'Imwald Blog by Laeserin' og_subheading: 'Imwald Blog by Laeserin'
default_relay: 'wss://TheForest.nostr1.com' default_relay: 'wss://TheForest.nostr1.com'
# Extra wss:// URLs for article sync (articles:get), comment threads (NIP-22 / getArticleDiscussion),
# and any request that merges the default set with author-specific relays. default_relay is first; duplicates ignored.
article_relays: ['wss://christpill.nostr1.com', 'wss://nostr.land', 'wss://nostr.wine', 'wss://nostr21.com', 'wss://nostr.sovbit.host']
# Kind-0 / profile fetches (author metadata, prewarm). Tried first, then default + article_relays (deduped).
profile_relays:
- 'wss://relay.damus.io'
- 'wss://nos.lol'
- 'wss://profiles.nostr1.com'
# Example:
# article_relays:
# - 'wss://nos.lol'
# - 'wss://relay.ditto.pub'
theme: 'imwald' theme: 'imwald'
theme_color: '#8c2f1c' theme_color: '#8c2f1c'

35
docker/cron/Dockerfile

@ -1,30 +1,21 @@
FROM php:8.2-cli FROM php:8.3-cli
# Install cron and Redis PHP extension dependencies RUN apt-get update && apt-get install -y --no-install-recommends \
RUN apt-get update && apt-get install -y \ bash \
cron \ cron \
libzip-dev \ && rm -rf /var/lib/apt/lists/*
libicu-dev \
libpq-dev \
libonig-dev
ADD https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
RUN chmod +x /usr/local/bin/install-php-extensions \
&& install-php-extensions pdo_mysql intl opcache zip gmp
# Set working directory
WORKDIR /var/www/html WORKDIR /var/www/html
# Install Symfony CLI tools (optional) COPY crontab /etc/cron.d/unfold-prewarm
# RUN curl -sS https://get.symfony.com/cli/installer | bash COPY prewarm_cron.sh /prewarm_cron.sh
COPY entry-cron.sh /entry-cron.sh
# Copy cron and script RUN chmod 0644 /etc/cron.d/unfold-prewarm \
COPY crontab /etc/cron.d/app-cron && chmod +x /prewarm_cron.sh /entry-cron.sh
COPY index_articles.sh /index_articles.sh
# Set permissions CMD ["/entry-cron.sh"]
RUN chmod 0644 /etc/cron.d/app-cron && \
chmod +x /index_articles.sh
# Apply cron job
RUN crontab /etc/cron.d/app-cron
# Run cron in the foreground
CMD ["cron", "-f"]

95
docker/cron/README.md

@ -1,94 +1,15 @@
# `cron` service (Docker)
# 🕒 Cron Job Container The `cron` image runs a single job: **`php bin/console app:prewarm` every 10 minutes**, against the app tree bind-mounted at `/var/www/html`.
This folder contains the Docker configuration to run scheduled Symfony commands via cron inside a separate container. - **Flags:** set **`PREWARM_FLAGS`** in the project `.env` (Compose injects it). Example: `PREWARM_FLAGS="--metadata-limit=30 --no-magazine"`. After editing, run `docker compose up -d --force-recreate cron` (or `docker compose up -d cron`) so the container gets the new value. If unset, `app:prewarm` uses its **built-in defaults** (same idea as running the console with no args).
- Run Symfony console commands periodically using a cron schedule (e.g. every 6 hours) - **How env reaches cron:** the entrypoint writes `PREWARM_FLAGS` to `/run/cron-prewarm.env` at boot, because the system `crond` does not pass the container environment into crontab jobs.
- Decouple scheduled jobs from the main PHP/FPM container
- Easily manage and test cron execution in a Dockerized Symfony project
--- - **Logs inside the container:** `tail -f /var/log/cron.log` (e.g. `docker compose exec cron tail -f /var/log/cron.log`).
## Build & Run - **PHP 8.3** extensions in the image are limited to what `app:prewarm` needs; the host **vendor** tree is what you mount from the repo.
1. **Build the cron image** - **Not included in** `compose.hub.yaml` (no app source mount). For production images, use host **cron** / **systemd** to `exec` the same command.
From the project root:
```bash
docker-compose build cron
```
2. **Start the cron container** Change the schedule: edit `docker/cron/crontab`, then `docker compose build cron && docker compose up -d cron`.
```bash
docker-compose up -d cron
```
---
## Cron Schedule
The default cron schedule is set to run **every 6 hours**:
```cron
0 */6 * * * root /run_commands.sh >> /var/log/cron.log 2>&1
```
To customize the schedule, edit the `crontab` file and rebuild the container.
---
## Testing & Debugging
### Manually test the command runner
You can run the script manually to check behavior without waiting for the cron trigger:
```bash
docker-compose exec cron /run_commands.sh
```
### Check the cron output log
```bash
docker-compose exec cron tail -f /var/log/cron.log
```
### Shell into the cron container
```bash
docker-compose exec cron bash
```
Once inside, you can:
- Check crontab entries: `crontab -l`
- Manually trigger cron: `cron` or `cron -f` (in another session)
---
## Customization
- **Add/Remove Symfony Commands:**
Edit `run_commands.sh` to include the commands you want to run.
- **Change Schedule:**
Edit `crontab` using standard cron syntax.
- **Logging:**
Logs are sent to `/var/log/cron.log` inside the container.
---
## Rebuilding After Changes
If you modify the `crontab` or `run_commands.sh`, make sure to rebuild:
```bash
docker-compose build cron
docker-compose up -d cron
```
---
## Notes
- Symfony project source is mounted at `/var/www/html` via volume.
- Make sure your commands do **not rely on services** (like `php-fpm`) that are not running in this container.

2
docker/cron/crontab

@ -1 +1 @@
*/5 * * * * /index_articles.sh >> /var/log/cron.log 2>&1 */10 * * * * root /prewarm_cron.sh >>/var/log/cron.log 2>&1

6
docker/cron/entry-cron.sh

@ -0,0 +1,6 @@
#!/bin/bash
set -euo pipefail
# crond does not pass Compose env to job children; write once at boot for prewarm_cron.sh.
export PREWARM_FLAGS="${PREWARM_FLAGS:-}"
declare -p PREWARM_FLAGS >/run/cron-prewarm.env
exec cron -f

5
docker/cron/index_articles.sh

@ -1,5 +0,0 @@
#!/bin/bash
set -e
export PATH="/usr/local/bin:/usr/bin:/bin"
php /var/www/html/bin/console articles:get -- '-6 min' 'now'

9
docker/cron/prewarm_cron.sh

@ -0,0 +1,9 @@
#!/bin/bash
set -euo pipefail
# shellcheck source=/dev/null
if [[ -f /run/cron-prewarm.env ]]; then
source /run/cron-prewarm.env
fi
cd /var/www/html
# shellcheck disable=SC2086
exec php bin/console app:prewarm ${PREWARM_FLAGS:-}

2
frankenphp/conf.d/10-app.ini

@ -1,4 +1,6 @@
expose_php = 0 expose_php = 0
; Default 128M is tight for long-form sync (large event JSON + Doctrine). Chunking helps; this adds headroom.
memory_limit = 256M
date.timezone = UTC date.timezone = UTC
apc.enable_cli = 1 apc.enable_cli = 1
session.use_strict_mode = 1 session.use_strict_mode = 1

2
frankenphp/conf.d/20-app.dev.ini

@ -2,4 +2,6 @@
; See https://github.com/docker/for-linux/issues/264 ; See https://github.com/docker/for-linux/issues/264
; The `client_host` below may optionally be replaced with `discover_client_host=yes` ; The `client_host` below may optionally be replaced with `discover_client_host=yes`
; Add `start_with_request=yes` to start debug session on each request ; Add `start_with_request=yes` to start debug session on each request
; develop is required for xdebug_info() and related helpers. Docker sets XDEBUG_MODE (overrides this).
xdebug.mode = develop
xdebug.client_host = host.docker.internal xdebug.client_host = host.docker.internal

12
frankenphp/docker-entrypoint.sh

@ -16,7 +16,7 @@ if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then
composer require "php:>=$PHP_VERSION" runtime/frankenphp-symfony composer require "php:>=$PHP_VERSION" runtime/frankenphp-symfony
composer config --json extra.symfony.docker 'true' composer config --json extra.symfony.docker 'true'
if grep -q ^DATABASE_URL= .env; then if [ -f .env ] && grep -q ^DATABASE_URL= .env; then
echo 'To finish the installation please press Ctrl+C to stop Docker Compose and run: docker compose up --build -d --wait' echo 'To finish the installation please press Ctrl+C to stop Docker Compose and run: docker compose up --build -d --wait'
sleep infinity sleep infinity
fi fi
@ -26,7 +26,8 @@ if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then
composer install --prefer-dist --no-progress --no-interaction composer install --prefer-dist --no-progress --no-interaction
fi fi
if grep -q ^DATABASE_URL= .env; then # DATABASE_URL from Compose / k8s env, or from a local .env file (dev bind-mount).
if [ -n "${DATABASE_URL:-}" ] || { [ -f .env ] && grep -q ^DATABASE_URL= .env; }; then
echo 'Waiting for database to be ready...' echo 'Waiting for database to be ready...'
ATTEMPTS_LEFT_TO_REACH_DATABASE=60 ATTEMPTS_LEFT_TO_REACH_DATABASE=60
until [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ] || DATABASE_ERROR=$(php bin/console dbal:run-sql -q "SELECT 1" 2>&1); do until [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ] || DATABASE_ERROR=$(php bin/console dbal:run-sql -q "SELECT 1" 2>&1); do
@ -51,6 +52,13 @@ if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then
if [ "$( find ./migrations -iname '*.php' -print -quit )" ]; then if [ "$( find ./migrations -iname '*.php' -print -quit )" ]; then
php bin/console doctrine:migrations:migrate --no-interaction --all-or-nothing php bin/console doctrine:migrations:migrate --no-interaction --all-or-nothing
fi fi
# Optional: warm magazine index + metadata cache on container start (does not run articles:get).
# Prefer ./scripts/docker-prewarm.sh or `make prewarm` for full DB + relay backfill from the host.
if [ "${PREWARM_ON_START:-0}" = "1" ]; then
echo "PREWARM_ON_START=1: running app:prewarm..."
php bin/console app:prewarm || true
fi
fi fi
setfacl -R -m u:www-data:rwX -m u:"$(whoami)":rwX var setfacl -R -m u:www-data:rwX -m u:"$(whoami)":rwX var

3
importmap.php

@ -57,7 +57,4 @@ return [
'version' => '2.0.3', 'version' => '2.0.3',
'type' => 'css', 'type' => 'css',
], ],
'es-module-shims' => [
'version' => '2.0.10',
],
]; ];

22
scripts/docker-prewarm.sh

@ -0,0 +1,22 @@
#!/usr/bin/env sh
# After `docker compose up`, run migrations, backfill long-form articles, and app:prewarm.
# Usage: from repository root: ./scripts/docker-prewarm.sh
# Or: make prewarm
set -eu
ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$ROOT_DIR"
echo "==> docker compose up -d --wait (php, database, cron: full app:prewarm every 10 min; set PREWARM_FLAGS in .env to match your CLI)"
docker compose up -d --wait
echo "==> doctrine:migrations:migrate"
docker compose exec -T php php bin/console doctrine:migrations:migrate --no-interaction
echo "==> articles:get (last 2 months → now)"
docker compose exec -T php php bin/console articles:get -- '-2 month' 'now'
echo "==> app:prewarm"
docker compose exec -T php php bin/console app:prewarm
echo "Done."

250
src/Command/PrewarmCommand.php

@ -0,0 +1,250 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\Article;
use App\Repository\ArticleRepository;
use App\Service\ArticleCommentThreadLoader;
use App\Service\CacheService;
use App\Service\MagazineRefresher;
use App\Service\Nip09DeletionApplier;
use App\Service\NostrClient;
use Psr\Log\LoggerInterface;
use swentel\nostr\Key\Key;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
/**
* Prewarms magazine index cache, author metadata cache, and optional comment thread cache.
* Does not persist comments to MySQL; comments are cache-only in this app.
*/
#[AsCommand(
name: 'app:prewarm',
description: 'Refresh magazine indices, NIP-09 deletions, profile metadata, and comment caches',
)]
final class PrewarmCommand extends Command
{
public function __construct(
private readonly MagazineRefresher $magazineRefresher,
private readonly Nip09DeletionApplier $nip09DeletionApplier,
private readonly CacheService $cacheService,
private readonly NostrClient $nostrClient,
private readonly ArticleRepository $articleRepository,
private readonly ArticleCommentThreadLoader $commentThreadLoader,
private readonly ParameterBagInterface $params,
private readonly LoggerInterface $logger,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('no-magazine', null, InputOption::VALUE_NONE, 'Skip magazine 30040 index fetch')
->addOption('no-deletions', null, InputOption::VALUE_NONE, 'Skip NIP-09 kind 5 deletion sync (30023/30024 DB + 30040 magazine cache)')
->addOption('deletion-since', null, InputOption::VALUE_REQUIRED, 'strtotime() window start for kind 5 fetch', '-2 month')
->addOption('no-metadata', null, InputOption::VALUE_NONE, 'Skip Nostr profile metadata cache')
->addOption('no-comments', null, InputOption::VALUE_NONE, 'Skip comment thread cache')
->addOption('magazine-budget', null, InputOption::VALUE_REQUIRED, 'Seconds wall time for magazine relay refresh', '30')
->addOption('metadata-limit', null, InputOption::VALUE_REQUIRED, 'Max distinct author pubkeys to warm (0 = all)', '0')
->addOption('metadata-batch', null, InputOption::VALUE_REQUIRED, 'Kind-0 metadata: pubkeys per Nostr REQ (batched)', '50')
->addOption('comments-max', null, InputOption::VALUE_REQUIRED, 'Newest N articles to warm comment cache for (0 = all, order: createdAt DESC)', '20')
->addOption('comments-budget', null, InputOption::VALUE_REQUIRED, 'Max seconds for the whole comments phase', '120');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->disableCliExecutionTimeLimit();
$io = new SymfonyStyle($input, $output);
$keys = new Key();
if (!$input->getOption('no-magazine')) {
$budget = max(1, (int) $input->getOption('magazine-budget'));
$io->section('Magazine index (kinds 30040)');
try {
$this->magazineRefresher->refreshFromRelays($budget, []);
$io->success('Magazine indices refreshed (within budget).');
} catch (\Throwable $e) {
$this->logger->error('app:prewarm magazine failed', ['e' => $e]);
$io->warning('Magazine refresh failed: '.$e->getMessage());
}
} else {
$io->note('Skipping magazine (--no-magazine).');
}
// MagazineRefresher sets max_execution_time (e.g. 60 for budget 30); restore before metadata.
$this->disableCliExecutionTimeLimit();
if (!$input->getOption('no-deletions')) {
$io->section('NIP-09 deletions (kind 5 → 30023/30024 / 30040)');
$sinceStr = (string) $input->getOption('deletion-since');
$since = strtotime($sinceStr);
if ($since === false) {
$since = strtotime('-2 month');
}
$until = time();
$deletionPubkeys = [];
foreach ($this->articleRepository->findDistinctAuthorPubkeys() as $pk) {
if (\is_string($pk) && 64 === \strlen($pk)) {
$deletionPubkeys[] = $pk;
}
}
$npubParam = (string) $this->params->get('npub');
if (str_starts_with($npubParam, 'npub')) {
try {
$sitePk = $keys->convertToHex($npubParam);
if ($sitePk !== '' && 64 === \strlen($sitePk) && !\in_array($sitePk, $deletionPubkeys, true)) {
$deletionPubkeys[] = $sitePk;
}
} catch (\Throwable) {
}
}
if ($deletionPubkeys === []) {
$io->note('No author pubkeys; skipping kind 5 deletion fetch.');
} else {
try {
$kind5 = $this->nostrClient->fetchKind5DeletionEventsForAuthors(
$deletionPubkeys,
$since,
$until,
40
);
$st = $this->nip09DeletionApplier->apply($kind5);
$io->writeln(sprintf(
'Kind 5 events: <info>%d</info> (deduped). Articles removed: <info>%d</info>; magazine root/category cache entries removed: <info>%d</info> / <info>%d</info>.',
\count($kind5),
$st['articles_removed'],
$st['magazine_roots'],
$st['magazine_categories']
));
} catch (\Throwable $e) {
$this->logger->error('app:prewarm NIP-09 failed', ['exception' => $e]);
$io->warning('NIP-09 step failed: '.$e->getMessage());
}
}
} else {
$io->note('Skipping NIP-09 deletions (--no-deletions).');
}
$this->disableCliExecutionTimeLimit();
if (!$input->getOption('no-metadata')) {
$io->section('Author metadata (cache)');
$pubkeys = $this->articleRepository->findDistinctAuthorPubkeys();
$npubParam = (string) $this->params->get('npub');
if (str_starts_with($npubParam, 'npub')) {
try {
$sitePk = $keys->convertToHex($npubParam);
if ($sitePk !== '' && !\in_array($sitePk, $pubkeys, true)) {
$pubkeys[] = $sitePk;
}
} catch (\Throwable) {
// ignore bad npub
}
}
$limit = (int) $input->getOption('metadata-limit');
if ($limit > 0) {
$pubkeys = \array_slice($pubkeys, 0, $limit);
}
$toWarm = [];
foreach ($pubkeys as $pubkey) {
if (strlen($pubkey) === 64) {
$toWarm[] = $pubkey;
}
}
$total = \count($toWarm);
$n = 0;
if ($total === 0) {
$io->note('No valid author pubkeys to warm.');
} else {
$batchSize = max(1, min(200, (int) $input->getOption('metadata-batch')));
$io->writeln(sprintf(
'Fetching kind-0 metadata: <info>%d</info> author(s) in Nostr requests of up to <info>%d</info> pubkeys each.',
$total,
$batchSize
));
$bar = $io->createProgressBar($total);
$bar->start();
try {
foreach (array_chunk($toWarm, $batchSize) as $chunk) {
$fetched = $this->nostrClient->fetchKind0MetadataForAuthors($chunk, $batchSize);
$n += $this->cacheService->putPrewarmMetadataBatch($chunk, $fetched, $keys);
$bar->advance(\count($chunk));
}
} catch (\Throwable $e) {
$this->logger->error('app:prewarm metadata batch failed', ['exception' => $e]);
$io->error($e->getMessage());
$bar->finish();
$io->newLine(2);
return Command::FAILURE;
}
$bar->finish();
$io->newLine(2);
}
$io->success(sprintf('Warmed metadata for %d of %d author(s).', $n, $total));
} else {
$io->note('Skipping metadata (--no-metadata).');
}
if ($input->getOption('no-comments')) {
$io->note('Skipping comments (--no-comments).');
return Command::SUCCESS;
}
$maxArticles = (int) $input->getOption('comments-max');
$io->section('Comment / interaction cache');
$deadline = microtime(true) + max(1, (int) $input->getOption('comments-budget'));
$qb = $this->articleRepository->createQueryBuilder('a')
->where('a.slug IS NOT NULL')
->andWhere("a.slug != ''")
->andWhere('a.pubkey IS NOT NULL')
->andWhere("a.pubkey != ''")
->orderBy('a.createdAt', 'DESC');
if ($maxArticles > 0) {
$qb->setMaxResults($maxArticles);
}
$articles = $qb->getQuery()->getResult();
$w = 0;
/** @var Article $article */
foreach ($articles as $article) {
if (microtime(true) >= $deadline) {
$io->warning('Comment phase stopped: comments-budget reached.');
break;
}
$slug = trim((string) $article->getSlug());
$pubkey = (string) $article->getPubkey();
if ($slug === '' || strlen($pubkey) !== 64) {
continue;
}
$kind = $article->getKind()?->value ?? 30023;
$coordinate = $kind.':'.$pubkey.':'.$slug;
$eventHex = (string) ($article->getEventId() ?? '');
try {
$this->commentThreadLoader->load($coordinate, $eventHex !== '' ? $eventHex : null);
++$w;
} catch (\Throwable $e) {
$this->logger->warning('app:prewarm comment load', ['coord' => $coordinate, 'error' => $e->getMessage()]);
}
}
$io->success(sprintf('Warmed comment cache for %d of %d article(s).', $w, \count($articles)));
return Command::SUCCESS;
}
private function disableCliExecutionTimeLimit(): void
{
@set_time_limit(0);
@ini_set('max_execution_time', '0');
}
}

52
src/Controller/ArticleController.php

@ -208,45 +208,64 @@ class ArticleController extends AbstractController
CacheItemPoolInterface $articlesCache CacheItemPoolInterface $articlesCache
): Response { ): Response {
$data = $request->getContent(); $data = $request->getContent();
// descriptor is an object with properties type, identifier and data
// if type === 'nevent', identifier is the event id
// if type === 'naddr', identifier is the naddr
// if type === 'nprofile', identifier is the npub
$descriptor = json_decode($data); $descriptor = json_decode($data);
$previewData = [];
// if nprofile, get from redis cache if (!\is_object($descriptor) || !isset($descriptor->type)) {
return new Response(
'<span class="text-subtle">Invalid preview request.</span>',
Response::HTTP_OK,
['Content-Type' => 'text/html; charset=UTF-8']
);
}
$html = '';
try {
if ($descriptor->type === 'nprofile') { if ($descriptor->type === 'nprofile') {
if (!isset($descriptor->decoded) || !\is_string($descriptor->decoded)) {
$html = '<span class="text-subtle">Profile preview unavailable.</span>';
} else {
$hint = json_decode($descriptor->decoded); $hint = json_decode($descriptor->decoded);
if (!\is_object($hint) || !isset($hint->pubkey)) {
$html = '<span class="text-subtle">Profile preview unavailable.</span>';
} else {
$key = new Key(); $key = new Key();
$npub = $key->convertPublicKeyToBech32($hint->pubkey); $npub = $key->convertPublicKeyToBech32($hint->pubkey);
$metadata = $cacheService->getMetadata($npub); $metadata = $cacheService->getMetadata($npub);
$metadata->npub = $npub; $metadata->npub = $npub;
$metadata->pubkey = $hint->pubkey; $metadata->pubkey = $hint->pubkey;
$metadata->type = 'nprofile'; $metadata->type = 'nprofile';
// Render the NostrPreviewContent component with the preview data
$html = $this->renderView('components/Molecules/NostrPreviewContent.html.twig', [ $html = $this->renderView('components/Molecules/NostrPreviewContent.html.twig', [
'preview' => $metadata 'preview' => $metadata,
]); ]);
}
}
} elseif (!isset($descriptor->decoded)) {
$html = '<span class="text-subtle">Preview unavailable (missing data).</span>';
} else { } else {
// For nevent or naddr, fetch the event data
try { try {
$previewData = $nostrClient->getEventFromDescriptor($descriptor); $previewData = $nostrClient->getEventFromDescriptor($descriptor);
$previewData->type = $descriptor->type; // Add type to the preview data } catch (\Throwable $e) {
// Render the NostrPreviewContent component with the preview data $previewData = null;
$html = '<span class="text-subtle">Error fetching preview: '.htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').'</span>';
}
if ($html === '' && $previewData === null) {
$html = '<span class="text-subtle">No event found on the default relay for this preview.</span>';
} elseif ($html === '' && \is_object($previewData)) {
$previewData->type = $descriptor->type;
$html = $this->renderView('components/Molecules/NostrPreviewContent.html.twig', [ $html = $this->renderView('components/Molecules/NostrPreviewContent.html.twig', [
'preview' => $previewData 'preview' => $previewData,
]); ]);
} catch (\Exception $e) {
$html = '<span>Error fetching preview: ' . htmlspecialchars($e->getMessage()) . '</span>';
} }
} }
} catch (\Throwable $e) {
$html = '<span class="text-subtle">Preview error: '.htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').'</span>';
}
return new Response( return new Response(
$html, $html,
Response::HTTP_OK, Response::HTTP_OK,
['Content-Type' => 'text/html'] ['Content-Type' => 'text/html; charset=UTF-8']
); );
} }
@ -359,6 +378,7 @@ class ArticleController extends AbstractController
return $this->render('pages/category.html.twig', [ return $this->render('pages/category.html.twig', [
'category' => $category, 'category' => $category,
'list' => $articles, 'list' => $articles,
'sync_slug' => '',
]); ]);
} }

128
src/Controller/DefaultController.php

@ -4,134 +4,37 @@ declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use App\Repository\ArticleRepository; use App\Service\MagazineContentService;
use App\Service\NostrClient;
use Exception; use Exception;
use Psr\Cache\InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface;
use Psr\Log\LoggerInterface;
class DefaultController extends AbstractController class DefaultController extends AbstractController
{ {
public function __construct( public function __construct(
private readonly CacheInterface $cache, private readonly MagazineContentService $magazineContent,
private readonly NostrClient $nostrClient, ) {
private readonly ParameterBagInterface $params }
) {}
/**
* @throws Exception
* @throws InvalidArgumentException
*/
#[Route('/', name: 'home')] #[Route('/', name: 'home')]
public function index(): Response public function index(): Response
{ {
$npub = $this->params->get('npub');
$dTag = $this->params->get('d_tag');
// Key must match {@see Header} — `magazine_root_` avoids stale `null` entries from the old Header callback.
$cacheKey = 'magazine_root_'.$dTag;
$mag = $this->cache->get($cacheKey, function ($item) use ($npub, $dTag) {
$item->expiresAfter(300); // 5 minutes
return $this->nostrClient->getMagazineIndex($npub, $dTag);
});
// Handle case when magazine is not found
if ($mag === null) {
return $this->render('home.html.twig', [
'indices' => []
]);
}
$tags = $mag->getTags();
$cats = array_filter($tags, function($tag) {
return ($tag[0] === 'a');
});
return $this->render('home.html.twig', [ return $this->render('home.html.twig', [
'indices' => array_values($cats) 'indices' => $this->magazineContent->getHomeCategoryIndexTags(),
]); ]);
} }
/**
* @throws InvalidArgumentException
*/
#[Route('/cat/{slug}', name: 'magazine-category')] #[Route('/cat/{slug}', name: 'magazine-category')]
public function magCategory($slug, ArticleRepository $articleRepository, LoggerInterface $logger): Response public function magCategory(string $slug): Response
{ {
$npub = $this->params->get('npub'); $data = $this->magazineContent->getCategoryPageData($slug);
$cacheKey = 'magazine-' . $slug;
try {
$catIndex = $this->cache->get($cacheKey, function ($item) use ($npub, $slug) {
$item->expiresAfter(300); // 5 minutes
$mag = $this->nostrClient->getMagazineIndex($npub, $slug);
if ($mag === null) {
throw new \RuntimeException('Category index not found for '.$slug);
}
return $mag;
});
} catch (\Throwable) {
$catIndex = null;
}
$list = [];
$coordinates = [];
$category = [];
if ($catIndex) {
foreach ($catIndex->getTags() as $tag) {
if ($tag[0] === 'title') {
$category['title'] = $tag[1];
}
if ($tag[0] === 'summary') {
$category['summary'] = $tag[1];
}
if ($tag[0] === 'a') {
$coordinates[] = $tag[1];
}
}
}
if (!empty($coordinates)) {
$slugs = array_map(function($coordinate) {
$parts = explode(':', $coordinate, 3);
return end($parts);
}, $coordinates);
$slugs = array_filter($slugs);
$articles = $articleRepository->findBySlugsCriteria($slugs);
$slugMap = [];
foreach ($articles as $item) {
$slug = $item->getSlug();
if ($slug !== '') {
if (!isset($slugMap[$slug])) {
$slugMap[$slug] = $item;
} else {
$existingItem = $slugMap[$slug];
if ($item->getCreatedAt() > $existingItem->getCreatedAt()) {
$slugMap[$slug] = $item;
}
}
}
}
foreach ($coordinates as $coordinate) {
$parts = explode(':', $coordinate, 3);
if (isset($slugMap[end($parts)])) {
$list[] = $slugMap[end($parts)];
}
}
}
$category['title'] = $category['title'] ?? '';
$category['summary'] = $category['summary'] ?? '';
return $this->render('pages/category.html.twig', [ return $this->render('pages/category.html.twig', [
'list' => $list, 'list' => $data['list'],
'category' => $category 'category' => $data['category'],
'sync_slug' => $slug,
]); ]);
} }
@ -151,18 +54,19 @@ class DefaultController extends AbstractController
$embed = new \Embed\Embed(); $embed = new \Embed\Embed();
$info = $embed->get($url); $info = $embed->get($url);
if (!$info) { if (!$info) {
throw new \Exception('No OG data found'); throw new Exception('No OG data found');
} }
return $this->render('components/Molecules/OgPreview.html.twig', [ return $this->render('components/Molecules/OgPreview.html.twig', [
'og' => [ 'og' => [
'title' => $info->title, 'title' => $info->title,
'description' => $info->description, 'description' => $info->description,
'image' => $info->image, 'image' => $info->image,
'url' => $url 'url' => $url,
] ],
]); ]);
} catch (\Exception $e) { } catch (Exception $e) {
return new Response('<div class="alert alert-warning">Unable to load OG preview for ' . htmlspecialchars($url) . '</div>', 200); return new Response('<div class="alert alert-warning">Unable to load OG preview for '.htmlspecialchars($url).'</div>', 200);
} }
} }
} }

103
src/Controller/MagazineSyncController.php

@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Service\MagazineContentService;
use App\Service\MagazineRefresher;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Twig\Environment;
/** Stale-first: the main request only reads {@see \App\Service\MagazineIndexStore}; this refetches Nostr, updates that store, and returns HTML fragments for Stimulus to patch the document. */
#[AsController]
final class MagazineSyncController
{
public function __construct(
private readonly Environment $twig,
private readonly MagazineRefresher $refresher,
private readonly MagazineContentService $magazineContent,
private readonly ParameterBagInterface $params,
private readonly LoggerInterface $logger,
) {
}
#[Route('/ux/magazine-sync', name: 'ux_magazine_sync', methods: ['GET'])]
public function __invoke(Request $request): JsonResponse
{
try {
$page = (string) $request->query->get('page', 'article');
if (!\in_array($page, ['home', 'category', 'article', 'articles'], true)) {
$page = 'article';
}
$slug = (string) $request->query->get('slug', '');
$prefer = $slug !== '' ? [$slug] : [];
try {
$this->refresher->refreshFromRelays(20, $prefer);
} catch (\Throwable $e) {
$this->logger->warning('MagazineSyncController: refresh failed', [
'message' => $e->getMessage(),
'exception' => $e,
]);
return new JsonResponse(
['ok' => false, 'error' => 'refresh_failed', 'message' => $e->getMessage()],
Response::HTTP_OK
);
}
$community = (bool) $this->params->get('community_articles');
$tags = $this->magazineContent->getHomeCategoryAIndexTagsFromStoreOnly();
$globals = [
'magazine_community_articles' => $community,
];
$header = $this->twig->render('ux/magazine/header_ul.html.twig', array_merge($globals, [
'cats' => $tags,
]));
$body = null;
if ($page === 'home') {
$body = $this->twig->render('ux/magazine/home_body.html.twig', array_merge($globals, [
'indices' => $tags,
]));
} elseif ($page === 'category' && $slug !== '') {
$data = $this->magazineContent->getCategoryPageData($slug);
$body = $this->twig->render('ux/magazine/category_body.html.twig', array_merge($globals, [
'list' => $data['list'],
'category' => $data['category'],
]));
} elseif ($page === 'articles') {
$body = null;
}
return new JsonResponse([
'ok' => true,
'header' => $header,
'body' => $body,
]);
} catch (\Throwable $e) {
$this->logger->error('MagazineSyncController: unexpected failure', [
'message' => $e->getMessage(),
'exception' => $e,
]);
return new JsonResponse(
[
'ok' => false,
'error' => 'server_error',
'message' => 'Magazine UI sync could not be rendered.',
],
Response::HTTP_OK
);
}
}
}

1
src/Enum/KindsEnum.php

@ -5,6 +5,7 @@ namespace App\Enum;
enum KindsEnum: int enum KindsEnum: int
{ {
case METADATA = 0; // metadata, NIP-01 case METADATA = 0; // metadata, NIP-01
case DELETION_REQUEST = 5; // NIP-09
case TEXT_NOTE = 1; // text note, NIP-01, will not implement case TEXT_NOTE = 1; // text note, NIP-01, will not implement
case FOLLOWS = 3; case FOLLOWS = 3;
case REPOST = 6; // Only wraps kind 1, NIP-18, will not implement case REPOST = 6; // Only wraps kind 1, NIP-18, will not implement

61
src/Repository/ArticleRepository.php

@ -69,6 +69,67 @@ class ArticleRepository extends ServiceEntityRepository
->getResult(); ->getResult();
} }
/**
* Resolve NIP-33 `a` tags (kind:pubkey:identifier) to articles without conflating the same
* #d value across different authors.
*
* @param list<array{pubkey: string, slug: string}> $pairs
* @return array<string, Article> key "pubkey\0slug" (lowercase hex pubkey, trimmed slug)
*/
public function findByAuthorAndSlugIndexed(array $pairs): array
{
$pairs = array_values(array_filter($pairs, static fn (array $p): bool => $p['pubkey'] !== '' && $p['slug'] !== ''));
if ($pairs === []) {
return [];
}
$qb = $this->createQueryBuilder('a');
$orX = $qb->expr()->orX();
foreach ($pairs as $i => $p) {
$orX->add($qb->expr()->andX(
$qb->expr()->eq('a.pubkey', ':pk'.$i),
$qb->expr()->eq('a.slug', ':sl'.$i)
));
$qb->setParameter('pk'.$i, $p['pubkey']);
$qb->setParameter('sl'.$i, $p['slug']);
}
$qb->where($orX);
/** @var list<Article> $rows */
$rows = $qb->getQuery()->getResult();
$out = [];
foreach ($rows as $a) {
$pk = (string) $a->getPubkey();
$sl = trim((string) $a->getSlug());
if ($sl !== '') {
$out[$pk."\0".$sl] = $a;
}
}
return $out;
}
/**
* Distinct hex pubkeys for prewarming Nostr profile cache.
*
* @return list<string>
*/
public function findDistinctAuthorPubkeys(): array
{
return $this->createQueryBuilder('a')
->select('a.pubkey')
->distinct()
->where('a.pubkey IS NOT NULL')
->andWhere("a.pubkey != ''")
->getQuery()
->getSingleColumnResult();
}
public function findOneByEventId(string $eventId): ?Article
{
return $this->findOneBy(['eventId' => $eventId]);
}
/** /**
* Find articles by author's public key * Find articles by author's public key
*/ */

13
src/Service/ArticleCommentThreadLoader.php

@ -47,7 +47,7 @@ final readonly class ArticleCommentThreadLoader
'elapsed_since_load_start_ms' => (int) round((microtime(true) - $t0) * 1000), 'elapsed_since_load_start_ms' => (int) round((microtime(true) - $t0) * 1000),
]); ]);
$tNostr = microtime(true); $tNostr = microtime(true);
try { // On failure, let this throw: Symfony cache will not store a value, so a prior good thread is not replaced by [].
$out = $this->nostrClient->getArticleDiscussion($coordinate, $articleEventHexId); $out = $this->nostrClient->getArticleDiscussion($coordinate, $articleEventHexId);
$this->logger->info('comments.loader.nostr_ok', [ $this->logger->info('comments.loader.nostr_ok', [
'nostr_elapsed_ms' => (int) round((microtime(true) - $tNostr) * 1000), 'nostr_elapsed_ms' => (int) round((microtime(true) - $tNostr) * 1000),
@ -56,18 +56,9 @@ final readonly class ArticleCommentThreadLoader
]); ]);
return $out; return $out;
} catch (\Throwable $e) {
$this->logger->error('comments.loader.nostr_failed', [
'message' => $e->getMessage(),
'exception_class' => \get_class($e),
'nostr_elapsed_ms' => (int) round((microtime(true) - $tNostr) * 1000),
]);
return ['thread' => [], 'quotes' => []];
}
}); });
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->logger->error('comments.loader.cache_failed', [ $this->logger->error('comments.loader.cache_or_nostr_failed', [
'message' => $e->getMessage(), 'message' => $e->getMessage(),
'exception_class' => \get_class($e), 'exception_class' => \get_class($e),
]); ]);

74
src/Service/CacheService.php

@ -2,20 +2,21 @@
namespace App\Service; namespace App\Service;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use swentel\nostr\Key\Key;
use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface; use Symfony\Contracts\Cache\ItemInterface;
readonly class CacheService readonly class CacheService
{ {
public function __construct( public function __construct(
private NostrClient $nostrClient, private NostrClient $nostrClient,
private CacheInterface $cache, private CacheInterface $cache,
private LoggerInterface $logger private LoggerInterface $logger,
) private CacheItemPoolInterface $appCache,
{ ) {
} }
/** /**
@ -26,25 +27,52 @@ readonly class CacheService
{ {
$cacheKey = '0_' . $npub; $cacheKey = '0_' . $npub;
try { try {
return $this->cache->get($cacheKey, function (ItemInterface $item) use ($npub, $cacheKey) { return $this->cache->get($cacheKey, function (ItemInterface $item) use ($npub) {
$item->expiresAfter(3600); // 1 hour, adjust as needed $item->expiresAfter(3600); // 1 hour, adjust as needed
try { try {
$meta = $this->nostrClient->getNpubMetadata($npub); $meta = $this->nostrClient->getNpubMetadata($npub);
$this->logger->info('Metadata:', ['meta' => json_encode($meta)]);
return json_decode($meta->content); return json_decode($meta->content);
} catch (\Exception $e) { } catch (\Exception $e) {
$this->logger->error('Error getting user data.', ['exception' => $e]);
throw new MetadataRetrievalException('Failed to retrieve metadata', 0, $e); throw new MetadataRetrievalException('Failed to retrieve metadata', 0, $e);
} }
}); });
} catch (\Exception|InvalidArgumentException $e) { } catch (\Exception|InvalidArgumentException $e) {
$this->logger->error('Error getting user data.', ['exception' => $e]); $root = $e->getPrevious() ?? $e;
$this->logger->warning('Profile metadata fetch failed; using npub placeholder.', [
'npub' => $npub,
'exception' => $root,
]);
$content = new \stdClass(); $content = new \stdClass();
$content->name = substr($npub, 0, 8) . '…' . substr($npub, -4); $content->name = substr($npub, 0, 8) . '…' . substr($npub, -4);
return $content; return $content;
} }
} }
/**
* @param list<string> $authorPubkeyHex
* @param array<string, \stdClass> $metadataByHex from {@see NostrClient::fetchKind0MetadataForAuthors}
*/
public function putPrewarmMetadataBatch(array $authorPubkeyHex, array $metadataByHex, Key $key): int
{
$n = 0;
foreach ($authorPubkeyHex as $hex) {
if (strlen($hex) !== 64) {
continue;
}
$npub = $key->convertPublicKeyToBech32($hex);
if (isset($metadataByHex[$hex]) && $metadataByHex[$hex] instanceof \stdClass) {
$this->putProfileInCache($npub, $metadataByHex[$hex]);
} else {
$this->putProfilePlaceholderInCache($npub);
}
++$n;
}
return $n;
}
public function getRelays($npub) public function getRelays($npub)
{ {
$cacheKey = '3_' . $npub; $cacheKey = '3_' . $npub;
@ -63,4 +91,34 @@ readonly class CacheService
return []; return [];
} }
} }
private function putProfileInCache(string $npub, \stdClass $content): void
{
try {
$item = $this->appCache->getItem('0_'.$npub);
$item->set($content);
$item->expiresAfter(3600);
$this->appCache->save($item);
} catch (InvalidArgumentException $e) {
$this->logger->error('putProfileInCache', ['npub' => $npub, 'exception' => $e]);
}
}
private function putProfilePlaceholderInCache(string $npub): void
{
try {
$item = $this->appCache->getItem('0_'.$npub);
if ($item->isHit()) {
// Prewarm miss: keep an earlier good (or any) value — do not downgrade to placeholder.
return;
}
} catch (InvalidArgumentException $e) {
$this->logger->error('putProfilePlaceholderInCache', ['npub' => $npub, 'exception' => $e]);
return;
}
$c = new \stdClass();
$c->name = substr($npub, 0, 8).'…'.substr($npub, -4);
$this->putProfileInCache($npub, $c);
}
} }

177
src/Service/MagazineContentService.php

@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Article;
use App\Entity\Event;
use App\Repository\ArticleRepository;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
/**
* Magazine index events for templates. Reads {@see MagazineIndexStore} first; on a cold cache or when
* the last successful relay sync is older than {@see self::ROOT_REVALIDATE_SECONDS}, the service
* calls {@see MagazineRefresher} so the root index (and nav) can pick up new categories.
*/
final class MagazineContentService
{
/** Re-fetch root from relays at most this often so new `a` tags appear in the header. */
private const ROOT_REVALIDATE_SECONDS = 300;
public function __construct(
private readonly MagazineIndexStore $store,
private readonly MagazineRefresher $refresher,
private readonly ParameterBagInterface $params,
private readonly ArticleRepository $articleRepository,
private readonly NostrClient $nostrClient,
) {
}
/**
* "indices" for the home template: Nostr `a` tag rows for each category.
*
* @return list<array<int, string>>
*/
public function getHomeCategoryIndexTags(): array
{
$npub = (string) $this->params->get('npub');
$dTag = (string) $this->params->get('d_tag');
if ($this->store->getRoot($npub, $dTag) === null) {
$this->refresher->refreshFromRelays(20, []);
} elseif ($this->shouldRevalidateRootFromRelay()) {
$this->refresher->refreshFromRelays(20, []);
}
return $this->getHomeCategoryAIndexTagsFromStoreOnly();
}
/**
* Category `a` tags from the persisted root only (no relay). Used after /ux/magazine-sync
* has already called {@see MagazineRefresher::refreshFromRelays}.
*
* @return list<array<int, string>>
*/
public function getHomeCategoryAIndexTagsFromStoreOnly(): array
{
return $this->categoryATagsFromStoredRoot();
}
/**
* @return list<array<int, string>>
*/
private function categoryATagsFromStoredRoot(): array
{
$npub = (string) $this->params->get('npub');
$dTag = (string) $this->params->get('d_tag');
$mag = $this->store->getRoot($npub, $dTag);
return $this->categoryATagsFromMag($mag);
}
/**
* @return list<array<int, string>>
*/
private function categoryATagsFromMag(?Event $mag): array
{
if ($mag === null) {
return [];
}
$tags = $mag->getTags();
$cats = array_filter($tags, static function (mixed $tag): bool {
return \is_array($tag) && ($tag[0] ?? null) === 'a';
});
return array_values($cats);
}
private function shouldRevalidateRootFromRelay(): bool
{
$age = $this->refresher->getSecondsSinceLastRelayRun();
if ($age === null) {
return true;
}
return $age > self::ROOT_REVALIDATE_SECONDS;
}
/**
* @return array{list: list<Article>, category: array{title: string, summary: string}}
*/
public function getCategoryPageData(string $slug): array
{
$catIndex = $this->store->getCategory($slug);
if ($catIndex === null) {
$this->refresher->refreshFromRelays(20, [$slug]);
$catIndex = $this->store->getCategory($slug);
}
$list = [];
$coordinates = [];
$category = [];
if ($catIndex) {
foreach ($catIndex->getTags() as $tag) {
if ($tag[0] === 'title') {
$category['title'] = (string) $tag[1];
}
if ($tag[0] === 'summary') {
$category['summary'] = (string) $tag[1];
}
if ($tag[0] === 'a') {
$coordinates[] = $tag[1];
}
}
}
if (!empty($coordinates)) {
$pairs = [];
foreach ($coordinates as $coordinate) {
$parts = explode(':', (string) $coordinate, 3);
if (\count($parts) < 3) {
continue;
}
$slugPart = trim((string) $parts[2]);
if ($slugPart === '') {
continue;
}
$pairs[] = [
'pubkey' => (string) $parts[1],
'slug' => $slugPart,
];
}
$byAddress = $this->articleRepository->findByAuthorAndSlugIndexed($pairs);
$missing = [];
foreach ($coordinates as $coordinate) {
$parts = explode(':', (string) $coordinate, 3);
if (\count($parts) < 3) {
continue;
}
$k = (string) $parts[1]."\0".trim((string) $parts[2]);
if (!isset($byAddress[$k])) {
$missing[] = (string) $coordinate;
}
}
if ($missing !== []) {
$this->nostrClient->ingestMissingLongformForCategoryCoordinates($missing);
$byAddress = $this->articleRepository->findByAuthorAndSlugIndexed($pairs);
}
foreach ($coordinates as $coordinate) {
$parts = explode(':', (string) $coordinate, 3);
if (\count($parts) < 3) {
continue;
}
$k = (string) $parts[1]."\0".trim((string) $parts[2]);
if (isset($byAddress[$k])) {
$list[] = $byAddress[$k];
}
}
}
$category['title'] = $category['title'] ?? '';
$category['summary'] = $category['summary'] ?? '';
return [
'list' => $list,
'category' => $category,
];
}
}

116
src/Service/MagazineIndexStore.php

@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Event;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
/**
* Read/write persisted magazine Nostr index events (kinds 30040) without callback-based relay I/O
* on the request path. Updated by {@see MagazineRefresher} or the /ux/magazine-sync action.
*/
final class MagazineIndexStore
{
private const ROOT_PREFIX = 'mroot_v1_';
private const CAT_PREFIX = 'mcat_v1_';
/** 30 days — we refresh on page load, TTL is a safety cap if sync stops working. */
private const PERSIST_TTL = 2_592_000;
public function __construct(
private readonly CacheItemPoolInterface $pool,
) {
}
public function getRoot(string $npub, string $dTag): ?Event
{
$item = $this->pool->getItem($this->rootKey($npub, $dTag));
if (!$item->isHit()) {
return null;
}
return $this->unwrap($item->get());
}
public function getCategory(string $slug): ?Event
{
if ($slug === '') {
return null;
}
$item = $this->pool->getItem(self::CAT_PREFIX.$slug);
if (!$item->isHit()) {
return null;
}
return $this->unwrap($item->get());
}
/**
* @throws InvalidArgumentException
*/
public function putRoot(string $npub, string $dTag, Event $event): void
{
$item = $this->pool->getItem($this->rootKey($npub, $dTag));
$item->set(serialize($event));
$item->expiresAfter(self::PERSIST_TTL);
$this->pool->save($item);
}
/**
* @throws InvalidArgumentException
*/
public function putCategory(string $slug, Event $event): void
{
if ($slug === '') {
return;
}
$item = $this->pool->getItem(self::CAT_PREFIX.$slug);
$item->set(serialize($event));
$item->expiresAfter(self::PERSIST_TTL);
$this->pool->save($item);
}
/**
* Remove a cached category index (NIP-09 / local invalidation).
*
* @throws InvalidArgumentException
*/
public function deleteCategory(string $slug): void
{
if ($slug === '') {
return;
}
$this->pool->deleteItem(self::CAT_PREFIX.$slug);
}
/**
* Remove the cached root magazine index for this npub + d_tag.
*
* @throws InvalidArgumentException
*/
public function deleteRoot(string $npub, string $dTag): void
{
$this->pool->deleteItem($this->rootKey($npub, $dTag));
}
private function rootKey(string $npub, string $dTag): string
{
return self::ROOT_PREFIX.hash('sha256', $npub."\0".$dTag);
}
private function unwrap(mixed $value): ?Event
{
if (!\is_string($value) || $value === '') {
return null;
}
$e = unserialize($value, ['allowed_classes' => [Event::class]]);
if (!$e instanceof Event) {
return null;
}
return $e;
}
}

177
src/Service/MagazineRefresher.php

@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Event;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
/**
* Pulls magazine indices from relays within a wall-clock budget and persists them to {@see MagazineIndexStore}.
*/
final class MagazineRefresher
{
private const RELAY_STAMP_KEY = 'mag_relay_v1';
public function __construct(
private readonly NostrClient $nostrClient,
private readonly MagazineIndexStore $store,
private readonly ParameterBagInterface $params,
private readonly LoggerInterface $logger,
private readonly CacheItemPoolInterface $appCache,
) {
}
/**
* Fetches the root index then each category index until $budgetSeconds elapses. $preferSlugs
* are requested first (e.g. current /cat route) so they are less likely to miss the budget.
*/
public function refreshFromRelays(int $budgetSeconds = 8, array $preferSlugs = []): void
{
$budgetSeconds = max(1, min(30, $budgetSeconds));
$deadline = microtime(true) + $budgetSeconds;
$npub = (string) $this->params->get('npub');
$dTag = (string) $this->params->get('d_tag');
// Do not set max_execution_time to the *remaining* soft budget: PHP resets the timer, so
// after a 6s root fetch, "2s left" would become a 2s hard cap for the *next* relay I/O
// (e.g. slow TLS) and can fatal. Cap once with headroom; the $deadline loop limits work.
$this->applyExecutionTimeCap($budgetSeconds);
$defaultRelay = (string) $this->params->get('default_relay');
$relayLabel = (string) (parse_url($defaultRelay, \PHP_URL_HOST) ?: $defaultRelay);
$root = $this->nostrClient->getMagazineIndex($npub, $dTag);
if ($root === null) {
$this->logger->warning(sprintf(
'MagazineRefresher: root index not returned (tried from %s)',
$relayLabel
), [
'd_tag' => $dTag,
'relay' => $defaultRelay,
]);
return;
}
$this->store->putRoot($npub, $dTag, $root);
$slugs = $this->orderedCategorySlugs($this->categorySlugsFromRoot($root), $preferSlugs);
foreach ($slugs as $slug) {
if (microtime(true) >= $deadline) {
$this->logger->notice('MagazineRefresher: stopped at time budget; some categories not fetched', [
'unprocessed_from' => $slug,
]);
break;
}
try {
$cat = $this->nostrClient->getMagazineIndex($npub, $slug);
if ($cat !== null) {
$this->store->putCategory($slug, $cat);
}
} catch (\Throwable $e) {
$this->logger->error(sprintf(
'MagazineRefresher: category fetch failed (relays from %s): %s',
$relayLabel,
$e->getMessage()
), [
'slug' => $slug,
'message' => $e->getMessage(),
'relay' => $defaultRelay,
]);
}
}
$this->touchLastRelayTime();
}
/**
* @throws InvalidArgumentException
*/
public function getSecondsSinceLastRelayRun(): ?int
{
try {
$item = $this->appCache->getItem(self::RELAY_STAMP_KEY);
} catch (InvalidArgumentException) {
return null;
}
if (!$item->isHit()) {
return null;
}
return time() - (int) $item->get();
}
/**
* Child category indices are kind 30040; each root "a" tag is a NIP-33 address
* kind:hexpubkey:d-identifier. The third segment is the child #d (e.g. the long
* newsroom-…-category-… string), not a shortened title.
*
* @return list<string>
*/
private function categorySlugsFromRoot(Event $root): array
{
$slugs = [];
foreach ($root->getTags() as $tag) {
if (($tag[0] ?? null) !== 'a' || !isset($tag[1])) {
continue;
}
$parts = explode(':', (string) $tag[1], 3);
if (\count($parts) < 3) {
continue;
}
$s = trim((string) end($parts));
if ($s !== '' && !\in_array($s, $slugs, true)) {
$slugs[] = $s;
}
}
return $slugs;
}
/**
* @param list<string> $allFromRoot
* @param list<string> $prefer
* @return list<string>
*/
private function orderedCategorySlugs(array $allFromRoot, array $prefer): array
{
$prefer = array_values(array_filter($prefer, static function (string $s): bool {
return $s !== '';
}));
$out = $prefer;
foreach ($allFromRoot as $s) {
if (!\in_array($s, $out, true)) {
$out[] = $s;
}
}
return $out;
}
/**
* @throws InvalidArgumentException
*/
private function touchLastRelayTime(): void
{
$item = $this->appCache->getItem(self::RELAY_STAMP_KEY);
$item->set((string) time());
$item->expiresAfter(86_400);
$this->appCache->save($item);
}
/**
* One generous ceiling for PHP so relay/WebSocket I/O in one Nostr call can outlast the soft
* $deadline by seconds without a fatal, while the loop still stops *starting* new fetches in time.
*/
private function applyExecutionTimeCap(int $budgetSeconds): void
{
$sec = max(30, min(120, $budgetSeconds + 30));
@set_time_limit($sec);
@ini_set('max_execution_time', (string) $sec);
}
}

359
src/Service/Nip09DeletionApplier.php

@ -0,0 +1,359 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Event as MagazineNostrEvent;
use App\Enum\KindsEnum;
use App\Repository\ArticleRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use swentel\nostr\Key\Key;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
/**
* Applies NIP-09 (kind 5) deletion requests to local MySQL articles and magazine 30040 cache.
*
* Relays are not authoritative; we only remove data we can validate (same pubkey as deletion request).
* For cached 30040 category indices (keyed by `d` only), we require the stored event’s author
* to match the deletion — not just an `a` tag whose own pubkey matches, so colliding `d` values
* across authors cannot wipe another author’s cache entry.
*/
final class Nip09DeletionApplier
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ArticleRepository $articleRepository,
private readonly MagazineIndexStore $magazineIndexStore,
private readonly ParameterBagInterface $params,
private readonly LoggerInterface $logger,
) {
}
/**
* @param list<object> $deletionEvents Kind-5 events from relays (e.g. {@see NostrClient::fetchKind5DeletionEventsForAuthors})
*
* @return array{articles_removed: int, magazine_roots: int, magazine_categories: int}
*/
public function apply(array $deletionEvents): array
{
$articlesRemoved = 0;
$articlesPendingFlush = 0;
$roots = 0;
$cats = 0;
$seenArticleIds = [];
foreach ($deletionEvents as $ev) {
if (!\is_object($ev)) {
continue;
}
if ((int) ($ev->kind ?? 0) !== KindsEnum::DELETION_REQUEST->value) {
continue;
}
$deletionPubkey = (string) ($ev->pubkey ?? '');
if (64 !== \strlen($deletionPubkey)) {
continue;
}
[$eIds, $eKinds] = $this->parseETags($ev);
$aAddrs = $this->parseATags($ev);
foreach ($eIds as $i => $eId) {
if (64 !== \strlen($eId)) {
continue;
}
$declared = $eKinds[$i] ?? null;
if ($declared !== null
&& !\in_array($declared, [30023, 30024, 30040, 1], true)) {
// Other kinds: we do not mirror in this app; skip.
continue;
}
if ($declared === 1) {
continue;
}
if ($this->removeArticleByEventIdIfValid($eId, $deletionPubkey, $declared, $seenArticleIds)) {
++$articlesRemoved;
++$articlesPendingFlush;
continue;
}
// No article row: 30040 index (or mis-tagged kind); only skip unrelated kinds.
if ($declared === null || \in_array($declared, [30023, 30024, 30040], true)) {
$mag = $this->tryRemoveMagazine30040ByEventId($eId, $deletionPubkey);
if ($mag === 1) {
++$roots;
} elseif ($mag === 2) {
++$cats;
}
}
}
foreach ($aAddrs as $addr) {
$r = $this->removeByNip33Address($addr, $deletionPubkey, $seenArticleIds);
$articlesRemoved += $r['articles'];
$articlesPendingFlush += $r['articles'];
$roots += $r['roots'];
$cats += $r['cats'];
}
}
if ($articlesPendingFlush > 0) {
try {
$this->entityManager->flush();
} catch (\Throwable $e) {
$this->logger->error('Nip09DeletionApplier: flush failed', ['exception' => $e]);
}
}
return [
'articles_removed' => $articlesRemoved,
'magazine_roots' => $roots,
'magazine_categories' => $cats,
];
}
/** 0 = none, 1 = root cache, 2 = category cache */
private function tryRemoveMagazine30040ByEventId(string $eventId, string $deletionPubkey): int
{
$npub = (string) $this->params->get('npub');
$dTag = (string) $this->params->get('d_tag');
if ($npub === '' || $dTag === '') {
return 0;
}
$root = $this->magazineIndexStore->getRoot($npub, $dTag);
if ($root === null) {
return 0;
}
if ($this->eventIdMatches($root, $eventId) && $this->pubkeyEquals($root->getPubkey(), $deletionPubkey)) {
$this->magazineIndexStore->deleteRoot($npub, $dTag);
$this->logger->notice('NIP-09: removed cached magazine root index', [
'event_id' => $eventId,
]);
return 1;
}
foreach ($this->categorySlugsFromRoot($root) as $slug) {
$cat = $this->magazineIndexStore->getCategory($slug);
if ($cat === null) {
continue;
}
if ($this->eventIdMatches($cat, $eventId) && $this->pubkeyEquals($cat->getPubkey(), $deletionPubkey)) {
$this->magazineIndexStore->deleteCategory($slug);
$this->logger->notice('NIP-09: removed cached magazine category index', [
'event_id' => $eventId,
'slug' => $slug,
]);
return 2;
}
}
return 0;
}
private function eventIdMatches(MagazineNostrEvent $e, string $eventId): bool
{
$a = strtolower($e->getId());
$b = strtolower($eventId);
return $a === $b;
}
private function pubkeyEquals(string $a, string $b): bool
{
if (64 !== \strlen($a) || 64 !== \strlen($b)) {
return $a === $b;
}
return strtolower($a) === strtolower($b);
}
/**
* @return list<string>
*/
private function categorySlugsFromRoot(MagazineNostrEvent $root): array
{
$slugs = [];
foreach ($root->getTags() as $tag) {
if (($tag[0] ?? null) !== 'a' || !isset($tag[1])) {
continue;
}
$parts = explode(':', (string) $tag[1], 3);
if (\count($parts) < 3) {
continue;
}
$s = trim((string) end($parts));
if ($s !== '' && !\in_array($s, $slugs, true)) {
$slugs[] = $s;
}
}
return $slugs;
}
/**
* @param array<string, true> $seenArticleIds
*/
private function removeArticleByEventIdIfValid(
string $eId,
string $deletionPubkey,
?int $declaredKind,
array &$seenArticleIds,
): bool {
if (isset($seenArticleIds[$eId])) {
return false;
}
$article = $this->articleRepository->findOneByEventId($eId);
if ($article === null) {
return false;
}
if (!$this->pubkeyEquals($article->getPubkey() ?? '', $deletionPubkey)) {
$this->logger->debug('NIP-09: ignore e tag (pubkey mismatch)', [
'event_id' => $eId,
]);
return false;
}
$k = $article->getKind()?->value;
if ($declaredKind !== null && $k !== null && $declaredKind !== $k) {
return false;
}
if ($k !== null && !\in_array($k, [KindsEnum::LONGFORM->value, KindsEnum::LONGFORM_DRAFT->value], true)) {
return false;
}
$this->entityManager->remove($article);
$seenArticleIds[$eId] = true;
$this->logger->notice('NIP-09: removed article from database', [
'event_id' => $eId,
'kind' => $k,
]);
return true;
}
/**
* NIP-33: `kind:pubkeyhex:d-identifier`
*
* @param array<string, true> $seenArticleIds
*
* @return array{articles: int, roots: int, cats: int}
*/
private function removeByNip33Address(string $addr, string $deletionPubkey, array &$seenArticleIds): array
{
$out = ['articles' => 0, 'roots' => 0, 'cats' => 0];
$parts = explode(':', $addr, 3);
if (\count($parts) < 3) {
return $out;
}
$kind = (int) $parts[0];
$pk = (string) $parts[1];
$d = trim((string) $parts[2]);
if ($d === '' || !$this->pubkeyEquals($pk, $deletionPubkey)) {
return $out;
}
if ($kind === KindsEnum::LONGFORM->value || $kind === KindsEnum::LONGFORM_DRAFT->value) {
$article = $this->articleRepository->findOneBy(['pubkey' => $pk, 'slug' => $d]);
if ($article !== null) {
$eid = (string) ($article->getEventId() ?? '');
$dedupeKey = $eid !== '' ? $eid : 'ps:'.$pk."\0".$d;
if (!isset($seenArticleIds[$dedupeKey])) {
$this->entityManager->remove($article);
$seenArticleIds[$dedupeKey] = true;
++$out['articles'];
$this->logger->notice('NIP-09: removed article (a tag)', [
'address' => $addr,
]);
}
}
return $out;
}
if ($kind === KindsEnum::PUBLICATION_INDEX->value) {
$npub = (string) $this->params->get('npub');
$siteD = (string) $this->params->get('d_tag');
$siteHex = '';
if (str_starts_with($npub, 'npub1')) {
try {
$h = (new Key())->convertToHex($npub);
if (64 === \strlen($h)) {
$siteHex = $h;
}
} catch (\Throwable) {
}
}
if ($npub !== '' && $siteD !== '' && $d === $siteD && $siteHex !== '' && $this->pubkeyEquals($pk, $siteHex)) {
$this->magazineIndexStore->deleteRoot($npub, $siteD);
++$out['roots'];
$this->logger->notice('NIP-09: removed magazine root (a tag)', ['address' => $addr]);
} else {
// Category cache is keyed by `d` only; the same d string can appear for different
// authors' 30040 events. Only remove if the cached event was authored by this deletion.
$cachedCat = $this->magazineIndexStore->getCategory($d);
if ($cachedCat === null) {
$this->logger->debug('NIP-09: skip category delete (nothing cached for d)', [
'address' => $addr,
'd' => $d,
]);
} elseif (!$this->pubkeyEquals($cachedCat->getPubkey(), $deletionPubkey)) {
$this->logger->debug('NIP-09: skip category delete (cached index author != deletion author)', [
'address' => $addr,
'd' => $d,
]);
} else {
$this->magazineIndexStore->deleteCategory($d);
++$out['cats'];
$this->logger->notice('NIP-09: removed magazine category (a tag)', [
'address' => $addr,
'd' => $d,
]);
}
}
}
return $out;
}
/**
* @return array{0: list<string>, 1: list<?int>} e-ids and parallel k kinds (NIP-09 example order)
*/
private function parseETags(object $ev): array
{
$eIds = [];
$kinds = [];
foreach ($ev->tags ?? [] as $tag) {
if (!\is_array($tag) || !isset($tag[0], $tag[1])) {
continue;
}
if ($tag[0] === 'e') {
$eIds[] = (string) $tag[1];
}
if ($tag[0] === 'k') {
$kinds[] = (int) $tag[1];
}
}
$pairs = [];
for ($i = 0; $i < \count($eIds); ++$i) {
$pairs[] = $kinds[$i] ?? null;
}
return [$eIds, $pairs];
}
/**
* @return list<string> NIP-33 addresses
*/
private function parseATags(object $ev): array
{
$a = [];
foreach ($ev->tags ?? [] as $tag) {
if (!\is_array($tag) || ($tag[0] ?? null) !== 'a' || !isset($tag[1])) {
continue;
}
$a[] = (string) $tag[1];
}
return $a;
}
}

701
src/Service/NostrClient.php

@ -24,8 +24,15 @@ use Symfony\Contracts\Cache\ItemInterface;
class NostrClient class NostrClient
{ {
/** Per-relay WebSocket I/O cap (seconds), applied on each relay’s {@see \WebSocket\Client}. */
private const RELAY_REQUEST_TIMEOUT_SEC = 15;
private RelaySet $defaultRelaySet; private RelaySet $defaultRelaySet;
/**
* @param list<string> $articleRelayUrls extra relays for the default set (default_relay is always first)
* @param list<string> $profileRelayUrls kind-0 / profile; merged for metadata (see {@see profileMetadataQueryRelayUrlList()})
*/
public function __construct( public function __construct(
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
private readonly ManagerRegistry $managerRegistry, private readonly ManagerRegistry $managerRegistry,
@ -33,26 +40,109 @@ class NostrClient
private readonly TokenStorageInterface $tokenStorage, private readonly TokenStorageInterface $tokenStorage,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
private readonly string $defaultRelayUrl, private readonly string $defaultRelayUrl,
private readonly array $articleRelayUrls,
private readonly array $profileRelayUrls,
private readonly CacheInterface $relayQueryCache, private readonly CacheInterface $relayQueryCache,
) { ) {
$this->defaultRelaySet = new RelaySet(); $this->defaultRelaySet = $this->buildArticleRelaySet();
$this->defaultRelaySet->addRelay(new Relay($this->defaultRelayUrl));
} }
/** /**
* Build a fresh relay set: default relay plus optional extras (deduped). * default_relay + article_relays from config, in order, deduplicated. Used for the static
* Never reuse {@see $defaultRelaySet} as a mutable base — that used to append relays * default set and as the base when merging author/extra relay URLs in {@see createRelaySet()}.
* onto the singleton forever and multiplied every nostr request latency. *
* @return list<string>
*/ */
private function createRelaySet(array $relayUrls): RelaySet private function configuredArticleRelayUrlList(): array
{ {
$relaySet = new RelaySet();
$seen = []; $seen = [];
foreach (array_merge([$this->defaultRelayUrl], $relayUrls) as $relayUrl) { $out = [];
if (!\is_string($relayUrl) || $relayUrl === '') { foreach (array_merge([$this->defaultRelayUrl], $this->articleRelayUrls) as $url) {
if (!\is_string($url) || $url === '' || isset($seen[$url])) {
continue; continue;
} }
if (isset($seen[$relayUrl])) { $seen[$url] = true;
$out[] = $url;
}
if ($out === []) {
$out[] = $this->defaultRelayUrl;
}
return $out;
}
private function buildArticleRelaySet(): RelaySet
{
$relaySet = new RelaySet();
foreach ($this->configuredArticleRelayUrlList() as $url) {
$relaySet->addRelay(new Relay($url));
}
return $relaySet;
}
/**
* One relay for magazine 30040 lookups. {@see Request::send()} iterates every relay in the set
* sequentially; the full default set (5–6 wss) multiplies wall time — often 10s+ while a single
* relay returns in under 2s for the same filter.
*/
private function buildSingleRelaySet(string $wssUrl): RelaySet
{
$rs = new RelaySet();
$rs->addRelay(new Relay($wssUrl));
return $rs;
}
/**
* Host (or full URL) for log messages so console output shows which relay without opening context.
*/
private static function relayLogLabel(string $relayUrl): string
{
$host = parse_url($relayUrl, \PHP_URL_HOST);
if (\is_string($host) && $host !== '') {
return $host;
}
return $relayUrl;
}
private function newTimedRequest(RelaySet $relaySet, RequestMessage $requestMessage): Request
{
$request = new Request($relaySet, $requestMessage);
// 1.9.4+: Request::setTimeout() drives getResponseFromRelay(). Older: only WebSocket client on Relay.
if (method_exists($request, 'setTimeout')) {
$request->setTimeout(self::RELAY_REQUEST_TIMEOUT_SEC);
} else {
$this->applyRelaySocketTimeoutToSet($relaySet);
}
return $request;
}
/**
* Set per-relay WebSocket I/O cap. {@see RelaySet::send()} bypasses {@see Request}; use this there too.
*/
private function applyRelaySocketTimeoutToSet(RelaySet $relaySet): void
{
foreach ($relaySet->getRelays() as $relay) {
$client = $relay->getClient();
if (method_exists($client, 'setTimeout')) {
$client->setTimeout(self::RELAY_REQUEST_TIMEOUT_SEC);
}
}
}
/**
* Merges all configured article relays (default + article_relays) with the given URLs in order, deduped.
* Used for comment threads (getArticleDiscussion), per-author fetches, etc.
*/
private function createRelaySet(array $relayUrls): RelaySet
{
$relaySet = new RelaySet();
$seen = [];
foreach (array_merge($this->configuredArticleRelayUrlList(), $relayUrls) as $relayUrl) {
if (!\is_string($relayUrl) || $relayUrl === '' || isset($seen[$relayUrl])) {
continue; continue;
} }
$seen[$relayUrl] = true; $seen[$relayUrl] = true;
@ -97,34 +187,224 @@ class NostrClient
}); });
} }
/**
* @return list<string> Deduplicated profile relay URLs from config
*/
private function profileRelayUrlList(): array
{
$seen = [];
$out = [];
foreach ($this->profileRelayUrls as $url) {
if (!\is_string($url) || $url === '' || isset($seen[$url])) {
continue;
}
if (!str_starts_with($url, 'wss:')) {
continue;
}
$seen[$url] = true;
$out[] = $url;
}
return $out;
}
/**
* Profile (kind-0) queries: {@see profileRelayUrlList()} first (Damus, nos.lol, …), then default + article set.
* Order matters: {@see Request::send()} walks relays sequentially.
*
* @return list<string>
*/
private function profileMetadataQueryRelayUrlList(): array
{
$seen = [];
$ordered = [];
foreach (array_merge($this->profileRelayUrlList(), $this->configuredArticleRelayUrlList()) as $u) {
if (!\is_string($u) || $u === '' || isset($seen[$u])) {
continue;
}
$seen[$u] = true;
$ordered[] = $u;
}
if ($ordered === []) {
$ordered[] = $this->defaultRelayUrl;
}
return $ordered;
}
/**
* Same relays for kind-0 metadata, without mutating {@see $this->defaultRelaySet}.
*/
private function relaySetForProfileMetadataFetch(): RelaySet
{
$relaySet = new RelaySet();
foreach ($this->profileMetadataQueryRelayUrlList() as $url) {
$relaySet->addRelay(new Relay($url));
}
return $relaySet;
}
/**
* Batched kind-0 profile fetch: one Nostr REQ per chunk with multiple "authors" (hex pubkeys).
*
* @param list<string> $authorPubkeyHex
* @return array<string, \stdClass> Newest kind-0 JSON per pubkey, keyed by hex
*/
public function fetchKind0MetadataForAuthors(array $authorPubkeyHex, int $authorsPerRequest = 50): array
{
$authorPubkeyHex = \array_values(\array_unique(\array_filter(
$authorPubkeyHex,
static fn (mixed $h): bool => \is_string($h) && 64 === \strlen($h),
)));
if ($authorPubkeyHex === []) {
return [];
}
$authorsPerRequest = max(1, min(200, $authorsPerRequest));
$byPub = [];
$relaysTried = $this->profileMetadataQueryRelayUrlList();
$relaysTriedStr = implode(', ', array_map(self::relayLogLabel(...), $relaysTried));
$relaySet = $this->relaySetForProfileMetadataFetch();
$chunks = array_chunk($authorPubkeyHex, $authorsPerRequest);
foreach ($chunks as $i => $chunk) {
$t0 = microtime(true);
$request = $this->createNostrRequest(
kinds: [KindsEnum::METADATA],
filters: ['authors' => $chunk],
relaySet: $relaySet
);
$events = $this->processResponse(
$request->send(),
static fn ($ev) => $ev,
);
$this->logger->info('nostr.metadata.batch_chunk', [
'chunk' => 1 + $i,
'of' => \count($chunks),
'authors' => \count($chunk),
'events' => \count($events),
'relays' => $relaysTriedStr,
'ms' => (int) round((microtime(true) - $t0) * 1000),
]);
$newest = [];
foreach ($events as $ev) {
if (!\is_object($ev) || !isset($ev->pubkey, $ev->content)) {
continue;
}
$pk = (string) $ev->pubkey;
if (64 !== \strlen($pk)) {
continue;
}
$ts = (int) ($ev->created_at ?? 0);
if (isset($newest[$pk]) && $ts <= $newest[$pk]['t']) {
continue;
}
$newest[$pk] = ['ev' => $ev, 't' => $ts];
}
foreach ($newest as $pk => $row) {
$ev = $row['ev'];
try {
$data = \json_decode((string) $ev->content, false, 512, \JSON_THROW_ON_ERROR);
} catch (\JsonException) {
continue;
}
if (\is_object($data)) {
$byPub[$pk] = $data;
}
}
}
return $byPub;
}
/**
* NIP-09 kind 5 deletion requests in $since..$until (unix), batched by author pubkey (hex).
*
* @param list<string> $authorPubkeyHex
* @return list<stdClass> Deduplicated by event `id` (highest {@see created_at} kept)
*/
public function fetchKind5DeletionEventsForAuthors(
array $authorPubkeyHex,
int $since,
int $until,
int $authorsPerRequest = 40,
): array {
$authorPubkeyHex = \array_values(\array_unique(\array_filter(
$authorPubkeyHex,
static fn (mixed $h): bool => \is_string($h) && 64 === \strlen($h),
)));
if ($authorPubkeyHex === [] || $since >= $until) {
return [];
}
$authorsPerRequest = max(1, min(100, $authorsPerRequest));
$byId = [];
foreach (array_chunk($authorPubkeyHex, $authorsPerRequest) as $chunk) {
$request = $this->createNostrRequest(
kinds: [KindsEnum::DELETION_REQUEST],
filters: [
'authors' => $chunk,
'since' => $since,
'until' => $until,
],
);
$t0 = microtime(true);
$events = $this->processResponse(
$request->send(),
static fn (object $event) => $event,
);
$this->logger->info('nostr.nip09.kind5_chunk', [
'authors' => \count($chunk),
'raw_events' => \count($events),
'ms' => (int) round((microtime(true) - $t0) * 1000),
]);
foreach ($events as $ev) {
if (!\is_object($ev) || (int) ($ev->kind ?? 0) !== KindsEnum::DELETION_REQUEST->value) {
continue;
}
$id = (string) ($ev->id ?? '');
if (64 !== \strlen($id)) {
continue;
}
$t = (int) ($ev->created_at ?? 0);
if (isset($byId[$id]) && $t <= (int) ($byId[$id]->created_at ?? 0)) {
continue;
}
$byId[$id] = $ev;
}
}
return array_values($byId);
}
/** /**
* @throws \Exception * @throws \Exception
*/ */
public function getNpubMetadata($npub): \stdClass public function getNpubMetadata($npub): \stdClass
{ {
$relaySet = $this->defaultRelaySet; $relaysTried = $this->profileMetadataQueryRelayUrlList();
$relaySet->addRelay(new Relay('wss://profiles.nostr1.com')); // profile aggregator $relaysTriedStr = implode(', ', array_map(self::relayLogLabel(...), $relaysTried));
$this->logger->info('Getting metadata for npub', ['npub' => $npub]); $relaySet = $this->relaySetForProfileMetadataFetch();
// Npubs are converted to hex for the request down the line $this->logger->info(sprintf('Getting metadata for npub (relays: %s)', $relaysTriedStr), ['npub' => $npub, 'relays' => $relaysTried]);
$request = $this->createNostrRequest( $request = $this->createNostrRequest(
kinds: [KindsEnum::METADATA], kinds: [KindsEnum::METADATA],
filters: ['authors' => [$npub]], filters: ['authors' => [$npub]],
relaySet: $relaySet relaySet: $relaySet
); );
$events = $this->processResponse($request->send(), function($received) { $events = $this->processResponse(
$this->logger->info('Getting metadata for npub', ['item' => $received]); $request->send(),
return $received; function ($received) {
}); $this->logger->debug('nostr.metadata.relay_event', ['event' => $received]);
$this->logger->info('Getting metadata for npub', ['response' => $events]); return $received;
},
);
if (empty($events)) { if (empty($events)) {
throw new \Exception('No metadata found for npub: ' . $npub); throw new \Exception('No metadata for npub '.$npub.' (relays: '.$relaysTriedStr.')');
} }
// Sort by date and return newest // Sort by date and return newest
usort($events, fn($a, $b) => $b->created_at <=> $a->created_at); usort($events, static fn ($a, $b) => (int) ($b->created_at ?? 0) <=> (int) ($a->created_at ?? 0));
return $events[0]; return $events[0];
} }
@ -151,18 +431,16 @@ class NostrClient
} }
} }
$request = new Request($relays, $requestMessage); $request = $this->newTimedRequest($relays, $requestMessage);
$response = $request->send(); $wrappers = $this->processResponse($request->send(), function (object $event) {
// response is an n-dimensional array, where n is the number of relays in the set $w = new \stdClass();
// check that response has events in the results $w->event = $event;
foreach ($response as $relayRes) {
$filtered = array_filter($relayRes, function ($item) { return $w;
return $item->type === 'EVENT';
}); });
if (count($filtered) > 0) { if ($wrappers !== []) {
$this->saveLongFormContent($filtered); $this->saveLongFormContent($wrappers);
}
} }
// TODO handle relays that require auth // TODO handle relays that require auth
} }
@ -176,41 +454,57 @@ class NostrClient
$relaySet->addRelay($relay); $relaySet->addRelay($relay);
} }
$relaySet->setMessage($eventMessage); $relaySet->setMessage($eventMessage);
$this->applyRelaySocketTimeoutToSet($relaySet);
// TODO handle responses appropriately // TODO handle responses appropriately
return $relaySet->send(); return $relaySet->send();
} }
/**
* Backfill long-form (NIP-23) in time windows so relay responses and PHP stay bounded (avoids
* OOM on year-wide queries with many relays). ~60 days per step (≈2 months).
*/
private const LONGFORM_BACKFILL_CHUNK_SECONDS = 5184000; // 60 days
/** /**
* Long-form Content * Long-form Content
* NIP-23 * NIP-23
*/ */
public function getLongFormContent($from = null, $to = null): void public function getLongFormContent($from = null, $to = null): void
{
$toTs = $to !== null ? (int) $to : time();
$fromTs = $from !== null ? (int) $from : strtotime('-1 week');
if ($fromTs >= $toTs) {
return;
}
$chunk = self::LONGFORM_BACKFILL_CHUNK_SECONDS;
for ($windowFrom = $fromTs; $windowFrom < $toTs; $windowFrom += $chunk) {
$windowTo = min($windowFrom + $chunk, $toTs);
$this->getLongFormContentForTimeWindow($windowFrom, $windowTo);
$this->entityManager->clear();
}
}
private function getLongFormContentForTimeWindow(int $since, int $until): void
{ {
$subscription = new Subscription(); $subscription = new Subscription();
$subscriptionId = $subscription->setId(); $subscriptionId = $subscription->setId();
$filter = new Filter(); $filter = new Filter();
$filter->setKinds([KindsEnum::LONGFORM]); $filter->setKinds([KindsEnum::LONGFORM]);
$filter->setSince(strtotime('-1 week')); // default $filter->setSince($since);
if ($from !== null) { $filter->setUntil($until);
$filter->setSince($from);
}
if ($to !== null) {
$filter->setUntil($to);
}
$requestMessage = new RequestMessage($subscriptionId, [$filter]); $requestMessage = new RequestMessage($subscriptionId, [$filter]);
$request = new Request($this->defaultRelaySet, $requestMessage); $request = $this->newTimedRequest($this->defaultRelaySet, $requestMessage);
$response = $request->send(); $wrappers = $this->processResponse($request->send(), function (object $event) {
// response is an n-dimensional array, where n is the number of relays in the set $w = new \stdClass();
// check that response has events in the results $w->event = $event;
foreach ($response as $relayRes) {
$filtered = array_filter($relayRes, function ($item) { return $w;
return $item->type === 'EVENT';
}); });
if (count($filtered) > 0) { if ($wrappers !== []) {
$this->saveLongFormContent($filtered); $this->saveLongFormContent($wrappers);
}
} }
} }
@ -222,9 +516,12 @@ class NostrClient
if (empty($relayList)) { if (empty($relayList)) {
$topAuthorRelays = $this->getTopReputableRelaysForAuthor($author); $topAuthorRelays = $this->getTopReputableRelaysForAuthor($author);
$authorRelaySet = $this->createRelaySet($topAuthorRelays); $authorRelaySet = $this->createRelaySet($topAuthorRelays);
$relaysTried = $this->plannedRelayUrlsForSet($topAuthorRelays);
} else { } else {
$authorRelaySet = $this->createRelaySet($relayList); $authorRelaySet = $this->createRelaySet($relayList);
$relaysTried = $this->plannedRelayUrlsForSet($relayList);
} }
$relaysTriedStr = implode(', ', array_map(self::relayLogLabel(...), $relaysTried));
try { try {
// Create request using the helper method for forest relay set // Create request using the helper method for forest relay set
@ -251,8 +548,9 @@ class NostrClient
$this->saveLongFormContent([$wrapper]); $this->saveLongFormContent([$wrapper]);
} }
} catch (\Exception $e) { } catch (\Exception $e) {
$this->logger->error('Error querying relays', [ $this->logger->error(sprintf('Error querying relays (%s): %s', $relaysTriedStr, $e->getMessage()), [
'error' => $e->getMessage() 'error' => $e->getMessage(),
'relays' => $relaysTried,
]); ]);
throw new \Exception('Error querying relays', 0, $e); throw new \Exception('Error querying relays', 0, $e);
} }
@ -442,7 +740,7 @@ class NostrClient
$subscription = new Subscription(); $subscription = new Subscription();
$subscriptionId = $subscription->setId(); $subscriptionId = $subscription->setId();
$requestMessage = new RequestMessage($subscriptionId, $filters); $requestMessage = new RequestMessage($subscriptionId, $filters);
$request = new Request($relaySet, $requestMessage); $request = $this->newTimedRequest($relaySet, $requestMessage);
$this->logger->info('nostr.article_discussion.req_sending', [ $this->logger->info('nostr.article_discussion.req_sending', [
'subscription_id' => $subscriptionId, 'subscription_id' => $subscriptionId,
@ -462,13 +760,20 @@ class NostrClient
]); ]);
$this->logNostrWireResponseSummary('article_discussion', $response); $this->logNostrWireResponseSummary('article_discussion', $response);
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->logger->error('nostr.article_discussion.req_send_failed', [ $this->logger->error(sprintf(
'nostr.article_discussion.req_send_failed (relays: %s): %s',
implode(', ', array_map(self::relayLogLabel(...), $plannedRelayUrls)),
$e->getMessage()
), [
'coordinate' => $coordinate, 'coordinate' => $coordinate,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
'exception_class' => \get_class($e), 'exception_class' => \get_class($e),
'relays' => $plannedRelayUrls,
]); ]);
return ['thread' => [], 'quotes' => []]; // Do not return a successful empty shape: callers (e.g. comment cache) must not
// persist [] as if relays responded — that would clobber a previously good thread.
throw new \RuntimeException('Nostr request failed for article discussion', 0, $e);
} }
$tParse = microtime(true); $tParse = microtime(true);
@ -541,11 +846,8 @@ class NostrClient
{ {
$seen = []; $seen = [];
$out = []; $out = [];
foreach (array_merge([$this->defaultRelayUrl], $relayUrls) as $relayUrl) { foreach (array_merge($this->configuredArticleRelayUrlList(), $relayUrls) as $relayUrl) {
if (!\is_string($relayUrl) || $relayUrl === '') { if (!\is_string($relayUrl) || $relayUrl === '' || isset($seen[$relayUrl])) {
continue;
}
if (isset($seen[$relayUrl])) {
continue; continue;
} }
$seen[$relayUrl] = true; $seen[$relayUrl] = true;
@ -564,7 +866,11 @@ class NostrClient
{ {
foreach ($response as $relayUrl => $relayRes) { foreach ($response as $relayUrl => $relayRes) {
if ($relayRes instanceof \Throwable) { if ($relayRes instanceof \Throwable) {
$this->logger->warning('nostr.wire.relay_throwable', [ $this->logger->warning(sprintf(
'nostr.wire.relay_throwable [%s]: %s',
self::relayLogLabel($relayUrl),
$relayRes->getMessage()
), [
'context' => $context, 'context' => $context,
'relay' => $relayUrl, 'relay' => $relayUrl,
'message' => $relayRes->getMessage(), 'message' => $relayRes->getMessage(),
@ -574,7 +880,11 @@ class NostrClient
continue; continue;
} }
if (!\is_iterable($relayRes)) { if (!\is_iterable($relayRes)) {
$this->logger->warning('nostr.wire.relay_not_iterable', [ $this->logger->warning(sprintf(
'nostr.wire.relay_not_iterable [%s]: %s',
self::relayLogLabel($relayUrl),
\get_debug_type($relayRes)
), [
'context' => $context, 'context' => $context,
'relay' => $relayUrl, 'relay' => $relayUrl,
'php_type' => \get_debug_type($relayRes), 'php_type' => \get_debug_type($relayRes),
@ -604,7 +914,7 @@ class NostrClient
++$counts['other']; ++$counts['other'];
} }
} }
$this->logger->info('nostr.wire.relay_messages', [ $this->logger->info(sprintf('nostr.wire.relay_messages [%s]', self::relayLogLabel($relayUrl)), [
'context' => $context, 'context' => $context,
'relay' => $relayUrl, 'relay' => $relayUrl,
'counts' => $counts, 'counts' => $counts,
@ -865,12 +1175,24 @@ class NostrClient
$requestMessage = new RequestMessage($subscriptionId, [$filter]); $requestMessage = new RequestMessage($subscriptionId, [$filter]);
try { try {
$request = new Request($this->defaultRelaySet, $requestMessage); $request = $this->newTimedRequest($this->defaultRelaySet, $requestMessage);
$response = $request->send(); $response = $request->send();
$hasEvents = false; $hasEvents = false;
// Check if we got any events // Check if we got any events
foreach ($response as $value) { foreach ($response as $relayUrl => $value) {
if ($value instanceof \Throwable) {
$this->logger->warning(sprintf(
'[%s] getArticles: %s',
self::relayLogLabel($relayUrl),
$value->getMessage()
), ['relay' => $relayUrl]);
continue;
}
if (!\is_iterable($value)) {
continue;
}
foreach ($value as $item) { foreach ($value as $item) {
if ($item->type === 'EVENT') { if ($item->type === 'EVENT') {
if (!isset($articles[$item->event->id])) { if (!isset($articles[$item->event->id])) {
@ -885,31 +1207,58 @@ class NostrClient
if (!$hasEvents && !empty($slugs)) { if (!$hasEvents && !empty($slugs)) {
$this->logger->info('No results from theforest, trying default relays'); $this->logger->info('No results from theforest, trying default relays');
$request = new Request($this->defaultRelaySet, $requestMessage); $request = $this->newTimedRequest($this->defaultRelaySet, $requestMessage);
$response = $request->send(); $response = $request->send();
foreach ($response as $value) { foreach ($response as $relayUrl => $value) {
if ($value instanceof \Throwable) {
$this->logger->warning(sprintf(
'[%s] getArticles: %s',
self::relayLogLabel($relayUrl),
$value->getMessage()
), ['relay' => $relayUrl]);
continue;
}
if (!\is_iterable($value)) {
continue;
}
foreach ($value as $item) { foreach ($value as $item) {
if ($item->type === 'EVENT') { if ($item->type === 'EVENT') {
if (!isset($articles[$item->event->id])) { if (!isset($articles[$item->event->id])) {
$articles[$item->event->id] = $item->event; $articles[$item->event->id] = $item->event;
} }
} elseif (in_array($item->type, ['AUTH', 'ERROR', 'NOTICE'])) { } elseif (in_array($item->type, ['AUTH', 'ERROR', 'NOTICE'], true)) {
$this->logger->error('An error while getting articles.', ['response' => $item]); $msg = (string) ($item->message ?? '');
$this->logger->error(sprintf(
'[%s] %s while getting articles: %s',
self::relayLogLabel($relayUrl),
$item->type,
$msg !== '' ? $msg : '(no message)'
), ['relay' => $relayUrl, 'response' => $item]);
} }
} }
} }
} }
} catch (\Exception $e) { } catch (\Exception $e) {
$this->logger->error('Error querying relays', [ $relaysTried = $this->configuredArticleRelayUrlList();
'error' => $e->getMessage() $relaysStr = implode(', ', array_map(self::relayLogLabel(...), $relaysTried));
$this->logger->error(sprintf('Error querying relays (%s): %s', $relaysStr, $e->getMessage()), [
'error' => $e->getMessage(),
'relays' => $relaysTried,
]); ]);
// Fall back to default relay set // Fall back to default relay set
$request = new Request($this->defaultRelaySet, $requestMessage); $request = $this->newTimedRequest($this->defaultRelaySet, $requestMessage);
$response = $request->send(); $response = $request->send();
foreach ($response as $value) { foreach ($response as $relayUrl => $value) {
if ($value instanceof \Throwable) {
continue;
}
if (!\is_iterable($value)) {
continue;
}
foreach ($value as $item) { foreach ($value as $item) {
if ($item->type === 'EVENT') { if ($item->type === 'EVENT') {
if (!isset($articles[$item->event->id])) { if (!isset($articles[$item->event->id])) {
@ -963,9 +1312,8 @@ class NostrClient
// Continue with default relays // Continue with default relays
} }
// If no author relays found, add default relay
if (empty($relayList)) { if (empty($relayList)) {
$relayList = [$this->defaultRelayUrl]; $relayList = [];
} }
// Ensure we use a RelaySet // Ensure we use a RelaySet
@ -979,14 +1327,28 @@ class NostrClient
$filter->setAuthors([$pubkey]); $filter->setAuthors([$pubkey]);
$filter->setTag('#d', [$slug]); $filter->setTag('#d', [$slug]);
$requestMessage = new RequestMessage($subscriptionId, [$filter]); $requestMessage = new RequestMessage($subscriptionId, [$filter]);
$relaysForLog = $this->plannedRelayUrlsForSet($relayList);
$relaysLogStr = implode(', ', array_map(self::relayLogLabel(...), $relaysForLog));
try { try {
$request = new Request($relaySet, $requestMessage); $request = $this->newTimedRequest($relaySet, $requestMessage);
$response = $request->send(); $response = $request->send();
$found = false; $found = false;
// Check responses from each relay // Check responses from each relay
foreach ($response as $value) { foreach ($response as $relayUrl => $value) {
if ($value instanceof \Throwable) {
$this->logger->warning(sprintf(
'[%s] getArticlesByCoordinates: %s',
self::relayLogLabel($relayUrl),
$value->getMessage()
), ['coordinate' => $coordinate, 'relay' => $relayUrl]);
continue;
}
if (!\is_iterable($value)) {
continue;
}
foreach ($value as $item) { foreach ($value as $item) {
if ($item->type === 'EVENT') { if ($item->type === 'EVENT') {
$articlesMap[$coordinate] = $item->event; $articlesMap[$coordinate] = $item->event;
@ -1002,10 +1364,22 @@ class NostrClient
'coordinate' => $coordinate 'coordinate' => $coordinate
]); ]);
$request = new Request($this->defaultRelaySet, $requestMessage); $request = $this->newTimedRequest($this->defaultRelaySet, $requestMessage);
$response = $request->send(); $response = $request->send();
foreach ($response as $value) { foreach ($response as $relayUrl => $value) {
if ($value instanceof \Throwable) {
$this->logger->warning(sprintf(
'[%s] getArticlesByCoordinates: %s',
self::relayLogLabel($relayUrl),
$value->getMessage()
), ['coordinate' => $coordinate, 'relay' => $relayUrl]);
continue;
}
if (!\is_iterable($value)) {
continue;
}
foreach ($value as $item) { foreach ($value as $item) {
if ($item->type === 'EVENT') { if ($item->type === 'EVENT') {
$articlesMap[$coordinate] = $item->event; $articlesMap[$coordinate] = $item->event;
@ -1015,9 +1389,14 @@ class NostrClient
} }
} }
} catch (\Exception $e) { } catch (\Exception $e) {
$this->logger->error('Error fetching article', [ $this->logger->error(sprintf(
'Error fetching article (relays: %s): %s',
$relaysLogStr,
$e->getMessage()
), [
'coordinate' => $coordinate, 'coordinate' => $coordinate,
'error' => $e->getMessage() 'error' => $e->getMessage(),
'relays' => $relaysForLog,
]); ]);
} }
} }
@ -1047,24 +1426,27 @@ class NostrClient
$requestMessage = new RequestMessage($subscriptionId, [$filter]); $requestMessage = new RequestMessage($subscriptionId, [$filter]);
return new Request($relaySet ?? $this->defaultRelaySet, $requestMessage); return $this->newTimedRequest($relaySet ?? $this->defaultRelaySet, $requestMessage);
} }
private function processResponse(array $response, callable $eventHandler): array private function processResponse(array $response, callable $eventHandler): array
{ {
$results = []; $results = [];
foreach ($response as $relayUrl => $relayRes) { foreach ($response as $relayUrl => $relayRes) {
// Skip if the relay response is an Exception if ($relayRes instanceof \Throwable) {
if ($relayRes instanceof \Exception) { $this->logger->error(sprintf(
$this->logger->error('Relay error', [ 'Relay error at %s: %s',
self::relayLogLabel($relayUrl),
$relayRes->getMessage()
), [
'relay' => $relayUrl, 'relay' => $relayUrl,
'error' => $relayRes->getMessage() 'error' => $relayRes->getMessage(),
]); ]);
continue; continue;
} }
$itemEstimate = \is_countable($relayRes) ? \count($relayRes) : null; $itemEstimate = \is_countable($relayRes) ? \count($relayRes) : null;
$this->logger->debug('Processing relay response', [ $this->logger->debug(sprintf('Processing relay response from %s', self::relayLogLabel($relayUrl)), [
'relay' => $relayUrl, 'relay' => $relayUrl,
'item_count' => $itemEstimate, 'item_count' => $itemEstimate,
]); ]);
@ -1072,18 +1454,21 @@ class NostrClient
foreach ($relayRes as $item) { foreach ($relayRes as $item) {
try { try {
if (!is_object($item)) { if (!is_object($item)) {
$this->logger->warning('Invalid response item', [ $this->logger->warning(sprintf(
'Invalid response item from %s',
self::relayLogLabel($relayUrl)
), [
'relay' => $relayUrl, 'relay' => $relayUrl,
'item' => $item 'item' => $item,
]); ]);
continue; continue;
} }
switch ($item->type) { switch ($item->type) {
case 'EVENT': case 'EVENT':
$this->logger->debug('Processing event', [ $this->logger->debug(sprintf('Processing event from %s', self::relayLogLabel($relayUrl)), [
'relay' => $relayUrl, 'relay' => $relayUrl,
'event_id' => $item->event->id ?? 'unknown' 'event_id' => $item->event->id ?? 'unknown',
]); ]);
$result = $eventHandler($item->event); $result = $eventHandler($item->event);
if ($result !== null) { if ($result !== null) {
@ -1091,24 +1476,37 @@ class NostrClient
} }
break; break;
case 'AUTH': case 'AUTH':
$this->logger->warning('Relay requires authentication', [ $this->logger->warning(sprintf(
'Relay %s requires authentication',
self::relayLogLabel($relayUrl)
), [
'relay' => $relayUrl, 'relay' => $relayUrl,
'response' => $item 'response' => $item,
]); ]);
break; break;
case 'ERROR': case 'ERROR':
case 'NOTICE': case 'NOTICE':
$this->logger->warning('Relay error/notice', [ $msg = (string) ($item->message ?? 'No message');
$this->logger->warning(sprintf(
'[%s] %s: %s',
self::relayLogLabel($relayUrl),
$item->type,
$msg
), [
'relay' => $relayUrl, 'relay' => $relayUrl,
'type' => $item->type, 'type' => $item->type,
'message' => $item->message ?? 'No message' 'message' => $msg,
]); ]);
break; break;
} }
} catch (\Exception $e) { } catch (\Exception $e) {
$this->logger->error('Error processing event from relay', [ $this->logger->error(sprintf(
'Error processing event from relay %s: %s',
self::relayLogLabel($relayUrl),
$e->getMessage()
), [
'relay' => $relayUrl, 'relay' => $relayUrl,
'error' => $e->getMessage() 'error' => $e->getMessage(),
]); ]);
continue; // Skip this item but continue processing others continue; // Skip this item but continue processing others
} }
@ -1237,21 +1635,55 @@ class NostrClient
/** /**
* Latest kind 30040 index for this author and #d tag, as {@see PublicationEventEntity} * Latest kind 30040 index for this author and #d tag, as {@see PublicationEventEntity}
* so callers can use {@see PublicationEventEntity::getTags()} (relay payloads are otherwise stdClass). * so callers can use {@see PublicationEventEntity::getTags()} (relay payloads are otherwise stdClass).
*
* The magazine root uses the site d_tag from config. Each category uses the full child d
* (third segment of the root "a" address). A category 30040 lists 30023 article "a" tags, not
* further nested 30040 indices.
*/ */
public function getMagazineIndex(mixed $npub, mixed $dTag): ?PublicationEventEntity public function getMagazineIndex(mixed $npub, mixed $dTag): ?PublicationEventEntity
{
$entity = $this->queryMagazineIndex(
$npub,
$dTag,
$this->buildSingleRelaySet($this->defaultRelayUrl),
self::relayLogLabel($this->defaultRelayUrl)
);
if ($entity !== null) {
return $entity;
}
if (\count($this->configuredArticleRelayUrlList()) <= 1) {
$this->logger->warning(sprintf(
'No magazine index found (tried %s)',
self::relayLogLabel($this->defaultRelayUrl)
), ['npub' => $npub, 'dTag' => $dTag, 'relay' => $this->defaultRelayUrl]);
return null;
}
$this->logger->notice('Magazine index not on default relay, falling back to full relay set', [
'dTag' => $dTag,
]);
$fullListStr = implode(', ', array_map(self::relayLogLabel(...), $this->configuredArticleRelayUrlList()));
return $this->queryMagazineIndex($npub, $dTag, $this->defaultRelaySet, $fullListStr);
}
private function queryMagazineIndex(mixed $npub, mixed $dTag, RelaySet $relaySet, string $relaysForLog): ?PublicationEventEntity
{ {
$request = $this->createNostrRequest( $request = $this->createNostrRequest(
kinds: [KindsEnum::PUBLICATION_INDEX], [KindsEnum::PUBLICATION_INDEX],
filters: ['authors' => [(string) $npub], 'tag' => ['#d', [(string) $dTag]]], ['authors' => [(string) $npub], 'tag' => ['#d', [(string) $dTag]]],
$relaySet,
); );
$this->logger->info(sprintf('Magazine index query (relays: %s)', $relaysForLog), [
'npub' => $npub,
'dTag' => $dTag,
'relays' => $relaysForLog,
]);
$response = $request->send(); $response = $request->send();
$this->logger->info('Getting magazine index', ['npub' => $npub, 'dTag' => $dTag, 'response' => $response]); $events = $this->processResponse($response, function ($received) {
$events = $this->processResponse($response, function($received) {
$this->logger->info('Received magazine index event', ['item' => $received]);
return $received; return $received;
}); });
if (empty($events)) { if (empty($events)) {
$this->logger->warning('No magazine index found', ['npub' => $npub, 'dTag' => $dTag]);
return null; return null;
} }
usort($events, static function ($a, $b): int { usort($events, static function ($a, $b): int {
@ -1261,6 +1693,71 @@ class NostrClient
return self::magazineEventToPublicationEntity($events[0]); return self::magazineEventToPublicationEntity($events[0]);
} }
/**
* Batch-fetch longform for category `a` coordinates that are not in the DB; one Nostr call per
* (author × kind) group, only the default relay (see {@see getMagazineIndex} rationale).
*
* @param list<string> $addresses kind:pubkey:identifier
*/
public function ingestMissingLongformForCategoryCoordinates(array $addresses): void
{
if ($addresses === []) {
return;
}
$groups = [];
foreach ($addresses as $c) {
$parts = explode(':', (string) $c, 3);
if (\count($parts) < 3) {
continue;
}
$kind = (int) $parts[0];
$pubkey = $parts[1];
$d = trim((string) $parts[2]);
if ($d === '' || $kind <= 0) {
continue;
}
$gkey = $pubkey.':'.(string) $kind;
$groups[$gkey]['pubkey'] = $pubkey;
$groups[$gkey]['kind'] = $kind;
$groups[$gkey]['dTags'][] = $d;
}
foreach ($groups as $g) {
$dTags = array_values(array_unique($g['dTags'] ?? []));
if ($dTags === [] || !isset($g['pubkey'], $g['kind'])) {
continue;
}
$kindEnum = KindsEnum::tryFrom((int) $g['kind']);
if ($kindEnum === null) {
$this->logger->notice('Skipping category coordinate with unknown kind', ['kind' => $g['kind']]);
continue;
}
$request = $this->createNostrRequest(
[$kindEnum],
['authors' => [(string) $g['pubkey']], 'tag' => ['#d', $dTags]],
$this->buildSingleRelaySet($this->defaultRelayUrl),
);
try {
$this->processResponse($request->send(), function ($event) {
$article = $this->articleFactory->createFromLongFormContentEvent($event);
$this->saveEachArticleToTheDatabase($article);
return null;
});
} catch (\Throwable $e) {
$this->logger->error(sprintf(
'ingestMissingLongformForCategoryCoordinates [%s]: %s',
self::relayLogLabel($this->defaultRelayUrl),
$e->getMessage()
), [
'message' => $e->getMessage(),
'pubkey' => $g['pubkey'] ?? null,
'relay' => $this->defaultRelayUrl,
]);
}
}
}
private static function magazineEventCreatedAt(mixed $event): int private static function magazineEventCreatedAt(mixed $event): int
{ {
if ($event instanceof PublicationEventEntity) { if ($event instanceof PublicationEventEntity) {

35
src/Twig/Components/Header.php

@ -4,11 +4,7 @@ declare(strict_types=1);
namespace App\Twig\Components; namespace App\Twig\Components;
use App\Service\NostrClient; use App\Service\MagazineContentService;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent] #[AsTwigComponent]
@ -16,34 +12,9 @@ class Header
{ {
public array $cats; public array $cats;
/**
* @throws InvalidArgumentException
*/
public function __construct( public function __construct(
private readonly CacheInterface $cache, private readonly MagazineContentService $magazineContent,
private readonly ParameterBagInterface $params,
private readonly NostrClient $nostrClient,
) { ) {
$dTag = (string) $this->params->get('d_tag'); $this->cats = $this->magazineContent->getHomeCategoryIndexTags();
$npub = (string) $this->params->get('npub');
// Same key as {@see DefaultController::index()} — must load the real index (not cache `null`).
$cacheKey = 'magazine_root_'.$dTag;
$mag = $this->cache->get($cacheKey, function (ItemInterface $item) use ($npub, $dTag) {
$item->expiresAfter(300);
return $this->nostrClient->getMagazineIndex($npub, $dTag);
});
if ($mag === null) {
$this->cats = [];
return;
}
$tags = $mag->getTags();
$this->cats = array_filter($tags, static function ($tag): bool {
return ($tag[0] ?? null) === 'a';
});
} }
} }

32
src/Twig/Components/Molecules/CategoryLink.php

@ -2,10 +2,7 @@
namespace App\Twig\Components\Molecules; namespace App\Twig\Components\Molecules;
use App\Service\NostrClient; use App\Service\MagazineIndexStore;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent] #[AsTwigComponent]
@ -16,9 +13,7 @@ final class CategoryLink
public string $slug = ''; public string $slug = '';
public function __construct( public function __construct(
private readonly CacheInterface $cache, private readonly MagazineIndexStore $store,
private readonly ParameterBagInterface $params,
private readonly NostrClient $nostrClient,
) { ) {
} }
@ -34,31 +29,14 @@ final class CategoryLink
} }
$this->title = $this->slug; $this->title = $this->slug;
$npub = (string) $this->params->get('npub'); $cat = $this->store->getCategory($this->slug);
// Same cache key/TTL as DefaultController::magCategory(); load from relay on miss (not read-only).
// The cache callback must return data on miss; otherwise the homepage shows raw d-tags.
try {
$cat = $this->cache->get('magazine-' . $this->slug, function (ItemInterface $item) use ($npub) {
$item->expiresAfter(300);
$mag = $this->nostrClient->getMagazineIndex($npub, $this->slug);
if ($mag === null) {
// Do not persist null: FeaturedList would get a cache hit and call getTags() on null.
throw new \RuntimeException('Category index not found for '.$this->slug);
}
return $mag;
});
} catch (\Throwable) {
return;
}
if (!\is_object($cat) || !\method_exists($cat, 'getTags')) { if (!\is_object($cat) || !\method_exists($cat, 'getTags')) {
return; return;
} }
$tags = $cat->getTags(); $tags = $cat->getTags();
$titleTags = array_filter($tags, static function ($tag): bool { $titleTags = array_filter($tags, static function (mixed $tag): bool {
return isset($tag[0]) && $tag[0] === 'title' && isset($tag[1]); return \is_array($tag) && ($tag[0] ?? null) === 'title' && isset($tag[1]);
}); });
$first = array_key_first($titleTags); $first = array_key_first($titleTags);
if ($first !== null) { if ($first !== null) {

32
src/Twig/Components/Organisms/FeaturedList.php

@ -3,11 +3,8 @@
namespace App\Twig\Components\Organisms; namespace App\Twig\Components\Organisms;
use App\Repository\ArticleRepository; use App\Repository\ArticleRepository;
use App\Service\NostrClient; use App\Service\MagazineIndexStore;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent] #[AsTwigComponent]
@ -20,10 +17,8 @@ final class FeaturedList
public array $list = []; public array $list = [];
public function __construct( public function __construct(
private readonly CacheInterface $cache, private readonly MagazineIndexStore $store,
private readonly ArticleRepository $articleRepository, private readonly ArticleRepository $articleRepository,
private readonly NostrClient $nostrClient,
private readonly ParameterBagInterface $params,
) { ) {
} }
@ -43,22 +38,8 @@ final class FeaturedList
} }
$slug = $parts[2]; $slug = $parts[2];
$npub = (string) $this->params->get('npub');
try {
$catIndex = $this->cache->get('magazine-' . $slug, function (ItemInterface $item) use ($npub, $slug) {
$item->expiresAfter(300);
$mag = $this->nostrClient->getMagazineIndex($npub, $slug);
if ($mag === null) {
throw new \RuntimeException('Category index not found for '.$slug);
}
return $mag;
});
} catch (\Throwable) {
return;
}
$catIndex = $this->store->getCategory($slug);
if (!\is_object($catIndex) || !\method_exists($catIndex, 'getTags')) { if (!\is_object($catIndex) || !\method_exists($catIndex, 'getTags')) {
return; return;
} }
@ -70,7 +51,7 @@ final class FeaturedList
} }
if (($tag[0] ?? null) === 'a' && isset($tag[1])) { if (($tag[0] ?? null) === 'a' && isset($tag[1])) {
$segs = explode(':', (string) $tag[1], 3); $segs = explode(':', (string) $tag[1], 3);
$slugs[] = end($segs); $slugs[] = trim((string) end($segs));
if (\count($slugs) >= 5) { if (\count($slugs) >= 5) {
break; break;
} }
@ -89,7 +70,7 @@ final class FeaturedList
$slugMap = []; $slugMap = [];
foreach ($articles as $article) { foreach ($articles as $article) {
$articleSlug = $article->getSlug(); $articleSlug = trim((string) $article->getSlug());
if ($articleSlug !== '') { if ($articleSlug !== '') {
if (!isset($slugMap[$articleSlug])) { if (!isset($slugMap[$articleSlug])) {
$slugMap[$articleSlug] = $article; $slugMap[$articleSlug] = $article;
@ -101,7 +82,8 @@ final class FeaturedList
$orderedList = []; $orderedList = [];
foreach ($slugs as $articleSlug) { foreach ($slugs as $articleSlug) {
if (isset($slugMap[$articleSlug])) { $articleSlug = trim((string) $articleSlug);
if ($articleSlug !== '' && isset($slugMap[$articleSlug])) {
$orderedList[] = $slugMap[$articleSlug]; $orderedList[] = $slugMap[$articleSlug];
} }
} }

7
src/Util/CommonMark/NostrSchemeExtension/NostrSchemeParser.php

@ -9,8 +9,6 @@ use nostriphant\NIP19\Bech32;
use nostriphant\NIP19\Data\NAddr; use nostriphant\NIP19\Data\NAddr;
use nostriphant\NIP19\Data\NEvent; use nostriphant\NIP19\Data\NEvent;
use nostriphant\NIP19\Data\NProfile; use nostriphant\NIP19\Data\NProfile;
use nostriphant\NIP19\Data\NPub;
class NostrSchemeParser implements InlineParserInterface class NostrSchemeParser implements InlineParserInterface
{ {
@ -38,9 +36,8 @@ class NostrSchemeParser implements InlineParserInterface
switch ($decoded->type) { switch ($decoded->type) {
case 'npub': case 'npub':
/** @var NPub $decoded */ // Use the decoded bech32 (npub1…). NPub::$data is the hex pubkey; NostrMentionLink /author routes expect npub1…
$decoded = $decoded->data; $inlineContext->getContainer()->appendChild(new NostrMentionLink(null, $bechEncoded));
$inlineContext->getContainer()->appendChild(new NostrMentionLink(null, $decoded->data->data));
break; break;
case 'nprofile': case 'nprofile':
/** @var NProfile $decodedProfile */ /** @var NProfile $decodedProfile */

17
templates/base.html.twig

@ -4,7 +4,10 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ website_name }}{% endblock %}</title> <title>{% block title %}{{ website_name }}{% endblock %}</title>
<meta name="description" content="{{ website_description }}"> {# Unfurlers often use the first meta description; pages override this block. #}
{% block meta_description %}
<meta name="description" content="{{ website_description|e('html_attr') }}">
{% endblock %}
<link rel="icon" type="image/png" href="{{ asset('icons/favicon-96x96.png') }}" sizes="96x96" /> <link rel="icon" type="image/png" href="{{ asset('icons/favicon-96x96.png') }}" sizes="96x96" />
<link rel="icon" type="image/x-icon" href="{{ asset('icons/favicon.ico') }}" /> <link rel="icon" type="image/x-icon" href="{{ asset('icons/favicon.ico') }}" />
<link rel="shortcut icon" href="{{ asset('icons/favicon.ico') }}" /> <link rel="shortcut icon" href="{{ asset('icons/favicon.ico') }}" />
@ -14,7 +17,10 @@
<link rel="manifest" href="{{ path('pwa_manifest') }}"> <link rel="manifest" href="{{ path('pwa_manifest') }}">
{% block ogtags %} {% block ogtags %}
<meta property="og:image" content="{{ absolute_url(asset('og-image.jpg')) }}" /> <meta property="og:type" content="website">
<meta property="og:description" content="{{ website_description|e('html_attr') }}">
<meta property="og:image" content="{{ absolute_url(asset('og-image.jpg'))|e('html_attr') }}">
<meta property="og:site_name" content="{{ website_name|e('html_attr') }}">
{% endblock %} {% endblock %}
{% block javascripts %} {% block javascripts %}
{% block importmap %}{{ importmap('app') }}{% endblock %} {% block importmap %}{{ importmap('app') }}{% endblock %}
@ -23,7 +29,12 @@
<link rel="stylesheet" href="{{ asset('theme.css') }}"> <link rel="stylesheet" href="{{ asset('theme.css') }}">
{% endblock %} {% endblock %}
</head> </head>
<body data-controller="service-worker"> <body
data-controller="service-worker magazine-sync"
data-magazine-sync-page-value="{% block magazine_sync_page %}article{% endblock %}"
data-magazine-sync-slug-value="{% block magazine_sync_slug %}{% endblock %}"
data-magazine-sync-url-value="{{ path('ux_magazine_sync') }}"
>
<twig:Header /> <twig:Header />

4
templates/components/Header.html.twig

@ -11,9 +11,9 @@
<button class="hamburger btn btn-secondary" data-action="click->menu#toggle" aria-label="Menu">&#9776;</button> <button class="hamburger btn btn-secondary" data-action="click->menu#toggle" aria-label="Menu">&#9776;</button>
</div> </div>
<div class="header__categories" data-menu-target="menu"> <div class="header__categories" data-menu-target="menu">
<ul> <ul data-magazine-sync-target="headerNav">
{% for category in cats %} {% for category in cats %}
<li><twig:Molecules:CategoryLink category="{{ category }}" /></li> <li><twig:Molecules:CategoryLink :category="category" /></li>
{% endfor %} {% endfor %}
{% if magazine_community_articles %} {% if magazine_community_articles %}
<li> <li>

2
templates/components/Organisms/FeaturedList.html.twig

@ -1,6 +1,6 @@
<div> <div>
{% if list %} {% if list %}
<div class="featured-cat hidden"> <div class="featured-cat">
<small><b>{{ title }}</b></small> <small><b>{{ title }}</b></small>
</div> </div>
<div {{ attributes }}> <div {{ attributes }}>

19
templates/home.html.twig

@ -1,13 +1,30 @@
{% extends 'base.html.twig' %} {% extends 'base.html.twig' %}
{% block magazine_sync_page %}home{% endblock %}
{% block ogtags %}
{% set _og_image = absolute_url(asset('og-image.jpg')) %}
<meta property="og:type" content="website">
<meta property="og:url" content="{{ url('home') }}">
<meta property="og:title" content="{{ website_name|e('html_attr') }}">
<meta property="og:description" content="{{ website_description|e('html_attr') }}">
<meta property="og:image" content="{{ _og_image|e('html_attr') }}">
<meta property="og:site_name" content="{{ website_name|e('html_attr') }}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ website_name|e('html_attr') }}">
<meta name="twitter:description" content="{{ website_description|e('html_attr') }}">
<meta name="twitter:image" content="{{ _og_image|e('html_attr') }}">
{% endblock %}
{% block nav %} {% block nav %}
{% endblock %} {% endblock %}
{% block body %} {% block body %}
{# content #} <div class="home-body" data-magazine-sync-target="pageBody">
{% for item in indices %} {% for item in indices %}
<twig:Organisms:FeaturedList :category="item" class="featured-list"/> <twig:Organisms:FeaturedList :category="item" class="featured-list"/>
{% endfor %} {% endfor %}
</div>
{% endblock %} {% endblock %}
{% block aside %} {% block aside %}

61
templates/pages/article.html.twig

@ -1,16 +1,61 @@
{% extends 'base.html.twig' %} {% extends 'base.html.twig' %}
{% block title %}{{ article.title|trim }} — {{ website_name }}{% endblock %}
{% block meta_description %}
{% set _desc = article.summary|default('')|striptags|u.truncate(159, '…') %}
<meta name="description" content="{{ _desc|e('html_attr') }}">
{% endblock %}
{% block ogtags %} {% block ogtags %}
<meta property="og:title" content="{{ article.title }}"> {# Upstream main only outputs og:image when article.image is set — unfurlers often show a blank card. Always set an absolute image + JSON-LD. #}
<meta property="og:type" content="article"> {% set _img = article.image|default('')|trim %}
<meta property="og:url" content="{{ app.request.uri }}"> {% if _img == '' %}
{% if article.image %} {% set _og_image = absolute_url(asset('og-image.jpg')) %}
<meta property="og:image" content="{{ article.image }}"> {% set _og_default_dims = true %}
{% elseif _img starts with '//' %}
{% set _og_image = 'https:' ~ _img %}
{% set _og_default_dims = false %}
{% else %} {% else %}
<meta property="og:image" content="{{ absolute_url(asset('og-image.jpg')) }}"> {% set _og_image = absolute_url(_img) %}
{% set _og_default_dims = false %}
{% endif %}
{% set _desc = article.summary|default('')|striptags|u.truncate(159, '…') %}
{% set _canonical = url('article-slug', {slug: article.slug}) %}
{% set _author_name = '' %}
{% if author is defined and author %}
{% set _author_name = attribute(author, 'name')|default(attribute(author, 'display_name')|default('')) %}
{% endif %}
<meta property="og:title" content="{{ article.title|e('html_attr') }}">
<meta property="og:type" content="article">
<meta property="og:url" content="{{ _canonical|e('html_attr') }}">
<meta property="og:image" content="{{ _og_image|e('html_attr') }}">
{% if _og_image starts with 'https://' %}
<meta property="og:image:secure_url" content="{{ _og_image|e('html_attr') }}">
{% endif %}
{% if _og_default_dims %}
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
{% endif %} {% endif %}
<meta property="og:description" content="{{ article.summary|striptags|u.truncate(159,'…')|e }}"> <meta property="og:description" content="{{ _desc|e('html_attr') }}">
<meta property="og:site_name" content="{{ website_name }}"> <meta property="og:site_name" content="{{ website_name|e('html_attr') }}">
<link rel="canonical" href="{{ _canonical|e('html_attr') }}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ article.title|e('html_attr') }}">
<meta name="twitter:description" content="{{ _desc|e('html_attr') }}">
<meta name="twitter:image" content="{{ _og_image|e('html_attr') }}">
<script type="application/ld+json">{{ {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
'headline': article.title,
'description': _desc,
'image': _og_image,
'url': _canonical,
'datePublished': (article.publishedAt ?? article.createdAt)|date('c'),
'mainEntityOfPage': {'@type': 'WebPage', '@id': _canonical},
'publisher': {'@type': 'Organization', 'name': website_name},
'author': {'@type': 'Person', 'name': _author_name != '' ? _author_name : (npub|default('Author'))}
}|json_encode|raw }}</script>
{% endblock %} {% endblock %}
{% block body %} {% block body %}

30
templates/pages/category.html.twig

@ -1,19 +1,39 @@
{% extends 'base.html.twig' %} {% extends 'base.html.twig' %}
{% block magazine_sync_page %}{% if app.request.attributes.get('_route') == 'articles' %}articles{% else %}category{% endif %}{% endblock %}
{% block magazine_sync_slug %}{{ (sync_slug|default(''))|e('html_attr') }}{% endblock %}
{% block title %}{{ (category.title|default(''))|trim != '' ? category.title|trim ~ ' — ' ~ website_name : website_name }}{% endblock %}
{% block meta_description %}
{% set _summary = category.summary|default('')|striptags|u.truncate(159, '…') %}
<meta name="description" content="{{ (_summary != '' ? _summary : (category.title|default('')|striptags))|e('html_attr') }}">
{% endblock %}
{% block ogtags %} {% block ogtags %}
<meta property="og:title" content="{{ category.title|default('') }}"> {% set _title = category.title|default('') %}
{% set _summary = category.summary|default('')|striptags|u.truncate(159, '…') %}
{% set _og_image = absolute_url(asset('og-image.jpg')) %}
<meta property="og:title" content="{{ _title|e('html_attr') }}">
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:url" content="{{ app.request.uri }}"> <meta property="og:url" content="{{ app.request.attributes.get('_route') == 'articles' ? url('articles') : url('magazine-category', {slug: sync_slug|default(app.request.attributes.get('slug'))}) }}">
<meta property="og:description" content="{{ category.summary|default('') }}"> <meta property="og:description" content="{{ (_summary != '' ? _summary : _title)|e('html_attr') }}">
<meta property="og:image" content="{{ absolute_url(asset('og-image.jpg')) }}"> <meta property="og:image" content="{{ _og_image|e('html_attr') }}">
<meta property="og:site_name" content="{{ website_name }}"> <meta property="og:site_name" content="{{ website_name|e('html_attr') }}">
<link rel="canonical" href="{{ app.request.attributes.get('_route') == 'articles' ? url('articles') : url('magazine-category', {slug: sync_slug|default(app.request.attributes.get('slug'))}) }}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ _title|e('html_attr') }}">
<meta name="twitter:description" content="{{ (_summary != '' ? _summary : _title)|e('html_attr') }}">
<meta name="twitter:image" content="{{ _og_image|e('html_attr') }}">
{% endblock %} {% endblock %}
{% block nav %} {% block nav %}
{% endblock %} {% endblock %}
{% block body %} {% block body %}
<div class="category-body" data-magazine-sync-target="pageBody">
<twig:Organisms:CardList :list="list" class="article-list" /> <twig:Organisms:CardList :list="list" class="article-list" />
</div>
{% endblock %} {% endblock %}
{% block aside %} {% block aside %}

3
templates/ux/magazine/category_body.html.twig

@ -0,0 +1,3 @@
<div class="category-body" data-magazine-sync-target="pageBody">
<twig:Organisms:CardList :list="list" class="article-list" />
</div>

10
templates/ux/magazine/header_ul.html.twig

@ -0,0 +1,10 @@
<ul data-magazine-sync-target="headerNav">
{% for category in cats %}
<li><twig:Molecules:CategoryLink :category="category" /></li>
{% endfor %}
{% if magazine_community_articles %}
<li>
<a href="{{ path('articles') }}">Latest Articles</a>
</li>
{% endif %}
</ul>

5
templates/ux/magazine/home_body.html.twig

@ -0,0 +1,5 @@
<div class="home-body" data-magazine-sync-target="pageBody">
{% for item in indices %}
<twig:Organisms:FeaturedList :category="item" class="featured-list"/>
{% endfor %}
</div>
Loading…
Cancel
Save