Compare commits

..

No commits in common. '2e6b7e178a22c2a63c91cfa5302f42b1efd33c65' and 'd5313b2211aa6c0141724c003ed682a4f01788b4' have entirely different histories.

  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. 82
      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. 27
      src/Service/ArticleCommentThreadLoader.php
  35. 78
      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. 705
      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. 25
      templates/home.html.twig
  49. 61
      templates/pages/article.html.twig
  50. 32
      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,7 +3,6 @@ @@ -3,7 +3,6 @@
**/*.php~
**/*.dist.php
**/*.dist
!.env.dist
**/*.cache
**/._*
**/.dockerignore
@ -29,7 +28,6 @@ tests/ @@ -29,7 +28,6 @@ tests/
var/
vendor/
.editorconfig
/.env
.env.*.local
.env.local
.env.local.php

12
.env.dist

@ -17,8 +17,6 @@ @@ -17,8 +17,6 @@
###> symfony/framework-bundle ###
APP_ENV=dev
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 ###
###> docker ###
# Dev URL: http://127.0.0.1:${HTTP_PORT}/ (override HTTP_PORT/HTTPS_PORT if busy).
@ -34,16 +32,6 @@ MYSQL_PASSWORD=password @@ -34,16 +32,6 @@ MYSQL_PASSWORD=password
# Root password is only used for the bundled database service, see compose.yaml
# skip it, if you use your own
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 ###
###> doctrine/doctrine-bundle ###

5
Dockerfile

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

7
Makefile

@ -1,7 +0,0 @@ @@ -1,7 +0,0 @@
# 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,130 +1,79 @@ @@ -1,130 +1,79 @@
# Unfold: Imwald
# Unfold
<p align="center">
<img src="assets/laeserin_logo.png" alt="Imwald" width="150">
</p>
Unfold is a customizable framework for your Nostr-based magazine.
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).
(This is the **Imwald** edition of Unfold.)
---
## Setup
## 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:
### Clone the repository
```bash
make prewarm
git clone https://github.com/decent-newsroom/unfold.git
cd unfold
```
| 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`) |
`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.
### Create the .env file
---
Copy the example file `.env.dist` and replace placeholders with your actual configuration.
## Console commands (overview)
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.
| Command | Purpose |
|---------|---------|
| `articles:get <from> <to>` | Pull long-form articles from Nostr for the time range, persist to DB |
| `app:prewarm` | Magazine relay refresh + metadata cache + comment cache warm |
| `doctrine:migrations:migrate` | Apply SQL migrations |
| `user:elevate` | (If used) user elevation helper |
### Configure `config/unfold.yaml`
`php bin/console list` and `… -h` for full options.
Before running the application, review and update `config/unfold.yaml` to match your desired magazine settings, theme, and external links. This file controls:
- Magazine name, short name, and description
- Theme and color settings
- Community articles feature
- External footer links
- Other project-specific configuration
### `app:prewarm` (notable options)
Edit the values in `config/unfold.yaml` as needed for your deployment.
| Option | Default | Meaning |
|--------|---------|--------|
| `--no-magazine` | off | Skip magazine 30040 index |
| `--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 |
### Customizing Theme and Icons
Prewarm clears the PHP **CLI** execution time limit for that run; relay work can be slow.
You can override the default theme and icons by adding your own files to `/assets/theme/local/`. To do this:
- Copy the structure and file names from `/assets/theme/default/`.
- Place your custom `theme.css` and icon files in your theme folder.
- Update your configuration in `config/unfold.yaml` to reference your custom theme if needed.
### `PREWARM_ON_START` (optional)
This allows you to easily switch or update the look and feel of your magazine without modifying the default assets.
| Variable | Set where | Effect |
|----------|------------|--------|
| `PREWARM_ON_START=1` | **Compose `environment` on the `php` service** (not only Symfony `.env` inside the container) | After DB is up and migrations run, executes **`app:prewarm` once** on start. **Does not** run `articles:get`. |
For a full **Nostr backfill** + one-shot prewarm, use **`make prewarm`** (or a host **cron** / **systemd** timer) instead of relying on **`PREWARM_ON_START` alone**.
### Build the Docker containers
---
## Configuration
| What | File |
|------|------|
| 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` |
For development:
```bash
docker compose build
```
**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`).
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
```
---
## Production / Hub image
### Start the Docker containers
```bash
docker compose up -d
```
| Topic | Notes |
|-------|--------|
| `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. |
File header in `compose.hub.yaml` lists pull, migrate, and optional build/push one-liners.
### Run Database Migrations
---
Before fetching or displaying articles, make sure your database schema is up to date. Run:
## License
```bash
docker compose exec php php bin/console doctrine:migrations:migrate --no-interaction
```
**MIT** — see [`LICENSE`](LICENSE).
### Fetching Articles
---
To fetch articles from the default relay for the last two months, run:
## Project links (example)
```bash
docker compose exec php php bin/console articles:get -- '-2 month' 'now'
```
Configurable under `parameters.external_links` in `config/unfold.yaml` (e.g. Unfold on GitHub, Decent Newsroom). Adjust for your deployment.
You can adjust the date range as needed. This command will import articles into the local database.

6
assets/bootstrap.js vendored

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

49
assets/controllers/magazine_sync_controller.js

@ -1,49 +0,0 @@ @@ -1,49 +0,0 @@
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

@ -1,61 +0,0 @@ @@ -1,61 +0,0 @@
# 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,11 +13,8 @@ services: @@ -13,11 +13,8 @@ services:
# from the bind-mount for better performance by enabling the next line:
- /app/vendor
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
XDEBUG_MODE: "${XDEBUG_MODE:-develop}"
XDEBUG_MODE: "${XDEBUG_MODE:-off}"
ports:
# Defaults avoid crowded 8080/8443; override with HTTP_PORT / HTTPS_PORT in .env
- "127.0.0.1:${HTTP_PORT:-9080}:80/tcp"

6
compose.yaml

@ -44,12 +44,8 @@ services: @@ -44,12 +44,8 @@ services:
context: ./docker/cron
volumes:
- .:/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:
database:
condition: service_healthy
- php
volumes:
caddy_data:

4
composer.json

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

131
composer.lock generated

@ -4,7 +4,7 @@ @@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "bd231af493534154dcbc0c3f059f329e",
"content-hash": "35730266c171fc5bbbe90ce2c0781509",
"packages": [
{
"name": "bitwasp/bech32",
@ -1403,64 +1403,6 @@ @@ -1403,64 +1403,6 @@
},
"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",
"version": "v4.4.17",
@ -2170,6 +2112,59 @@ @@ -2170,6 +2112,59 @@
],
"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",
"version": "2.10.0",
@ -3893,25 +3888,25 @@ @@ -3893,25 +3888,25 @@
},
{
"name": "swentel/nostr-php",
"version": "1.9.4",
"version": "1.9.2",
"source": {
"type": "git",
"url": "https://github.com/nostrver-se/nostr-php.git",
"reference": "e502540ea811199443e1fffcbdaef9048940399c"
"reference": "8fb8337354b2e9d48a901276c7814d7fa7b25653"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nostrver-se/nostr-php/zipball/e502540ea811199443e1fffcbdaef9048940399c",
"reference": "e502540ea811199443e1fffcbdaef9048940399c",
"url": "https://api.github.com/repos/nostrver-se/nostr-php/zipball/8fb8337354b2e9d48a901276c7814d7fa7b25653",
"reference": "8fb8337354b2e9d48a901276c7814d7fa7b25653",
"shasum": ""
},
"require": {
"bitwasp/bech32": "^0.0.1",
"dsbaars/chacha20": "^0.3.0",
"ext-gmp": "*",
"ext-xml": "*",
"leigh/chacha20": "^0.2.0",
"paragonie/ecc": "^2.4",
"php": ">=8.2 <8.6",
"php": ">=8.1 <8.5",
"phrity/websocket": "^3.0",
"simplito/elliptic-php": "^1.0"
},
@ -3952,9 +3947,9 @@ @@ -3952,9 +3947,9 @@
"chat": "https://t.me/nostr_php",
"issue": "https://github.com/swentel/nostr-php/issues",
"issues": "https://github.com/nostrver-se/nostr-php/issues",
"source": "https://github.com/nostrver-se/nostr-php/tree/1.9.4"
"source": "https://github.com/nostrver-se/nostr-php/tree/1.9.2"
},
"time": "2026-02-03T11:13:00+00:00"
"time": "2025-06-04T14:51:06+00:00"
},
{
"name": "symfony/asset",
@ -11321,5 +11316,5 @@ @@ -11321,5 +11316,5 @@
"ext-openssl": "*"
},
"platform-dev": {},
"plugin-api-version": "2.9.0"
"plugin-api-version": "2.6.0"
}

4
config/packages/asset_mapper.yaml

@ -1,9 +1,5 @@ @@ -1,9 +1,5 @@
framework:
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.
paths:
- assets/theme/local # Highest priority (overrides)

5
config/packages/framework.yaml

@ -10,14 +10,11 @@ framework: @@ -10,14 +10,11 @@ framework:
cookie_samesite: lax
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_proxies: '%env(TRUSTED_PROXIES)%'
#trusted_proxies: 'symfony,REMOTE_ADDR'
#esi: true
#fragments: true
when@prod:
framework:
trusted_proxies: '%env(TRUSTED_PROXIES)%'
when@test:
framework:
test: true

14
config/services.yaml

@ -8,8 +8,6 @@ imports: @@ -8,8 +8,6 @@ imports:
parameters:
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:
# default configuration for services in *this* file
@ -34,19 +32,7 @@ services: @@ -34,19 +32,7 @@ services:
App\Service\NostrClient:
arguments:
$defaultRelayUrl: '%default_relay%'
$articleRelayUrls: '%article_relays%'
$profileRelayUrls: '%profile_relays%'
App\Twig\FooterLinksExtension:
arguments:
$footerLinksPath: '%footer_links%'
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,18 +9,6 @@ parameters: @@ -9,18 +9,6 @@ parameters:
og_subheading: 'Imwald Blog by Laeserin'
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_color: '#8c2f1c'

35
docker/cron/Dockerfile

@ -1,21 +1,30 @@ @@ -1,21 +1,30 @@
FROM php:8.3-cli
FROM php:8.2-cli
RUN apt-get update && apt-get install -y --no-install-recommends \
bash \
# Install cron and Redis PHP extension dependencies
RUN apt-get update && apt-get install -y \
cron \
&& rm -rf /var/lib/apt/lists/*
libzip-dev \
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
COPY crontab /etc/cron.d/unfold-prewarm
COPY prewarm_cron.sh /prewarm_cron.sh
COPY entry-cron.sh /entry-cron.sh
# Install Symfony CLI tools (optional)
# RUN curl -sS https://get.symfony.com/cli/installer | bash
RUN chmod 0644 /etc/cron.d/unfold-prewarm \
&& chmod +x /prewarm_cron.sh /entry-cron.sh
# Copy cron and script
COPY crontab /etc/cron.d/app-cron
COPY index_articles.sh /index_articles.sh
CMD ["/entry-cron.sh"]
# Set permissions
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,15 +1,94 @@ @@ -1,15 +1,94 @@
# `cron` service (Docker)
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`.
# 🕒 Cron Job 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).
This folder contains the Docker configuration to run scheduled Symfony commands via cron inside a separate container.
- **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.
- Run Symfony console commands periodically using a cron schedule (e.g. every 6 hours)
- 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`).
---
- **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.
## Build & Run
- **Not included in** `compose.hub.yaml` (no app source mount). For production images, use host **cron** / **systemd** to `exec` the same command.
1. **Build the cron image**
From the project root:
```bash
docker-compose build cron
```
Change the schedule: edit `docker/cron/crontab`, then `docker compose build cron && docker compose up -d cron`.
2. **Start the cron container**
```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 @@ @@ -1 +1 @@
*/10 * * * * root /prewarm_cron.sh >>/var/log/cron.log 2>&1
*/5 * * * * /index_articles.sh >> /var/log/cron.log 2>&1

6
docker/cron/entry-cron.sh

@ -1,6 +0,0 @@ @@ -1,6 +0,0 @@
#!/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

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
#!/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

@ -1,9 +0,0 @@ @@ -1,9 +0,0 @@
#!/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,6 +1,4 @@ @@ -1,6 +1,4 @@
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
apc.enable_cli = 1
session.use_strict_mode = 1

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

@ -2,6 +2,4 @@ @@ -2,6 +2,4 @@
; See https://github.com/docker/for-linux/issues/264
; 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
; develop is required for xdebug_info() and related helpers. Docker sets XDEBUG_MODE (overrides this).
xdebug.mode = develop
xdebug.client_host = host.docker.internal

12
frankenphp/docker-entrypoint.sh

@ -16,7 +16,7 @@ if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then @@ -16,7 +16,7 @@ if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then
composer require "php:>=$PHP_VERSION" runtime/frankenphp-symfony
composer config --json extra.symfony.docker 'true'
if [ -f .env ] && grep -q ^DATABASE_URL= .env; then
if 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'
sleep infinity
fi
@ -26,8 +26,7 @@ if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then @@ -26,8 +26,7 @@ if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then
composer install --prefer-dist --no-progress --no-interaction
fi
# 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
if grep -q ^DATABASE_URL= .env; then
echo 'Waiting for database to be ready...'
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
@ -52,13 +51,6 @@ if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then @@ -52,13 +51,6 @@ if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then
if [ "$( find ./migrations -iname '*.php' -print -quit )" ]; then
php bin/console doctrine:migrations:migrate --no-interaction --all-or-nothing
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
setfacl -R -m u:www-data:rwX -m u:"$(whoami)":rwX var

3
importmap.php

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

22
scripts/docker-prewarm.sh

@ -1,22 +0,0 @@ @@ -1,22 +0,0 @@
#!/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

@ -1,250 +0,0 @@ @@ -1,250 +0,0 @@
<?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');
}
}

82
src/Controller/ArticleController.php

@ -208,64 +208,45 @@ class ArticleController extends AbstractController @@ -208,64 +208,45 @@ class ArticleController extends AbstractController
CacheItemPoolInterface $articlesCache
): Response {
$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);
$previewData = [];
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 (!isset($descriptor->decoded) || !\is_string($descriptor->decoded)) {
$html = '<span class="text-subtle">Profile preview unavailable.</span>';
} else {
$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();
$npub = $key->convertPublicKeyToBech32($hint->pubkey);
$metadata = $cacheService->getMetadata($npub);
$metadata->npub = $npub;
$metadata->pubkey = $hint->pubkey;
$metadata->type = 'nprofile';
$html = $this->renderView('components/Molecules/NostrPreviewContent.html.twig', [
'preview' => $metadata,
]);
}
}
} elseif (!isset($descriptor->decoded)) {
$html = '<span class="text-subtle">Preview unavailable (missing data).</span>';
} else {
try {
$previewData = $nostrClient->getEventFromDescriptor($descriptor);
} catch (\Throwable $e) {
$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', [
'preview' => $previewData,
]);
}
// if nprofile, get from redis cache
if ($descriptor->type === 'nprofile') {
$hint = json_decode($descriptor->decoded);
$key = new Key();
$npub = $key->convertPublicKeyToBech32($hint->pubkey);
$metadata = $cacheService->getMetadata($npub);
$metadata->npub = $npub;
$metadata->pubkey = $hint->pubkey;
$metadata->type = 'nprofile';
// Render the NostrPreviewContent component with the preview data
$html = $this->renderView('components/Molecules/NostrPreviewContent.html.twig', [
'preview' => $metadata
]);
} else {
// For nevent or naddr, fetch the event data
try {
$previewData = $nostrClient->getEventFromDescriptor($descriptor);
$previewData->type = $descriptor->type; // Add type to the preview data
// Render the NostrPreviewContent component with the preview data
$html = $this->renderView('components/Molecules/NostrPreviewContent.html.twig', [
'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(
$html,
Response::HTTP_OK,
['Content-Type' => 'text/html; charset=UTF-8']
['Content-Type' => 'text/html']
);
}
@ -378,7 +359,6 @@ class ArticleController extends AbstractController @@ -378,7 +359,6 @@ class ArticleController extends AbstractController
return $this->render('pages/category.html.twig', [
'category' => $category,
'list' => $articles,
'sync_slug' => '',
]);
}

128
src/Controller/DefaultController.php

@ -4,37 +4,134 @@ declare(strict_types=1); @@ -4,37 +4,134 @@ declare(strict_types=1);
namespace App\Controller;
use App\Service\MagazineContentService;
use App\Repository\ArticleRepository;
use App\Service\NostrClient;
use Exception;
use Psr\Cache\InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface;
use Psr\Log\LoggerInterface;
class DefaultController extends AbstractController
{
public function __construct(
private readonly MagazineContentService $magazineContent,
) {
}
private readonly CacheInterface $cache,
private readonly NostrClient $nostrClient,
private readonly ParameterBagInterface $params
) {}
/**
* @throws Exception
* @throws InvalidArgumentException
*/
#[Route('/', name: 'home')]
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', [
'indices' => $this->magazineContent->getHomeCategoryIndexTags(),
'indices' => array_values($cats)
]);
}
/**
* @throws InvalidArgumentException
*/
#[Route('/cat/{slug}', name: 'magazine-category')]
public function magCategory(string $slug): Response
public function magCategory($slug, ArticleRepository $articleRepository, LoggerInterface $logger): Response
{
$data = $this->magazineContent->getCategoryPageData($slug);
$npub = $this->params->get('npub');
$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', [
'list' => $data['list'],
'category' => $data['category'],
'sync_slug' => $slug,
'list' => $list,
'category' => $category
]);
}
@ -54,19 +151,18 @@ class DefaultController extends AbstractController @@ -54,19 +151,18 @@ class DefaultController extends AbstractController
$embed = new \Embed\Embed();
$info = $embed->get($url);
if (!$info) {
throw new Exception('No OG data found');
throw new \Exception('No OG data found');
}
return $this->render('components/Molecules/OgPreview.html.twig', [
'og' => [
'title' => $info->title,
'description' => $info->description,
'image' => $info->image,
'url' => $url,
],
'url' => $url
]
]);
} catch (Exception $e) {
return new Response('<div class="alert alert-warning">Unable to load OG preview for '.htmlspecialchars($url).'</div>', 200);
} catch (\Exception $e) {
return new Response('<div class="alert alert-warning">Unable to load OG preview for ' . htmlspecialchars($url) . '</div>', 200);
}
}
}

103
src/Controller/MagazineSyncController.php

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

61
src/Repository/ArticleRepository.php

@ -69,67 +69,6 @@ class ArticleRepository extends ServiceEntityRepository @@ -69,67 +69,6 @@ class ArticleRepository extends ServiceEntityRepository
->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
*/

27
src/Service/ArticleCommentThreadLoader.php

@ -47,18 +47,27 @@ final readonly class ArticleCommentThreadLoader @@ -47,18 +47,27 @@ final readonly class ArticleCommentThreadLoader
'elapsed_since_load_start_ms' => (int) round((microtime(true) - $t0) * 1000),
]);
$tNostr = microtime(true);
// 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);
$this->logger->info('comments.loader.nostr_ok', [
'nostr_elapsed_ms' => (int) round((microtime(true) - $tNostr) * 1000),
'thread' => \count($out['thread'] ?? []),
'quotes' => \count($out['quotes'] ?? []),
]);
try {
$out = $this->nostrClient->getArticleDiscussion($coordinate, $articleEventHexId);
$this->logger->info('comments.loader.nostr_ok', [
'nostr_elapsed_ms' => (int) round((microtime(true) - $tNostr) * 1000),
'thread' => \count($out['thread'] ?? []),
'quotes' => \count($out['quotes'] ?? []),
]);
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 $out;
return ['thread' => [], 'quotes' => []];
}
});
} catch (\Throwable $e) {
$this->logger->error('comments.loader.cache_or_nostr_failed', [
$this->logger->error('comments.loader.cache_failed', [
'message' => $e->getMessage(),
'exception_class' => \get_class($e),
]);

78
src/Service/CacheService.php

@ -2,21 +2,20 @@ @@ -2,21 +2,20 @@
namespace App\Service;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use swentel\nostr\Key\Key;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
readonly class CacheService
{
public function __construct(
private NostrClient $nostrClient,
private CacheInterface $cache,
private LoggerInterface $logger,
private CacheItemPoolInterface $appCache,
) {
private NostrClient $nostrClient,
private CacheInterface $cache,
private LoggerInterface $logger
)
{
}
/**
@ -27,52 +26,25 @@ readonly class CacheService @@ -27,52 +26,25 @@ readonly class CacheService
{
$cacheKey = '0_' . $npub;
try {
return $this->cache->get($cacheKey, function (ItemInterface $item) use ($npub) {
return $this->cache->get($cacheKey, function (ItemInterface $item) use ($npub, $cacheKey) {
$item->expiresAfter(3600); // 1 hour, adjust as needed
try {
$meta = $this->nostrClient->getNpubMetadata($npub);
$this->logger->info('Metadata:', ['meta' => json_encode($meta)]);
return json_decode($meta->content);
} catch (\Exception $e) {
$this->logger->error('Error getting user data.', ['exception' => $e]);
throw new MetadataRetrievalException('Failed to retrieve metadata', 0, $e);
}
});
} catch (\Exception|InvalidArgumentException $e) {
$root = $e->getPrevious() ?? $e;
$this->logger->warning('Profile metadata fetch failed; using npub placeholder.', [
'npub' => $npub,
'exception' => $root,
]);
$this->logger->error('Error getting user data.', ['exception' => $e]);
$content = new \stdClass();
$content->name = substr($npub, 0, 8) . '…' . substr($npub, -4);
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)
{
$cacheKey = '3_' . $npub;
@ -91,34 +63,4 @@ readonly class CacheService @@ -91,34 +63,4 @@ readonly class CacheService
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

@ -1,177 +0,0 @@ @@ -1,177 +0,0 @@
<?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

@ -1,116 +0,0 @@ @@ -1,116 +0,0 @@
<?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

@ -1,177 +0,0 @@ @@ -1,177 +0,0 @@
<?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

@ -1,359 +0,0 @@ @@ -1,359 +0,0 @@
<?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;
}
}

705
src/Service/NostrClient.php

@ -24,15 +24,8 @@ use Symfony\Contracts\Cache\ItemInterface; @@ -24,15 +24,8 @@ use Symfony\Contracts\Cache\ItemInterface;
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;
/**
* @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(
private readonly EntityManagerInterface $entityManager,
private readonly ManagerRegistry $managerRegistry,
@ -40,109 +33,26 @@ class NostrClient @@ -40,109 +33,26 @@ class NostrClient
private readonly TokenStorageInterface $tokenStorage,
private readonly LoggerInterface $logger,
private readonly string $defaultRelayUrl,
private readonly array $articleRelayUrls,
private readonly array $profileRelayUrls,
private readonly CacheInterface $relayQueryCache,
) {
$this->defaultRelaySet = $this->buildArticleRelaySet();
}
/**
* default_relay + article_relays from config, in order, deduplicated. Used for the static
* default set and as the base when merging author/extra relay URLs in {@see createRelaySet()}.
*
* @return list<string>
*/
private function configuredArticleRelayUrlList(): array
{
$seen = [];
$out = [];
foreach (array_merge([$this->defaultRelayUrl], $this->articleRelayUrls) as $url) {
if (!\is_string($url) || $url === '' || isset($seen[$url])) {
continue;
}
$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;
$this->defaultRelaySet = new RelaySet();
$this->defaultRelaySet->addRelay(new Relay($this->defaultRelayUrl));
}
/**
* 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.
* Build a fresh relay set: default relay plus optional extras (deduped).
* Never reuse {@see $defaultRelaySet} as a mutable base — that used to append relays
* onto the singleton forever and multiplied every nostr request latency.
*/
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])) {
foreach (array_merge([$this->defaultRelayUrl], $relayUrls) as $relayUrl) {
if (!\is_string($relayUrl) || $relayUrl === '') {
continue;
}
if (isset($seen[$relayUrl])) {
continue;
}
$seen[$relayUrl] = true;
@ -187,224 +97,34 @@ class NostrClient @@ -187,224 +97,34 @@ 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
*/
public function getNpubMetadata($npub): \stdClass
{
$relaysTried = $this->profileMetadataQueryRelayUrlList();
$relaysTriedStr = implode(', ', array_map(self::relayLogLabel(...), $relaysTried));
$relaySet = $this->relaySetForProfileMetadataFetch();
$this->logger->info(sprintf('Getting metadata for npub (relays: %s)', $relaysTriedStr), ['npub' => $npub, 'relays' => $relaysTried]);
$relaySet = $this->defaultRelaySet;
$relaySet->addRelay(new Relay('wss://profiles.nostr1.com')); // profile aggregator
$this->logger->info('Getting metadata for npub', ['npub' => $npub]);
// Npubs are converted to hex for the request down the line
$request = $this->createNostrRequest(
kinds: [KindsEnum::METADATA],
filters: ['authors' => [$npub]],
relaySet: $relaySet
);
$events = $this->processResponse(
$request->send(),
function ($received) {
$this->logger->debug('nostr.metadata.relay_event', ['event' => $received]);
$events = $this->processResponse($request->send(), function($received) {
$this->logger->info('Getting metadata for npub', ['item' => $received]);
return $received;
});
return $received;
},
);
$this->logger->info('Getting metadata for npub', ['response' => $events]);
if (empty($events)) {
throw new \Exception('No metadata for npub '.$npub.' (relays: '.$relaysTriedStr.')');
throw new \Exception('No metadata found for npub: ' . $npub);
}
// Sort by date and return newest
usort($events, static fn ($a, $b) => (int) ($b->created_at ?? 0) <=> (int) ($a->created_at ?? 0));
// Sort by date and return newest
usort($events, fn($a, $b) => $b->created_at <=> $a->created_at);
return $events[0];
}
@ -431,16 +151,18 @@ class NostrClient @@ -431,16 +151,18 @@ class NostrClient
}
}
$request = $this->newTimedRequest($relays, $requestMessage);
$wrappers = $this->processResponse($request->send(), function (object $event) {
$w = new \stdClass();
$w->event = $event;
$request = new Request($relays, $requestMessage);
return $w;
});
if ($wrappers !== []) {
$this->saveLongFormContent($wrappers);
$response = $request->send();
// response is an n-dimensional array, where n is the number of relays in the set
// check that response has events in the results
foreach ($response as $relayRes) {
$filtered = array_filter($relayRes, function ($item) {
return $item->type === 'EVENT';
});
if (count($filtered) > 0) {
$this->saveLongFormContent($filtered);
}
}
// TODO handle relays that require auth
}
@ -454,57 +176,41 @@ class NostrClient @@ -454,57 +176,41 @@ class NostrClient
$relaySet->addRelay($relay);
}
$relaySet->setMessage($eventMessage);
$this->applyRelaySocketTimeoutToSet($relaySet);
// TODO handle responses appropriately
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
* NIP-23
*/
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();
$subscriptionId = $subscription->setId();
$filter = new Filter();
$filter->setKinds([KindsEnum::LONGFORM]);
$filter->setSince($since);
$filter->setUntil($until);
$filter->setSince(strtotime('-1 week')); // default
if ($from !== null) {
$filter->setSince($from);
}
if ($to !== null) {
$filter->setUntil($to);
}
$requestMessage = new RequestMessage($subscriptionId, [$filter]);
$request = $this->newTimedRequest($this->defaultRelaySet, $requestMessage);
$wrappers = $this->processResponse($request->send(), function (object $event) {
$w = new \stdClass();
$w->event = $event;
$request = new Request($this->defaultRelaySet, $requestMessage);
return $w;
});
if ($wrappers !== []) {
$this->saveLongFormContent($wrappers);
$response = $request->send();
// response is an n-dimensional array, where n is the number of relays in the set
// check that response has events in the results
foreach ($response as $relayRes) {
$filtered = array_filter($relayRes, function ($item) {
return $item->type === 'EVENT';
});
if (count($filtered) > 0) {
$this->saveLongFormContent($filtered);
}
}
}
@ -516,12 +222,9 @@ class NostrClient @@ -516,12 +222,9 @@ class NostrClient
if (empty($relayList)) {
$topAuthorRelays = $this->getTopReputableRelaysForAuthor($author);
$authorRelaySet = $this->createRelaySet($topAuthorRelays);
$relaysTried = $this->plannedRelayUrlsForSet($topAuthorRelays);
} else {
$authorRelaySet = $this->createRelaySet($relayList);
$relaysTried = $this->plannedRelayUrlsForSet($relayList);
}
$relaysTriedStr = implode(', ', array_map(self::relayLogLabel(...), $relaysTried));
try {
// Create request using the helper method for forest relay set
@ -548,9 +251,8 @@ class NostrClient @@ -548,9 +251,8 @@ class NostrClient
$this->saveLongFormContent([$wrapper]);
}
} catch (\Exception $e) {
$this->logger->error(sprintf('Error querying relays (%s): %s', $relaysTriedStr, $e->getMessage()), [
'error' => $e->getMessage(),
'relays' => $relaysTried,
$this->logger->error('Error querying relays', [
'error' => $e->getMessage()
]);
throw new \Exception('Error querying relays', 0, $e);
}
@ -740,7 +442,7 @@ class NostrClient @@ -740,7 +442,7 @@ class NostrClient
$subscription = new Subscription();
$subscriptionId = $subscription->setId();
$requestMessage = new RequestMessage($subscriptionId, $filters);
$request = $this->newTimedRequest($relaySet, $requestMessage);
$request = new Request($relaySet, $requestMessage);
$this->logger->info('nostr.article_discussion.req_sending', [
'subscription_id' => $subscriptionId,
@ -760,20 +462,13 @@ class NostrClient @@ -760,20 +462,13 @@ class NostrClient
]);
$this->logNostrWireResponseSummary('article_discussion', $response);
} catch (\Throwable $e) {
$this->logger->error(sprintf(
'nostr.article_discussion.req_send_failed (relays: %s): %s',
implode(', ', array_map(self::relayLogLabel(...), $plannedRelayUrls)),
$e->getMessage()
), [
$this->logger->error('nostr.article_discussion.req_send_failed', [
'coordinate' => $coordinate,
'error' => $e->getMessage(),
'exception_class' => \get_class($e),
'relays' => $plannedRelayUrls,
]);
// 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);
return ['thread' => [], 'quotes' => []];
}
$tParse = microtime(true);
@ -846,8 +541,11 @@ class NostrClient @@ -846,8 +541,11 @@ class NostrClient
{
$seen = [];
$out = [];
foreach (array_merge($this->configuredArticleRelayUrlList(), $relayUrls) as $relayUrl) {
if (!\is_string($relayUrl) || $relayUrl === '' || isset($seen[$relayUrl])) {
foreach (array_merge([$this->defaultRelayUrl], $relayUrls) as $relayUrl) {
if (!\is_string($relayUrl) || $relayUrl === '') {
continue;
}
if (isset($seen[$relayUrl])) {
continue;
}
$seen[$relayUrl] = true;
@ -866,11 +564,7 @@ class NostrClient @@ -866,11 +564,7 @@ class NostrClient
{
foreach ($response as $relayUrl => $relayRes) {
if ($relayRes instanceof \Throwable) {
$this->logger->warning(sprintf(
'nostr.wire.relay_throwable [%s]: %s',
self::relayLogLabel($relayUrl),
$relayRes->getMessage()
), [
$this->logger->warning('nostr.wire.relay_throwable', [
'context' => $context,
'relay' => $relayUrl,
'message' => $relayRes->getMessage(),
@ -880,11 +574,7 @@ class NostrClient @@ -880,11 +574,7 @@ class NostrClient
continue;
}
if (!\is_iterable($relayRes)) {
$this->logger->warning(sprintf(
'nostr.wire.relay_not_iterable [%s]: %s',
self::relayLogLabel($relayUrl),
\get_debug_type($relayRes)
), [
$this->logger->warning('nostr.wire.relay_not_iterable', [
'context' => $context,
'relay' => $relayUrl,
'php_type' => \get_debug_type($relayRes),
@ -914,7 +604,7 @@ class NostrClient @@ -914,7 +604,7 @@ class NostrClient
++$counts['other'];
}
}
$this->logger->info(sprintf('nostr.wire.relay_messages [%s]', self::relayLogLabel($relayUrl)), [
$this->logger->info('nostr.wire.relay_messages', [
'context' => $context,
'relay' => $relayUrl,
'counts' => $counts,
@ -1175,24 +865,12 @@ class NostrClient @@ -1175,24 +865,12 @@ class NostrClient
$requestMessage = new RequestMessage($subscriptionId, [$filter]);
try {
$request = $this->newTimedRequest($this->defaultRelaySet, $requestMessage);
$request = new Request($this->defaultRelaySet, $requestMessage);
$response = $request->send();
$hasEvents = false;
// Check if we got any events
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 ($response as $value) {
foreach ($value as $item) {
if ($item->type === 'EVENT') {
if (!isset($articles[$item->event->id])) {
@ -1207,58 +885,31 @@ class NostrClient @@ -1207,58 +885,31 @@ class NostrClient
if (!$hasEvents && !empty($slugs)) {
$this->logger->info('No results from theforest, trying default relays');
$request = $this->newTimedRequest($this->defaultRelaySet, $requestMessage);
$request = new Request($this->defaultRelaySet, $requestMessage);
$response = $request->send();
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 ($response as $value) {
foreach ($value as $item) {
if ($item->type === 'EVENT') {
if (!isset($articles[$item->event->id])) {
$articles[$item->event->id] = $item->event;
}
} elseif (in_array($item->type, ['AUTH', 'ERROR', 'NOTICE'], true)) {
$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]);
} elseif (in_array($item->type, ['AUTH', 'ERROR', 'NOTICE'])) {
$this->logger->error('An error while getting articles.', ['response' => $item]);
}
}
}
}
} catch (\Exception $e) {
$relaysTried = $this->configuredArticleRelayUrlList();
$relaysStr = implode(', ', array_map(self::relayLogLabel(...), $relaysTried));
$this->logger->error(sprintf('Error querying relays (%s): %s', $relaysStr, $e->getMessage()), [
'error' => $e->getMessage(),
'relays' => $relaysTried,
$this->logger->error('Error querying relays', [
'error' => $e->getMessage()
]);
// Fall back to default relay set
$request = $this->newTimedRequest($this->defaultRelaySet, $requestMessage);
$request = new Request($this->defaultRelaySet, $requestMessage);
$response = $request->send();
foreach ($response as $relayUrl => $value) {
if ($value instanceof \Throwable) {
continue;
}
if (!\is_iterable($value)) {
continue;
}
foreach ($response as $value) {
foreach ($value as $item) {
if ($item->type === 'EVENT') {
if (!isset($articles[$item->event->id])) {
@ -1312,8 +963,9 @@ class NostrClient @@ -1312,8 +963,9 @@ class NostrClient
// Continue with default relays
}
// If no author relays found, add default relay
if (empty($relayList)) {
$relayList = [];
$relayList = [$this->defaultRelayUrl];
}
// Ensure we use a RelaySet
@ -1327,28 +979,14 @@ class NostrClient @@ -1327,28 +979,14 @@ class NostrClient
$filter->setAuthors([$pubkey]);
$filter->setTag('#d', [$slug]);
$requestMessage = new RequestMessage($subscriptionId, [$filter]);
$relaysForLog = $this->plannedRelayUrlsForSet($relayList);
$relaysLogStr = implode(', ', array_map(self::relayLogLabel(...), $relaysForLog));
try {
$request = $this->newTimedRequest($relaySet, $requestMessage);
$request = new Request($relaySet, $requestMessage);
$response = $request->send();
$found = false;
// Check responses from each relay
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 ($response as $value) {
foreach ($value as $item) {
if ($item->type === 'EVENT') {
$articlesMap[$coordinate] = $item->event;
@ -1364,22 +1002,10 @@ class NostrClient @@ -1364,22 +1002,10 @@ class NostrClient
'coordinate' => $coordinate
]);
$request = $this->newTimedRequest($this->defaultRelaySet, $requestMessage);
$request = new Request($this->defaultRelaySet, $requestMessage);
$response = $request->send();
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 ($response as $value) {
foreach ($value as $item) {
if ($item->type === 'EVENT') {
$articlesMap[$coordinate] = $item->event;
@ -1389,14 +1015,9 @@ class NostrClient @@ -1389,14 +1015,9 @@ class NostrClient
}
}
} catch (\Exception $e) {
$this->logger->error(sprintf(
'Error fetching article (relays: %s): %s',
$relaysLogStr,
$e->getMessage()
), [
$this->logger->error('Error fetching article', [
'coordinate' => $coordinate,
'error' => $e->getMessage(),
'relays' => $relaysForLog,
'error' => $e->getMessage()
]);
}
}
@ -1426,27 +1047,24 @@ class NostrClient @@ -1426,27 +1047,24 @@ class NostrClient
$requestMessage = new RequestMessage($subscriptionId, [$filter]);
return $this->newTimedRequest($relaySet ?? $this->defaultRelaySet, $requestMessage);
return new Request($relaySet ?? $this->defaultRelaySet, $requestMessage);
}
private function processResponse(array $response, callable $eventHandler): array
{
$results = [];
foreach ($response as $relayUrl => $relayRes) {
if ($relayRes instanceof \Throwable) {
$this->logger->error(sprintf(
'Relay error at %s: %s',
self::relayLogLabel($relayUrl),
$relayRes->getMessage()
), [
// Skip if the relay response is an Exception
if ($relayRes instanceof \Exception) {
$this->logger->error('Relay error', [
'relay' => $relayUrl,
'error' => $relayRes->getMessage(),
'error' => $relayRes->getMessage()
]);
continue;
}
$itemEstimate = \is_countable($relayRes) ? \count($relayRes) : null;
$this->logger->debug(sprintf('Processing relay response from %s', self::relayLogLabel($relayUrl)), [
$this->logger->debug('Processing relay response', [
'relay' => $relayUrl,
'item_count' => $itemEstimate,
]);
@ -1454,21 +1072,18 @@ class NostrClient @@ -1454,21 +1072,18 @@ class NostrClient
foreach ($relayRes as $item) {
try {
if (!is_object($item)) {
$this->logger->warning(sprintf(
'Invalid response item from %s',
self::relayLogLabel($relayUrl)
), [
$this->logger->warning('Invalid response item', [
'relay' => $relayUrl,
'item' => $item,
'item' => $item
]);
continue;
}
switch ($item->type) {
case 'EVENT':
$this->logger->debug(sprintf('Processing event from %s', self::relayLogLabel($relayUrl)), [
$this->logger->debug('Processing event', [
'relay' => $relayUrl,
'event_id' => $item->event->id ?? 'unknown',
'event_id' => $item->event->id ?? 'unknown'
]);
$result = $eventHandler($item->event);
if ($result !== null) {
@ -1476,37 +1091,24 @@ class NostrClient @@ -1476,37 +1091,24 @@ class NostrClient
}
break;
case 'AUTH':
$this->logger->warning(sprintf(
'Relay %s requires authentication',
self::relayLogLabel($relayUrl)
), [
$this->logger->warning('Relay requires authentication', [
'relay' => $relayUrl,
'response' => $item,
'response' => $item
]);
break;
case 'ERROR':
case 'NOTICE':
$msg = (string) ($item->message ?? 'No message');
$this->logger->warning(sprintf(
'[%s] %s: %s',
self::relayLogLabel($relayUrl),
$item->type,
$msg
), [
$this->logger->warning('Relay error/notice', [
'relay' => $relayUrl,
'type' => $item->type,
'message' => $msg,
'message' => $item->message ?? 'No message'
]);
break;
}
} catch (\Exception $e) {
$this->logger->error(sprintf(
'Error processing event from relay %s: %s',
self::relayLogLabel($relayUrl),
$e->getMessage()
), [
$this->logger->error('Error processing event from relay', [
'relay' => $relayUrl,
'error' => $e->getMessage(),
'error' => $e->getMessage()
]);
continue; // Skip this item but continue processing others
}
@ -1635,55 +1237,21 @@ class NostrClient @@ -1635,55 +1237,21 @@ class NostrClient
/**
* 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).
*
* 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
{
$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(
[KindsEnum::PUBLICATION_INDEX],
['authors' => [(string) $npub], 'tag' => ['#d', [(string) $dTag]]],
$relaySet,
kinds: [KindsEnum::PUBLICATION_INDEX],
filters: ['authors' => [(string) $npub], 'tag' => ['#d', [(string) $dTag]]],
);
$this->logger->info(sprintf('Magazine index query (relays: %s)', $relaysForLog), [
'npub' => $npub,
'dTag' => $dTag,
'relays' => $relaysForLog,
]);
$response = $request->send();
$events = $this->processResponse($response, function ($received) {
$this->logger->info('Getting magazine index', ['npub' => $npub, 'dTag' => $dTag, 'response' => $response]);
$events = $this->processResponse($response, function($received) {
$this->logger->info('Received magazine index event', ['item' => $received]);
return $received;
});
if (empty($events)) {
$this->logger->warning('No magazine index found', ['npub' => $npub, 'dTag' => $dTag]);
return null;
}
usort($events, static function ($a, $b): int {
@ -1693,71 +1261,6 @@ class NostrClient @@ -1693,71 +1261,6 @@ class NostrClient
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
{
if ($event instanceof PublicationEventEntity) {

35
src/Twig/Components/Header.php

@ -4,7 +4,11 @@ declare(strict_types=1); @@ -4,7 +4,11 @@ declare(strict_types=1);
namespace App\Twig\Components;
use App\Service\MagazineContentService;
use App\Service\NostrClient;
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;
#[AsTwigComponent]
@ -12,9 +16,34 @@ class Header @@ -12,9 +16,34 @@ class Header
{
public array $cats;
/**
* @throws InvalidArgumentException
*/
public function __construct(
private readonly MagazineContentService $magazineContent,
private readonly CacheInterface $cache,
private readonly ParameterBagInterface $params,
private readonly NostrClient $nostrClient,
) {
$this->cats = $this->magazineContent->getHomeCategoryIndexTags();
$dTag = (string) $this->params->get('d_tag');
$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,7 +2,10 @@ @@ -2,7 +2,10 @@
namespace App\Twig\Components\Molecules;
use App\Service\MagazineIndexStore;
use App\Service\NostrClient;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
@ -13,7 +16,9 @@ final class CategoryLink @@ -13,7 +16,9 @@ final class CategoryLink
public string $slug = '';
public function __construct(
private readonly MagazineIndexStore $store,
private readonly CacheInterface $cache,
private readonly ParameterBagInterface $params,
private readonly NostrClient $nostrClient,
) {
}
@ -29,14 +34,31 @@ final class CategoryLink @@ -29,14 +34,31 @@ final class CategoryLink
}
$this->title = $this->slug;
$cat = $this->store->getCategory($this->slug);
$npub = (string) $this->params->get('npub');
// 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')) {
return;
}
$tags = $cat->getTags();
$titleTags = array_filter($tags, static function (mixed $tag): bool {
return \is_array($tag) && ($tag[0] ?? null) === 'title' && isset($tag[1]);
$titleTags = array_filter($tags, static function ($tag): bool {
return isset($tag[0]) && $tag[0] === 'title' && isset($tag[1]);
});
$first = array_key_first($titleTags);
if ($first !== null) {

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

@ -3,8 +3,11 @@ @@ -3,8 +3,11 @@
namespace App\Twig\Components\Organisms;
use App\Repository\ArticleRepository;
use App\Service\MagazineIndexStore;
use App\Service\NostrClient;
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;
#[AsTwigComponent]
@ -17,8 +20,10 @@ final class FeaturedList @@ -17,8 +20,10 @@ final class FeaturedList
public array $list = [];
public function __construct(
private readonly MagazineIndexStore $store,
private readonly CacheInterface $cache,
private readonly ArticleRepository $articleRepository,
private readonly NostrClient $nostrClient,
private readonly ParameterBagInterface $params,
) {
}
@ -38,8 +43,22 @@ final class FeaturedList @@ -38,8 +43,22 @@ final class FeaturedList
}
$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')) {
return;
}
@ -51,7 +70,7 @@ final class FeaturedList @@ -51,7 +70,7 @@ final class FeaturedList
}
if (($tag[0] ?? null) === 'a' && isset($tag[1])) {
$segs = explode(':', (string) $tag[1], 3);
$slugs[] = trim((string) end($segs));
$slugs[] = end($segs);
if (\count($slugs) >= 5) {
break;
}
@ -70,7 +89,7 @@ final class FeaturedList @@ -70,7 +89,7 @@ final class FeaturedList
$slugMap = [];
foreach ($articles as $article) {
$articleSlug = trim((string) $article->getSlug());
$articleSlug = $article->getSlug();
if ($articleSlug !== '') {
if (!isset($slugMap[$articleSlug])) {
$slugMap[$articleSlug] = $article;
@ -82,8 +101,7 @@ final class FeaturedList @@ -82,8 +101,7 @@ final class FeaturedList
$orderedList = [];
foreach ($slugs as $articleSlug) {
$articleSlug = trim((string) $articleSlug);
if ($articleSlug !== '' && isset($slugMap[$articleSlug])) {
if (isset($slugMap[$articleSlug])) {
$orderedList[] = $slugMap[$articleSlug];
}
}

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

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

17
templates/base.html.twig

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

4
templates/components/Header.html.twig

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

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

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

25
templates/home.html.twig

@ -1,30 +1,13 @@ @@ -1,30 +1,13 @@
{% 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 %}
{% endblock %}
{% block body %}
<div class="home-body" data-magazine-sync-target="pageBody">
{% for item in indices %}
<twig:Organisms:FeaturedList :category="item" class="featured-list"/>
{% endfor %}
</div>
{# content #}
{% for item in indices %}
<twig:Organisms:FeaturedList :category="item" class="featured-list"/>
{% endfor %}
{% endblock %}
{% block aside %}

61
templates/pages/article.html.twig

@ -1,61 +1,16 @@ @@ -1,61 +1,16 @@
{% 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 %}
{# Upstream main only outputs og:image when article.image is set — unfurlers often show a blank card. Always set an absolute image + JSON-LD. #}
{% set _img = article.image|default('')|trim %}
{% if _img == '' %}
{% set _og_image = absolute_url(asset('og-image.jpg')) %}
{% set _og_default_dims = true %}
{% elseif _img starts with '//' %}
{% set _og_image = 'https:' ~ _img %}
{% set _og_default_dims = false %}
{% else %}
{% 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:title" content="{{ article.title }}">
<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">
<meta property="og:url" content="{{ app.request.uri }}">
{% if article.image %}
<meta property="og:image" content="{{ article.image }}">
{% else %}
<meta property="og:image" content="{{ absolute_url(asset('og-image.jpg')) }}">
{% endif %}
<meta property="og:description" content="{{ _desc|e('html_attr') }}">
<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>
<meta property="og:description" content="{{ article.summary|striptags|u.truncate(159,'…')|e }}">
<meta property="og:site_name" content="{{ website_name }}">
{% endblock %}
{% block body %}

32
templates/pages/category.html.twig

@ -1,39 +1,19 @@ @@ -1,39 +1,19 @@
{% 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 %}
{% 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:title" content="{{ category.title|default('') }}">
<meta property="og:type" content="website">
<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="{{ (_summary != '' ? _summary : _title)|e('html_attr') }}">
<meta property="og:image" content="{{ _og_image|e('html_attr') }}">
<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') }}">
<meta property="og:url" content="{{ app.request.uri }}">
<meta property="og:description" content="{{ category.summary|default('') }}">
<meta property="og:image" content="{{ absolute_url(asset('og-image.jpg')) }}">
<meta property="og:site_name" content="{{ website_name }}">
{% endblock %}
{% block nav %}
{% endblock %}
{% block body %}
<div class="category-body" data-magazine-sync-target="pageBody">
<twig:Organisms:CardList :list="list" class="article-list" />
</div>
<twig:Organisms:CardList :list="list" class="article-list" />
{% endblock %}
{% block aside %}

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

@ -1,3 +0,0 @@ @@ -1,3 +0,0 @@
<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

@ -1,10 +0,0 @@ @@ -1,10 +0,0 @@
<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

@ -1,5 +0,0 @@ @@ -1,5 +0,0 @@
<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