Compare commits

..

27 Commits

Author SHA1 Message Date
Silberengel 74147931f0 fix backfill 2 weeks ago
Silberengel 46d0d803fb Merge imwald shared MySQL tenancy; gitcitadel uses imwald hub database 2 weeks ago
Silberengel 4cb4cc1b92 make the database sharable 2 weeks ago
Silberengel 1a35ca003f correct prewarm 2 weeks ago
Silberengel 6b2a39ce7a correct prewarm 2 weeks ago
Silberengel e24cc4b8b5 fix slug bug 2 weeks ago
Silberengel 5d276d1f98 fix slug bug 2 weeks ago
Silberengel 8cfe22205a fix footer address 2 weeks ago
Silberengel fa610dfebc update readme 2 weeks ago
Silberengel 093759151f change relay settings 2 weeks ago
Silberengel 4e3310bae4 limit to theforest 2 weeks ago
Silberengel 1afbfee540 compose.hub.yaml: default image tag → silberengel/unfold:gitcitadel 2 weeks ago
Silberengel d843269f8b compose.hub.yaml: add deploy note — copy this file from gitcitadel branch, not imwald 2 weeks ago
Silberengel 26eab2b3c3 gitcitadel branding: logos, favicons, og-image + CSS adjustments 2 weeks ago
Silberengel b501a13892 gitcitadel theme: purple/plum light + gitcitadel-online dark palette 2 weeks ago
Silberengel bd2f9db52e init gitcitadel branch: port 9085, GitCitadel identity 2 weeks ago
Silberengel c5e033bc4c bug-fix 2 weeks ago
Silberengel d81dbed032 bug-fixes 2 weeks ago
Silberengel cf8c3e8c2a bug-fixes 2 weeks ago
Silberengel 4767acd28d quiet logging and increase contrast 2 weeks ago
Silberengel e462f05d55 correct old bugs 2 weeks ago
Silberengel 171ef52acf bug-fixes 2 weeks ago
Silberengel 688c0909d9 drag-reorder articles on edit magazine page 2 weeks ago
Silberengel dd4c3ef9ed implement superchats kind 9741 2 weeks ago
Silberengel c60ba3ea61 Implement kind 30817 2 weeks ago
Silberengel e42aa8eb0b refactor 2 weeks ago
Silberengel c967cf1df5 bug-fixes 2 weeks ago
  1. 17
      .env.dist
  2. 9
      Dockerfile
  3. 45
      README.md
  4. 87
      assets/controllers/article_comments_controller.js
  5. 13
      assets/controllers/color_scheme_controller.js
  6. 1
      assets/controllers/comment_reply_controller.js
  7. 6
      assets/controllers/footer_magazine_edit_controller.js
  8. 16
      assets/controllers/hello_controller.js
  9. 209
      assets/controllers/magazine_hierarchy_editor_controller.js
  10. 27
      assets/controllers/progress_bar_controller.js
  11. 64
      assets/controllers/user_highlight_tooltip_controller.js
  12. BIN
      assets/laeserin_logo.png
  13. 168
      assets/styles/app.css
  14. 14
      assets/styles/article.css
  15. 2
      assets/styles/form.css
  16. 9
      assets/styles/layout.css
  17. 47
      assets/styles/magazine-editor.css
  18. 2
      assets/styles/nostr-previews.css
  19. 13
      assets/theme/default/theme-dark.css
  20. 34
      assets/theme/default/theme.css
  21. 3
      assets/theme/local/.gitignore
  22. BIN
      assets/theme/local/icons/apple-touch-icon.png
  23. BIN
      assets/theme/local/icons/favicon-96x96.png
  24. BIN
      assets/theme/local/icons/favicon.ico
  25. BIN
      assets/theme/local/icons/web-app-manifest-192x192.png
  26. BIN
      assets/theme/local/icons/web-app-manifest-512x512.png
  27. BIN
      assets/theme/local/og-image.jpg
  28. 75
      assets/theme/local/theme-dark.css
  29. 20
      assets/theme/local/theme.css
  30. 70
      compose.hub.yaml
  31. 4
      compose.yaml
  32. 2
      config/packages/cache.yaml
  33. 6
      config/packages/doctrine.yaml
  34. 3
      config/packages/monolog.yaml
  35. 7
      config/services.yaml
  36. 53
      config/unfold.yaml
  37. 26
      migrations/Version20260527110000.php
  38. 53
      migrations/Version20260528140000.php
  39. 612
      phpstan-baseline.neon
  40. BIN
      public/monero.png
  41. 5
      src/Command/ArticleHighlightsAuditCommand.php
  42. 9
      src/Command/ElevateUserCommand.php
  43. 104
      src/Command/PrewarmCommand.php
  44. 2
      src/Controller/Administration/RoleController.php
  45. 58
      src/Controller/ArticleController.php
  46. 2
      src/Controller/AuthorController.php
  47. 4
      src/Controller/CommentReplyController.php
  48. 3
      src/Controller/DefaultController.php
  49. 10
      src/Controller/SeoController.php
  50. 29
      src/Entity/Article.php
  51. 41
      src/Entity/ArticleMagazine.php
  52. 23
      src/Entity/FeaturedAuthor.php
  53. 18
      src/Entity/User.php
  54. 26
      src/Enum/KindsEnum.php
  55. 34
      src/Factory/ArticleFactory.php
  56. 9
      src/Form/DataTransformer/CommaSeparatedToArrayTransformer.php
  57. 31
      src/Nostr/MagazineEventKeys.php
  58. 10
      src/Nostr/Nip10Kind1ArticleReplyTags.php
  59. 8
      src/Nostr/Nip19Codec.php
  60. 4
      src/Nostr/Nip22CommentTags.php
  61. 46
      src/Repository/ArticleHighlightRepository.php
  62. 38
      src/Repository/ArticleMagazineRepository.php
  63. 95
      src/Repository/ArticleRepository.php
  64. 31
      src/Repository/EventRepository.php
  65. 39
      src/Repository/FeaturedAuthorRepository.php
  66. 24
      src/Repository/UserEntityRepository.php
  67. 28
      src/Security/UserDTOProvider.php
  68. 26
      src/Service/ArticleBodyHighlightInjector.php
  69. 29
      src/Service/ArticleCommentThreadLoader.php
  70. 25
      src/Service/ArticleMagazineRegistry.php
  71. 9
      src/Service/ArticleWorkflowService.php
  72. 219
      src/Service/CacheService.php
  73. 4
      src/Service/CommentReplyService.php
  74. 14
      src/Service/FeaturedAuthorListedRows.php
  75. 4
      src/Service/FeaturedAuthorSync.php
  76. 5
      src/Service/HighlightAuthorMetadataProvider.php
  77. 5
      src/Service/HighlightSyncService.php
  78. 203
      src/Service/MagazineContentService.php
  79. 13
      src/Service/MagazineHierarchyPublishService.php
  80. 13
      src/Service/MagazineIndexStore.php
  81. 4
      src/Service/MagazineRefresher.php
  82. 11
      src/Service/MetadataRetrievalException.php
  83. 2
      src/Service/Nip05VerificationService.php
  84. 22
      src/Service/Nip09DeletionApplier.php
  85. 207
      src/Service/NostrArticleDiscussionSupport.php
  86. 313
      src/Service/NostrClient.php
  87. 9
      src/Service/NostrKeyHelper.php
  88. 6
      src/Service/NostrKind5DeletionFilter.php
  89. 17
      src/Service/NostrLongformArticleStore.php
  90. 12
      src/Service/NostrRelayFanoutTransport.php
  91. 14
      src/Service/NostrRelayQuery.php
  92. 6
      src/Service/NostrRelayRequestFactory.php
  93. 8
      src/Service/NostrShareMenuBuilder.php
  94. 22
      src/Service/NostrWireEventMerge.php
  95. 10
      src/Service/ProfileIdentityLinksBuilder.php
  96. 26
      src/Service/ProfilePaymentLinksBuilder.php
  97. 25
      src/Service/TenantContext.php
  98. 5
      src/Service/TopicIndexService.php
  99. 46
      src/Twig/ArticleCardCoverExtension.php
  100. 3
      src/Twig/Components/IndexTabs.php
  101. Some files were not shown because too many files have changed in this diff Show More

17
.env.dist

@ -22,12 +22,12 @@ APP_SECRET=9e287f1ad737386dde46d51e80487236 @@ -22,12 +22,12 @@ APP_SECRET=9e287f1ad737386dde46d51e80487236
###< symfony/framework-bundle ###
###> docker ###
# Dev URL: http://127.0.0.1:${HTTP_PORT}/ (override HTTP_PORT/HTTPS_PORT if busy).
HTTP_PORT=9080
HTTP_PORT=9085
HTTPS_PORT=9443
# SERVER_NAME=:80
# If MYSQL_* changed after the DB volume exists: docker compose down -v (wipes data), then up.
MYSQL_DATABASE=unfold_db
MYSQL_VERSION=8.0
MYSQL_VERSION=8.0.36
MYSQL_CHARSET=utf8mb4
MYSQL_USER=unfold_user
MYSQL_PASSWORD=password
@ -46,9 +46,10 @@ MYSQL_ROOT_PASSWORD=root_password @@ -46,9 +46,10 @@ MYSQL_ROOT_PASSWORD=root_password
# PREWARM_FLAGS=
# Comma-separated magazine category #d slugs to refresh first when app:prewarm runs out of time before all categories (see MagazineRefresher).
# MAGAZINE_PREWARM_PREFER_SLUGS=
# 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
# compose.hub.yaml (gitcitadel): no bundled DB — uses imwald MySQL on Docker network unfold_default.
# DATABASE_HOST=unfold-mysql
# compose.hub.yaml: Apache reverse-proxies to 127.0.0.1:9085 (gitcitadel.imwald.eu vhost).
HTTP_PUBLISH=127.0.0.1:9085
# Optional: silence verbose Symfony deprecation output in the CLI. See Symfony docs for values (max[direct]=N, etc.).
# SYMFONY_DEPRECATIONS_HELPER=weak
# Optional: Nostr per-relay WebSocket timeout in seconds. Default: `nostr_relay_request_timeout_sec` in config/unfold.yaml
@ -58,6 +59,8 @@ MYSQL_ROOT_PASSWORD=root_password @@ -58,6 +59,8 @@ MYSQL_ROOT_PASSWORD=root_password
###> doctrine/doctrine-bundle ###
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
# `serverVersion` is also set in `config/packages/doctrine.yaml` to avoid DBAL 4 "MySQL earlier than 8" deprecation noise.
DATABASE_URL="mysql://${MYSQL_USER}:${MYSQL_PASSWORD}@database:3306/${MYSQL_DATABASE}?serverVersion=${MYSQL_VERSION}&charset=${MYSQL_CHARSET}"
# serverVersion is NOT in the URL; it is set in config/packages/doctrine.yaml (server_version: '8.0.36').
# Putting it in the URL too causes DBAL 4 to prefer the URL value and re-emit "MySQL < 8" deprecation
# warnings when a two-part string like "8.0" is present. The YAML value is the single source of truth.
DATABASE_URL="mysql://${MYSQL_USER}:${MYSQL_PASSWORD}@database:3306/${MYSQL_DATABASE}?charset=${MYSQL_CHARSET}"
###< doctrine/doctrine-bundle ###

9
Dockerfile

@ -109,4 +109,13 @@ RUN set -eux; \ @@ -109,4 +109,13 @@ RUN set -eux; \
rm -f .env; \
composer run-script --no-dev post-install-cmd; \
php bin/console asset-map:compile --no-debug; \
# Strip deployment secrets from the compiled .env.local.php so they cannot be read from the
# image layers. The listed keys must be injected as real environment variables at runtime;
# Symfony will raise a clear error rather than silently using the public .env.dist defaults.
# Done LAST: cache:clear and asset-map:compile both boot the Symfony kernel and need the env
# vars resolved; stripping before them causes "Environment variable not found" errors.
# MAINTENANCE: if a new secret is added to .env.dist, add it here too so it is not
# compiled into the image. Use array_diff_key so the strip is explicit and order-independent;
# missing keys are safely ignored (they were never compiled in and therefore never a risk).
php -r '$strip=array_flip(["APP_SECRET","DATABASE_URL","MYSQL_USER","MYSQL_PASSWORD","MYSQL_ROOT_PASSWORD"]); $e=array_diff_key(include(".env.local.php"),$strip); file_put_contents(".env.local.php","<?php return ".var_export($e,true).";".PHP_EOL);' ; \
chmod +x bin/console; sync;

45
README.md

@ -10,8 +10,8 @@ A Symfony + FrankenPHP site that **reads Nostr long-form articles (kind 30023)** @@ -10,8 +10,8 @@ A Symfony + FrankenPHP site that **reads Nostr long-form articles (kind 30023)**
| Data | Storage |
|------|---------|
| Published articles (30023/24) | **MySQL** `article` table (from `articles:get` / relay sync) |
| Magazine index (30040), kind-0 **profiles**, NIP-65 **relay lists** (10002) | **MySQL** `event` table with stable `core_row_key` (filled by `app:prewarm` and on-demand fetches) |
| Published articles (30023/24) | **MySQL** `article` table (global rows) + `article_magazine` (which magazine tenant ingested/references each row) |
| Magazine index (30040), kind-0 **profiles**, NIP-65 **relay lists** (10002) | **MySQL** `event` table with stable `core_row_key` — magazine indices are prefixed with `magazine_slug`; profiles/relay lists are shared |
| Comment / reply / thread **UI** (fetched thread HTML, etc.) | **Filesystem cache** pool `cache.replies` (not the DB) |
| Unpublished **editor preview** payloads | **Filesystem cache** pool `cache.drafts` |
| Generic Symfony `cache.app` | Other app caches; **not** used for long-term profile or magazine index storage |
@ -115,7 +115,7 @@ For a full **Nostr backfill** + one-shot prewarm, use **`make prewarm`** (or a h @@ -115,7 +115,7 @@ For a full **Nostr backfill** + one-shot prewarm, use **`make prewarm`** (or a h
| What | File |
|------|------|
| Site title, `npub`, `d_tag`, **relays** (`default_relay`, `article_relays`, `profile_relays`), theme | `config/unfold.yaml` (imported as Symfony parameters) |
| Site title, `npub`, `d_tag`, **`magazine_slug`** (tenant id for shared MySQL), **relays** (`default_relay`, `article_relays`, `profile_relays`), theme | `config/unfold.yaml` (imported as Symfony parameters) |
| `MAGAZINE_PREWARM_PREFER_SLUGS` | `.env` / `.env.local` — optional comma-separated category slugs to prioritize in `app:prewarm` magazine phase (after the root). Use when the relay time budget would otherwise skip your updated category. |
| `DATABASE_URL`, `APP_SECRET`, `HTTP_PORT`, `MYSQL_*`, optional **`PREWARM_FLAGS`** (for the Docker `cron` service) | `.env` / `.env.local` (see `.env.dist`) |
| Cache pool definitions (`cache.replies`, `cache.drafts`, `cache.app`) | `config/packages/cache.yaml` |
@ -123,37 +123,46 @@ For a full **Nostr backfill** + one-shot prewarm, use **`make prewarm`** (or a h @@ -123,37 +123,46 @@ For a full **Nostr backfill** + one-shot prewarm, use **`make prewarm`** (or a h
**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`).
### Shared MySQL (multiple magazines on one host)
Two deployments (e.g. Imwald + GitCitadel) can use **one MySQL** instead of separate `database_data` volumes:
1. Set a unique **`magazine_slug`** in each image’s `config/unfold.yaml` (e.g. `imwald`, `gitcitadel`).
2. Run **one** MySQL container (or external server). Point both stacks at the same **`DATABASE_URL`** (host port or Docker network alias).
3. **Nuke old volumes** and run migrations once: `docker compose exec php php bin/console doctrine:migrations:migrate --no-interaction`.
4. Backfill each site separately (`articles:get`, `app:prewarm`) — each container tags rows with its own `magazine_slug`.
Articles and kind-0 profiles are stored once and shared; magazine indices, featured authors, admin users, and list/search/sitemap views are scoped per `magazine_slug`.
---
## Production / Hub (remote server)
The app runs as a **pre-built** image (no app source on the server). The server only needs `compose.hub.yaml`, a `.env`, and Docker. Default image: `silberengel/unfold:latest`; override with **`UNFOLD_DOCKER_IMAGE`**.
The app runs as a **pre-built** image (no app source on the server). The server only needs `compose.hub.yaml`, a `.env`, and Docker. Default image: **`silberengel/unfold:gitcitadel`** (GitCitadel site on the **`gitcitadel`** branch); override with **`UNFOLD_DOCKER_IMAGE`**.
| Topic | Notes |
|-------|--------|
| `compose.hub.yaml` | Defines **`php`** (FrankenPHP) + **`database`** (MySQL) + **`prewarm`** (same app image: **`app:prewarm` every 10 minutes**, like dev’s `docker/cron`). Optional: disable `prewarm` in Compose if you prefer a host `cron` only. |
| HTTP | **`HTTP_PUBLISH`** in `.env` maps **host** port → container **80** (default **9080**). Put a reverse proxy (e.g. Apache) in front; set **`TRUSTED_PROXIES`** to match your proxy (often include `127.0.0.0/8` and the Docker bridge CIDR, e.g. `172.16.0.0/12`). |
| Secrets | Real **`APP_SECRET`** and **`MYSQL_*`** (or external DB via `DATABASE_URL` if you change the file). Do not commit production `.env`. |
| `compose.hub.yaml` | Compose project **`gitcitadel`** (containers e.g. `gitcitadel-php-1`). **`php`** + **`prewarm`** only — **no bundled MySQL**; connects to the imwald hub DB (`unfold-mysql` on network `unfold_default`). Start the **imwald** hub stack first. |
| HTTP | **`HTTP_PUBLISH`** in `.env` maps **host** port → container **80** (default **`127.0.0.1:9085`** for `gitcitadel.imwald.eu`). Put a reverse proxy (e.g. Apache) in front; set **`TRUSTED_PROXIES`** to match your proxy (often include `127.0.0.0/8` and the Docker bridge CIDR, e.g. `172.16.0.0/12`). |
| Secrets | Real **`APP_SECRET`** and **`MYSQL_*`** must match the **imwald** hub stack (same DB user/database). Optional **`DATABASE_HOST`** (default `unfold-mysql`). Do not commit production `.env`. |
| `PREWARM_FLAGS` | Optional extra CLI args for the hub **`prewarm`** service (and dev **`cron`**). After editing `.env`, run `docker compose -f compose.hub.yaml up -d --force-recreate prewarm`. |
### Build, tag, and push (on your machine or CI)
From the **repository root** (same `Dockerfile` as local prod):
From the **repository root** on the **`gitcitadel`** branch (same `Dockerfile` as local prod):
```bash
# Production image
docker build --platform linux/amd64 --target frankenphp_prod -t YOUR_REGISTRY/unfold:latest .
# Optional: immutable tag for rollbacks
docker build --platform linux/amd64 --target frankenphp_prod -t YOUR_REGISTRY/unfold:1.0.0 .
# GitCitadel hub image (default in compose.hub.yaml)
docker build --platform linux/amd64 --target frankenphp_prod -t silberengel/unfold:gitcitadel .
docker push silberengel/unfold:gitcitadel
# Push what you use on the server
docker push YOUR_REGISTRY/unfold:latest
docker push YOUR_REGISTRY/unfold:1.0.0
# Optional: immutable tag for rollbacks (set UNFOLD_DOCKER_IMAGE on the server to match)
docker build --platform linux/amd64 --target frankenphp_prod -t silberengel/unfold:1.0.0 .
docker push silberengel/unfold:1.0.0
```
- Use **`linux/amd64`** if the server is amd64; use **`arm64`** (or a matching `--platform`) for arm servers.
- The image name must match what the server will pull: either keep **`UNFOLD_DOCKER_IMAGE=YOUR_REGISTRY/unfold:TAG`** in server `.env`, or push to the default name **`silberengel/unfold:latest`**.
- The image tag must match what the server pulls: push to **`silberengel/unfold:gitcitadel`**, or set **`UNFOLD_DOCKER_IMAGE=YOUR_REGISTRY/unfold:TAG`** in server `.env`.
### Deploy on the server (pull, up, migrate)
@ -185,7 +194,7 @@ make -f Makefile.hub backfill # up + migrate + articles-get + prewarm-onc @@ -185,7 +194,7 @@ make -f Makefile.hub backfill # up + migrate + articles-get + prewarm-onc
**Optional image / tag** (in `.env` or one-shot):
```bash
export UNFOLD_DOCKER_IMAGE=YOUR_REGISTRY/unfold:1.0.0
export UNFOLD_DOCKER_IMAGE=silberengel/unfold:1.0.0
docker compose -f compose.hub.yaml up -d
```

87
assets/controllers/article_comments_controller.js

@ -1,7 +1,16 @@ @@ -1,7 +1,16 @@
import { Controller } from '@hotwired/stimulus';
/**
* Fetches the comment thread HTML after the article shell has rendered (no relay I/O on first paint).
* Two-phase comment loading:
*
* Phase 1 fires ?cached=1 immediately, shows whatever is in the server-side cache
* without touching any Nostr relays (< 100 ms on a warm cache).
*
* Phase 2 fires the full URL in parallel, which does relay I/O. When it resolves
* it replaces the Phase-1 content. If Phase 2 finishes first (e.g. the
* cached response was held up by DNS), Phase 1 is silently discarded.
*
* Result: readers always see something quickly; fresh relay data appears when ready.
*/
export default class extends Controller {
static values = {
@ -13,8 +22,7 @@ export default class extends Controller { @@ -13,8 +22,7 @@ export default class extends Controller {
connect() {
this.partialReloads = 0;
// Stable reference across reconnects: rebinding each connect() would strand old listeners
// because removeEventListener must use the same function reference that was passed to add.
// Stable reference across reconnects: rebinding each connect() would strand old listeners.
this.boundOnAuth ??= this.onAuthChanged.bind(this);
window.removeEventListener('unfold:auth-changed', this.boundOnAuth);
window.addEventListener('unfold:auth-changed', this.boundOnAuth);
@ -22,9 +30,8 @@ export default class extends Controller { @@ -22,9 +30,8 @@ export default class extends Controller {
return;
}
if (this.preloadedValue) {
// Article SSR already included comments. Do not re-fetch: a slow or dropped
// request would replace working HTML with a generic error. Re-fetch on auth
// only (reply UI may need fresh permission state).
// Article SSR already included comments (cache hit at render time). Do not re-fetch;
// a slow relay request would only replace working HTML. Auth changes may still reload.
return;
}
void this.load();
@ -43,13 +50,30 @@ export default class extends Controller { @@ -43,13 +50,30 @@ export default class extends Controller {
void this.load();
}
buildFetchUrl() {
/** Append ?cb=<timestamp> (and optional extras) to bust HTTP caches. */
buildFetchUrl(extra = '') {
const u = this.urlValue;
const bust = `cb=${Date.now()}`;
return u.includes('?') ? `${u}&${bust}` : `${u}?${bust}`;
const parts = [`cb=${Date.now()}`, extra].filter(Boolean);
const qs = parts.join('&');
return u.includes('?') ? `${u}&${qs}` : `${u}?${qs}`;
}
async load() {
async load(isPartialRetry = false) {
// Track whether Phase 2 has already written to the DOM so Phase 1 never clobbers it.
this._fullFetchDone = false;
// Only reset the partial-retry counter on a fresh top-level load, not on retries triggered
// by a partial result — otherwise the counter resets every call and the retry loop never stops.
if (!isPartialRetry) {
this.partialReloads = 0;
}
// Phase 1: fire a cache-only request in the background — completes in < 100 ms.
// Skip on partial retries: the container already has content; Phase 1 would overwrite it.
if (!isPartialRetry) {
void this._showCachedVersion();
}
// Phase 2: full relay fetch — replaces Phase 1 output when it resolves.
const t0 = performance.now();
const perAttemptMs = 45_000;
const maxAttempts = 3;
@ -57,7 +81,6 @@ export default class extends Controller { @@ -57,7 +81,6 @@ export default class extends Controller {
const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), perAttemptMs);
try {
// Avoid a stale 60s-cached "guest" fragment right after login (see comments fragment headers).
const res = await fetch(this.buildFetchUrl(), {
signal: controller.signal,
cache: 'no-store',
@ -68,27 +91,26 @@ export default class extends Controller { @@ -68,27 +91,26 @@ export default class extends Controller {
throw new Error(`HTTP ${res.status}`);
}
const html = await res.text();
window.clearTimeout(timer);
if (!this.hasContainerTarget) {
window.clearTimeout(timer);
return;
}
this._fullFetchDone = true;
this.containerTarget.innerHTML = html;
const isPartial = /data-comments-partial="1"/.test(html);
if (isPartial && this.partialReloads < 2) {
this.partialReloads += 1;
window.setTimeout(() => {
if (this.hasContainerTarget) {
void this.load();
void this.load(true);
}
}, 1200);
}
const ms = Math.round(performance.now() - t0);
if (attempt > 1) {
console.debug(`[article-comments] fragment OK in ${ms}ms (after ${attempt} attempts)`, this.urlValue);
} else {
console.debug(`[article-comments] fragment OK in ${ms}ms`, this.urlValue);
}
window.clearTimeout(timer);
console.debug(
`[article-comments] relay fetch OK in ${ms}ms${attempt > 1 ? ` (attempt ${attempt})` : ''}`,
this.urlValue,
);
return;
} catch (err) {
window.clearTimeout(timer);
@ -101,12 +123,35 @@ export default class extends Controller { @@ -101,12 +123,35 @@ export default class extends Controller {
continue;
}
const ms = Math.round(performance.now() - t0);
console.warn(`[article-comments] fragment failed after ${ms}ms`, this.urlValue, err);
if (this.hasContainerTarget) {
console.warn(`[article-comments] relay fetch failed after ${ms}ms`, this.urlValue, err);
// Only show the error if Phase 1 hasn't already displayed something useful.
if (this.hasContainerTarget && !this._fullFetchDone) {
this.containerTarget.innerHTML =
'<p class="text-subtle">Comments could not be loaded.</p>';
}
}
}
}
/** Phase 1: return the server's cached copy immediately, without doing any relay I/O. */
async _showCachedVersion() {
try {
const res = await fetch(this.buildFetchUrl('cached=1'), {
cache: 'no-store',
credentials: 'same-origin',
headers: { Accept: 'text/html', 'X-Requested-With': 'XMLHttpRequest' },
});
if (!res.ok || !this.hasContainerTarget || this._fullFetchDone) {
return;
}
const html = await res.text();
// Re-check: Phase 2 may have landed while we were awaiting the body.
if (!this.hasContainerTarget || this._fullFetchDone) {
return;
}
this.containerTarget.innerHTML = html;
} catch {
// Ignore; Phase 2 will fill the container regardless.
}
}
}

13
assets/controllers/color_scheme_controller.js

@ -7,7 +7,6 @@ export default class extends Controller { @@ -7,7 +7,6 @@ export default class extends Controller {
connect() {
this.link = document.getElementById('theme-magazine-stylesheet');
this._syncFromDom();
this._refreshIcons();
}
@ -40,16 +39,16 @@ export default class extends Controller { @@ -40,16 +39,16 @@ export default class extends Controller {
}
if (this.link) {
const light = this.link.getAttribute('data-href-light');
this.link.setAttribute('href', scheme === 'dark' && darkHref ? darkHref : light);
const href = scheme === 'dark' && darkHref ? darkHref : light;
// getAttribute returns null when the attribute is absent; passing null to setAttribute
// would coerce it to the string "null", producing a broken stylesheet URL.
if (href !== null) {
this.link.setAttribute('href', href);
}
}
this._refreshIcons();
}
_syncFromDom() {
/* Link href was set by inline script; icons follow current scheme. */
this._refreshIcons();
}
_refreshIcons() {
const dark = document.documentElement.getAttribute('data-color-scheme') === 'dark';
if (this.hasMoonTarget) {

1
assets/controllers/comment_reply_controller.js

@ -11,7 +11,6 @@ export default class extends Controller { @@ -11,7 +11,6 @@ export default class extends Controller {
csrf: String,
expectedCoordinate: String,
articleEventId: String,
fragmentUrl: String,
refreshAfter: { type: Boolean, default: true },
expectedTags: Array,
parentKind: Number,

6
assets/controllers/footer_magazine_edit_controller.js

@ -23,12 +23,10 @@ export default class extends Controller { @@ -23,12 +23,10 @@ export default class extends Controller {
if (!d) {
return;
}
if (d.loggedIn === false) {
this.element.hidden = true;
return;
}
if (d.loggedIn && d.npub === this.publisherNpubValue) {
this.element.hidden = false;
} else {
this.element.hidden = true;
}
}
}

16
assets/controllers/hello_controller.js

@ -1,16 +0,0 @@ @@ -1,16 +0,0 @@
import { Controller } from '@hotwired/stimulus';
/*
* This is an example Stimulus controller!
*
* Any element with a data-controller="hello" attribute will cause
* this controller to be executed. The name "hello" comes from the filename:
* hello_controller.js -> "hello"
*
* Delete this file or adapt it for your use!
*/
export default class extends Controller {
connect() {
this.element.textContent = 'Hello Stimulus! Edit me in assets/controllers/hello_controller.js';
}
}

209
assets/controllers/magazine_hierarchy_editor_controller.js

@ -3,6 +3,10 @@ import { Controller } from '@hotwired/stimulus'; @@ -3,6 +3,10 @@ import { Controller } from '@hotwired/stimulus';
const KIND_PUBLICATION_INDEX = 30040;
const KIND_LONGFORM = 30023;
const KIND_LONGFORM_DRAFT = 30024;
const KIND_WIKI = 30817;
/** All kinds allowed as article `a` tags inside a kind-30040 category index. */
const LONGFORM_KINDS = new Set([KIND_LONGFORM, KIND_LONGFORM_DRAFT, KIND_WIKI]);
/**
* Owner-only magazine hierarchy: build kind-30040 tags from fieldsets, NIP-07 sign each, POST batch.
@ -11,7 +15,7 @@ const KIND_LONGFORM_DRAFT = 30024; @@ -11,7 +15,7 @@ const KIND_LONGFORM_DRAFT = 30024;
const DTAG_PATTERN = /^[a-zA-Z0-9_-]+$/;
export default class MagazineHierarchyEditorController extends Controller {
static targets = ['status', 'publishBtn', 'node', 'nodes', 'newNodeTemplate', 'aRowTemplate'];
static targets = ['status', 'publishBtn', 'nodes', 'newNodeTemplate', 'aRowTemplate'];
static values = {
publishUrl: String,
@ -24,22 +28,41 @@ export default class MagazineHierarchyEditorController extends Controller { @@ -24,22 +28,41 @@ export default class MagazineHierarchyEditorController extends Controller {
};
connect() {
this.nodeBaseline = new WeakMap();
// Preserve the WeakMap across Stimulus reconnects so that baselines captured at page-load
// (or after a successful publish) are not lost. A fresh WeakMap would make captureBaselines()
// re-snapshot the *current* (potentially edited) DOM state, causing isNodeDirty() to return
// false for every node and publish to silently do nothing.
this.nodeBaseline ??= new WeakMap();
this.captureBaselines();
/**
* Clicks: `document` capture + `this.element.contains(target)` so we still run when bubble
* never reaches the panel (e.g. `stopPropagation` from another listener) or fieldset/legend
* hit-testing is odd.
*/
this._onDocClickCapture = this._onDocClickCapture.bind(this);
this._onPanelFocusOut = this._onPanelFocusOut.bind(this);
document.addEventListener('click', this._onDocClickCapture, true);
this.element.addEventListener('focusout', this._onPanelFocusOut);
// Bind once using *different* property names than the method names.
// ??= would short-circuit for same-name properties because the prototype method is
// already non-null, leaving the handler unbound (this === DOM element at call time).
this._boundDocClickCapture ??= this._onDocClickCapture.bind(this);
this._boundPanelFocusOut ??= this._onPanelFocusOut.bind(this);
this._boundDragStart ??= this._onDragStart.bind(this);
this._boundDragOver ??= this._onDragOver.bind(this);
this._boundDrop ??= this._onDrop.bind(this);
this._boundDragEnd ??= this._onDragEnd.bind(this);
document.addEventListener('click', this._boundDocClickCapture, true);
this.element.addEventListener('focusout', this._boundPanelFocusOut);
this.element.addEventListener('dragstart', this._boundDragStart);
this.element.addEventListener('dragover', this._boundDragOver);
this.element.addEventListener('drop', this._boundDrop);
this.element.addEventListener('dragend', this._boundDragEnd);
}
disconnect() {
document.removeEventListener('click', this._onDocClickCapture, true);
this.element.removeEventListener('focusout', this._onPanelFocusOut);
document.removeEventListener('click', this._boundDocClickCapture, true);
this.element.removeEventListener('focusout', this._boundPanelFocusOut);
this.element.removeEventListener('dragstart', this._boundDragStart);
this.element.removeEventListener('dragover', this._boundDragOver);
this.element.removeEventListener('drop', this._boundDrop);
this.element.removeEventListener('dragend', this._boundDragEnd);
}
/**
@ -63,6 +86,9 @@ export default class MagazineHierarchyEditorController extends Controller { @@ -63,6 +86,9 @@ export default class MagazineHierarchyEditorController extends Controller {
return;
}
const t = ev.target;
// Publish: handled here via document-capture; the button must NOT also carry a
// data-action binding or _publish() would fire twice per click.
if (t.closest('[data-mag-editor-cmd="publish"]')) {
void this._publish();
return;
@ -71,34 +97,24 @@ export default class MagazineHierarchyEditorController extends Controller { @@ -71,34 +97,24 @@ export default class MagazineHierarchyEditorController extends Controller {
this.addTopLevelCategory();
return;
}
const addSubCmd = t.closest('[data-mag-editor-cmd="add-subcategory"]');
if (addSubCmd instanceof HTMLElement) {
this.addSubcategory(ev, addSubCmd);
return;
}
const rmCatCmd = t.closest('[data-mag-editor-cmd="remove-category"]');
if (rmCatCmd instanceof HTMLElement) {
this.removeCategory(ev, rmCatCmd);
const addSubBtn = t.closest('[data-mag-editor-cmd="add-subcategory"]');
if (addSubBtn instanceof HTMLElement) {
this.addSubcategory(ev, addSubBtn);
return;
}
const addArticle = t.closest('[data-mag-a-add]');
if (addArticle instanceof HTMLElement) {
this.addALine(ev, addArticle);
const rmCatBtn = t.closest('[data-mag-editor-cmd="remove-category"]');
if (rmCatBtn instanceof HTMLElement) {
this.removeCategory(ev, rmCatBtn);
return;
}
const rmArticle = t.closest('.magazine-editor__a-remove-icon');
if (rmArticle instanceof HTMLElement) {
this.removeALine(ev, rmArticle);
const addArticleBtn = t.closest('[data-mag-a-add]');
if (addArticleBtn instanceof HTMLElement) {
this.addALine(ev, addArticleBtn);
return;
}
const sub = t.closest('.magazine-editor__add-sub');
if (sub instanceof HTMLElement) {
this.addSubcategory(ev, sub);
return;
}
const rmCat = t.closest('.magazine-editor__remove-node');
if (rmCat instanceof HTMLElement) {
this.removeCategory(ev, rmCat);
const rmArticleBtn = t.closest('.magazine-editor__a-remove-icon');
if (rmArticleBtn instanceof HTMLElement) {
this.removeALine(ev, rmArticleBtn);
return;
}
}
@ -117,12 +133,114 @@ export default class MagazineHierarchyEditorController extends Controller { @@ -117,12 +133,114 @@ export default class MagazineHierarchyEditorController extends Controller {
this.commitDTag(ev);
}
// -------------------------------------------------------------------------
// Drag-and-drop reordering for article (`a`-tag) rows within a list
// -------------------------------------------------------------------------
/**
* @param {DragEvent} ev
*/
_onDragStart(ev) {
const row = ev.target instanceof Element ? ev.target.closest('[data-mag-a-row]') : null;
if (!row) {
return;
}
this._dragRow = row;
ev.dataTransfer.effectAllowed = 'move';
// Firefox requires at least one dataTransfer item for drag to start.
ev.dataTransfer.setData('text/plain', '');
// Defer the opacity change so the browser captures the ghost image at full opacity first.
requestAnimationFrame(() => {
if (this._dragRow === row) {
row.dataset.dragging = '1';
}
});
}
/**
* @param {DragEvent} ev
*/
_onDragOver(ev) {
if (!this._dragRow) {
return;
}
const row = ev.target instanceof Element ? ev.target.closest('[data-mag-a-row]') : null;
if (!row || row === this._dragRow) {
this._clearDragOver();
return;
}
// Only allow reordering within the same `[data-mag-a-list]` container.
const dragList = this._dragRow.closest('[data-mag-a-list]');
if (!dragList || dragList !== row.closest('[data-mag-a-list]')) {
this._clearDragOver();
return;
}
ev.preventDefault();
ev.dataTransfer.dropEffect = 'move';
// Re-evaluate insert position as the cursor moves within the same row.
this._clearDragOver();
this._dragOverRow = row;
const rect = row.getBoundingClientRect();
row.dataset[ev.clientY < rect.top + rect.height / 2 ? 'dragBefore' : 'dragAfter'] = '1';
}
/**
* @param {DragEvent} ev
*/
_onDrop(ev) {
ev.preventDefault();
const overRow = this._dragOverRow;
const dragRow = this._dragRow;
if (!overRow || !dragRow || overRow === dragRow) {
this._clearDrag();
return;
}
const dragList = dragRow.closest('[data-mag-a-list]');
if (!dragList || dragList !== overRow.closest('[data-mag-a-list]')) {
this._clearDrag();
return;
}
const insertBefore = 'dragBefore' in overRow.dataset;
this._clearDrag();
if (insertBefore) {
dragList.insertBefore(dragRow, overRow);
} else {
overRow.insertAdjacentElement('afterend', dragRow);
}
}
_onDragEnd() {
this._clearDrag();
}
_clearDragOver() {
if (this._dragOverRow) {
delete this._dragOverRow.dataset.dragBefore;
delete this._dragOverRow.dataset.dragAfter;
this._dragOverRow = null;
}
}
_clearDrag() {
if (this._dragRow) {
delete this._dragRow.dataset.dragging;
this._dragRow = null;
}
this._clearDragOver();
}
captureBaselines() {
if (!this.hasNodesTarget) {
return;
}
for (const el of queryEditorNodeFieldsets(this.nodesTarget)) {
this.nodeBaseline.set(el, snapshotFromElement(el));
// Only set a baseline for nodes that don't already have one. On reconnect the WeakMap
// is preserved (see connect()), so existing entries reflect the true pre-edit state.
// Overwriting them here would reset "original = current dirty state", making every node
// appear clean and causing publish to silently skip it.
if (!this.nodeBaseline.has(el)) {
this.nodeBaseline.set(el, snapshotFromElement(el));
}
}
}
@ -795,7 +913,7 @@ export default class MagazineHierarchyEditorController extends Controller { @@ -795,7 +913,7 @@ export default class MagazineHierarchyEditorController extends Controller {
}
if (Number.isFinite(n) && Number.isFinite(ingested) && ingested > 0) {
this.setStatus(
`Published and stored ${n} index event(s); synced ${ingested} long-form address(es) from relays.`,
`Published and stored ${n} index event(s); synced ${ingested} article/wiki address(es) from relays.`,
{ tone: 'success', scroll: true },
);
} else {
@ -894,8 +1012,8 @@ export default class MagazineHierarchyEditorController extends Controller { @@ -894,8 +1012,8 @@ export default class MagazineHierarchyEditorController extends Controller {
if (kind === KIND_PUBLICATION_INDEX && pk !== ownerHex) {
throw new Error(`Nested 30040 address must use magazine owner pubkey: ${coord}`);
}
if (kind !== KIND_PUBLICATION_INDEX && kind !== KIND_LONGFORM && kind !== KIND_LONGFORM_DRAFT) {
throw new Error(`Only kinds 30040, 30023, 30024 allowed in a tag: ${coord}`);
if (kind !== KIND_PUBLICATION_INDEX && !LONGFORM_KINDS.has(kind)) {
throw new Error(`Only kinds 30040, 30023, 30024, 30817 allowed in a tag: ${coord}`);
}
}
@ -1117,29 +1235,6 @@ function lineMatches30040Child(line, ownerHexLower, childD) { @@ -1117,29 +1235,6 @@ function lineMatches30040Child(line, ownerHexLower, childD) {
return parts.identifier.trim() === childD;
}
/**
* @param {HTMLElement} el
* @returns {{ dTag: string, title: string, summary: string, content: string, aText: string, preserved: unknown[] }}
*/
function readFieldsForBuild(el) {
const dTag = readDTag(el);
const title = el.querySelector('[data-magazine-hierarchy-editor-target="title"]')?.value ?? '';
const summary = el.querySelector('[data-magazine-hierarchy-editor-target="summary"]')?.value ?? '';
const content = el.querySelector('[data-magazine-hierarchy-editor-target="content"]')?.value ?? '';
const aText = readJoinedALinesFromNode(el);
const preservedRaw = el.querySelector('[data-magazine-hierarchy-editor-target="preservedJson"]')?.value ?? '[]';
let preserved;
try {
preserved = JSON.parse(preservedRaw);
} catch {
throw new Error(`Invalid preserved-tags JSON for #d ${dTag}.`);
}
if (!Array.isArray(preserved)) {
throw new Error(`Preserved tags must be a JSON array for #d ${dTag}.`);
}
return { dTag, title, summary, content, aText, preserved };
}
/**
* @param {string} coord
* @returns {{ kind: string, pubkey: string, identifier: string } | null}

27
assets/controllers/progress_bar_controller.js

@ -25,14 +25,18 @@ export default class extends Controller { @@ -25,14 +25,18 @@ export default class extends Controller {
}
disconnect() {
document.removeEventListener('click', this.boundHandleInteraction);
if (this.boundHandleInteraction) {
document.removeEventListener('click', this.boundHandleInteraction);
}
if (this.boundTouchStart) {
document.removeEventListener('touchstart', this.boundTouchStart);
}
if (this.boundTouchEnd) {
document.removeEventListener('touchend', this.boundTouchEnd);
}
window.removeEventListener('pageshow', this.boundPageShow);
if (this.boundPageShow) {
window.removeEventListener('pageshow', this.boundPageShow);
}
if (this.loadListener) {
window.removeEventListener('load', this.loadListener);
this.loadListener = null;
@ -80,6 +84,9 @@ export default class extends Controller { @@ -80,6 +84,9 @@ export default class extends Controller {
this.barTarget.style.transition = 'width 0.18s ease-out';
this.barTarget.style.width = '100%';
window.setTimeout(() => {
if (!this.hasBarTarget) {
return;
}
this.barTarget.style.transition = 'none';
this.barTarget.style.width = '0';
this.barTarget.style.removeProperty('transition');
@ -96,15 +103,27 @@ export default class extends Controller { @@ -96,15 +103,27 @@ export default class extends Controller {
}
handleTouchStart(event) {
const touch = event.changedTouches[0];
const touch = event.changedTouches?.[0];
if (!touch) {
return;
}
this.touchStartX = touch.screenX;
this.touchStartY = touch.screenY;
}
handleTouchEnd(event) {
const touch = event.changedTouches[0];
const touch = event.changedTouches?.[0];
if (
!touch
|| typeof this.touchStartX !== 'number'
|| typeof this.touchStartY !== 'number'
) {
return;
}
const dx = Math.abs(touch.screenX - this.touchStartX);
const dy = Math.abs(touch.screenY - this.touchStartY);
this.touchStartX = undefined;
this.touchStartY = undefined;
if (dx < 10 && dy < 10) {
this.handleInteraction(event);
}

64
assets/controllers/user_highlight_tooltip_controller.js

@ -38,15 +38,28 @@ function formatHighlightDateUtc(d) { @@ -38,15 +38,28 @@ function formatHighlightDateUtc(d) {
*/
export default class extends Controller {
connect() {
this.tip = el('div', 'user-highlight__tip-popover', document.body);
this.tip.setAttribute('role', 'tooltip');
this.tip.setAttribute('hidden', '');
// Guard against duplicate elements if connect() fires more than once without an
// intervening disconnect() (e.g. direct invocation in tests or unusual Stimulus edge
// cases). Nulled out in disconnect() so the next normal connect() creates a fresh node.
if (!this.tip) {
this.tip = el('div', 'user-highlight__tip-popover', document.body);
this.tip.setAttribute('role', 'tooltip');
this.tip.setAttribute('hidden', '');
// Store named references so disconnect() can remove them explicitly and the
// anonymous-function-on-element pattern does not make cleanup impossible.
this._onTipEnter ??= () => { this._inTip = true; this._cancelHide(); };
this._onTipLeave ??= () => { this._inTip = false; this._scheduleHide(); };
this.tip.addEventListener('mouseenter', this._onTipEnter);
this.tip.addEventListener('mouseleave', this._onTipLeave);
}
this.activeMark = null;
this._hideT = 0;
this._inTip = false;
this._onOver = (e) => {
// Stable handler refs (??=) so reconnect without disconnect does not stack listeners.
this._onOver ??= (e) => {
if (!(e instanceof MouseEvent)) {
return;
}
@ -63,7 +76,7 @@ export default class extends Controller { @@ -63,7 +76,7 @@ export default class extends Controller {
this._show(/** @type {HTMLElement} */ (m), e);
}
};
this._onOut = (e) => {
this._onOut ??= (e) => {
if (!(e instanceof MouseEvent)) {
return;
}
@ -74,7 +87,7 @@ export default class extends Controller { @@ -74,7 +87,7 @@ export default class extends Controller {
const m =
t.nodeType === 1
? /** @type {Element} */ (t).closest('mark.user-highlight__marker[data-hl]')
: null;
: t.parentElement?.closest('mark.user-highlight__marker[data-hl]') ?? null;
if (m) {
const to = e.relatedTarget;
if (to && (m.contains(to) || (to instanceof Node && this.tip.contains(/** @type {Node} */(to))))) {
@ -84,16 +97,7 @@ export default class extends Controller { @@ -84,16 +97,7 @@ export default class extends Controller {
this._scheduleHide();
};
this.tip.addEventListener('mouseenter', () => {
this._inTip = true;
this._cancelHide();
});
this.tip.addEventListener('mouseleave', () => {
this._inTip = false;
this._scheduleHide();
});
this._onFocus = (e) => {
this._onFocus ??= (e) => {
const t = e.target;
if (!(t instanceof Element)) {
return;
@ -104,12 +108,15 @@ export default class extends Controller { @@ -104,12 +108,15 @@ export default class extends Controller {
this._show(/** @type {HTMLElement} */ (m), e);
}
};
this._onBlur = (e) => {
this._onBlur ??= (e) => {
const t = e.target;
if (!(t instanceof Node)) {
return;
}
const m = t.nodeType === 1 ? t.closest('mark.user-highlight__marker[data-hl]') : null;
const m =
t.nodeType === 1
? /** @type {Element} */ (t).closest('mark.user-highlight__marker[data-hl]')
: t.parentElement?.closest('mark.user-highlight__marker[data-hl]') ?? null;
if (m) {
const to = e.relatedTarget;
if (to && (m.contains(to) || (to instanceof Node && this.tip.contains(/** @type {Node} */ (to))))) {
@ -119,21 +126,27 @@ export default class extends Controller { @@ -119,21 +126,27 @@ export default class extends Controller {
this._scheduleHide();
};
this.element.removeEventListener('mouseover', this._onOver);
this.element.removeEventListener('mouseout', this._onOut);
this.element.removeEventListener('focusin', this._onFocus);
this.element.removeEventListener('focusout', this._onBlur);
this.element.addEventListener('mouseover', this._onOver);
this.element.addEventListener('mouseout', this._onOut);
this.element.addEventListener('focusin', this._onFocus);
this.element.addEventListener('focusout', this._onBlur);
this._onResize = () => {
this._onResize ??= () => {
if (this.activeMark) {
this._place(this.activeMark);
}
};
window.removeEventListener('resize', this._onResize);
window.addEventListener('resize', this._onResize);
this._onHashChange = () => {
this._onHashChange ??= () => {
this._scrollToHashHighlight();
};
window.removeEventListener('hashchange', this._onHashChange);
window.addEventListener('hashchange', this._onHashChange);
this._scrollToHashHighlight();
}
@ -175,11 +188,14 @@ export default class extends Controller { @@ -175,11 +188,14 @@ export default class extends Controller {
this.element.removeEventListener('focusin', this._onFocus);
this.element.removeEventListener('focusout', this._onBlur);
window.removeEventListener('resize', this._onResize);
if (this._onHashChange) {
window.removeEventListener('hashchange', this._onHashChange);
}
window.removeEventListener('hashchange', this._onHashChange);
this._cancelHide();
this.tip.remove();
if (this.tip) {
this.tip.removeEventListener('mouseenter', this._onTipEnter);
this.tip.removeEventListener('mouseleave', this._onTipLeave);
this.tip.remove();
this.tip = null;
}
}
_cancelHide() {

BIN
assets/laeserin_logo.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 166 KiB

168
assets/styles/app.css

@ -102,17 +102,37 @@ strong:not(>h2), .strong { @@ -102,17 +102,37 @@ strong:not(>h2), .strong {
a {
color: var(--color-secondary);
text-decoration: none;
/* WCAG 1.4.1: links must have a non-colour cue (underline) so they are identifiable
without relying on colour alone. UI-chrome zones (nav, buttons, cards, badges)
suppress it below. */
text-decoration: underline;
text-underline-offset: 0.18em;
}
a:hover {
color: var(--color-link-hover);
text-decoration: underline;
}
/* Cards: the whole card is the interactive element; suppress underline on heading/image links. */
.card a,
.card a:hover {
text-decoration: none;
color: var(--color-text);
cursor: pointer;
}
/* Buttons used as links. */
.btn,
a.btn,
button a {
text-decoration: none;
}
/* Tags, badges — clearly interactive chips. */
a.tag,
.tags a,
.user-badge,
.user-badge:hover {
text-decoration: none;
}
.card a:hover h2 {
@ -431,17 +451,17 @@ svg.icon { @@ -431,17 +451,17 @@ svg.icon {
transform: scale(1.06);
}
.featured-list--picture-grid .featured-tile__picture-img[src*="favicon-96x96"] {
object-fit: contain;
padding: 2rem;
box-sizing: border-box;
/* Suppress hover zoom when using the branded fallback — the faded portrait shouldn't animate */
.featured-list--picture-grid .featured-tile--picture-block:has(.card-header--no-cover) .featured-tile__picture-img {
transform: none;
transition: none;
}
.featured-list--picture-grid .featured-tile__picture-scrim {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 2;
background: linear-gradient(
to top,
color-mix(in srgb, #0a0a0a 88%, transparent) 0%,
@ -456,7 +476,7 @@ svg.icon { @@ -456,7 +476,7 @@ svg.icon {
right: 0;
bottom: 0;
padding: 0.65rem 0.85rem 0.75rem;
z-index: 1;
z-index: 3;
display: flex;
flex-direction: column;
gap: 0.28rem;
@ -716,12 +736,35 @@ svg.icon { @@ -716,12 +736,35 @@ svg.icon {
text-underline-offset: 2px;
}
/* List cards: same site-logo treatment when the hero is the default mark */
.article-list .card-header img[src*="favicon-96x96"] {
/* Fallback hero: landscape logo at low opacity with a diagonal stripe overlay.
Applied to any card container (.card-header, .featured-tile__media, etc.) when
the article has no cover image. Logo is shown in full (contain + center). */
.card-header--no-cover {
position: relative;
overflow: hidden;
background-color: var(--color-bg-light);
}
.card-header--no-cover img {
opacity: 0.18;
object-fit: contain;
padding: 1.25rem;
box-sizing: border-box;
background: var(--color-bg-light);
object-position: center;
}
.card-header--no-cover::after {
content: '';
position: absolute;
inset: 0;
background:
repeating-linear-gradient(
-45deg,
transparent 0,
transparent 5px,
rgba(0, 0, 0, 0.028) 5px,
rgba(0, 0, 0, 0.028) 6px
);
pointer-events: none;
z-index: 1;
}
/* Optional category label above cover (see Molecules/Card) */
@ -1089,9 +1132,9 @@ svg.icon { @@ -1089,9 +1132,9 @@ svg.icon {
padding: 0.12em 0 0.08em;
}
#site-header .header__logo-circle {
width: 40px;
height: 40px;
#site-header .header__logo-banner {
width: 100px;
height: 38px;
}
.hamburger {
@ -1104,37 +1147,22 @@ svg.icon { @@ -1104,37 +1147,22 @@ svg.icon {
}
}
/* Fixed square + overflow clips to a true circle. Logo img is out-of-flow so
global img { height: auto } cannot shrink the bitmap; object-fit fills the disc.
Slight scale crops typical padding baked into square marketing PNGs. */
.header__logo-circle {
display: inline-block;
position: relative;
width: 60px;
height: 60px;
/* Horizontal banner logo: landscape logo image shown in full (contain + center). */
.header__logo-banner {
display: block;
flex-shrink: 0;
border-radius: 50%;
overflow: hidden;
box-shadow: 0 0 0 1px var(--color-border);
vertical-align: middle;
}
#site-header .header__logo-circle {
width: 48px;
width: 140px;
height: 48px;
object-fit: contain;
object-position: center;
border-radius: 5px;
/* Prevent global img { height: auto } from overriding the fixed height. */
max-width: none;
}
.header__logo-circle > img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
max-width: none;
object-fit: cover;
object-position: center;
display: block;
transform: scale(1.18);
transform-origin: center center;
#site-header .header__logo-banner {
width: 120px;
height: 44px;
}
.header__logo a:hover {
@ -1192,7 +1220,9 @@ footer p { @@ -1192,7 +1220,9 @@ footer p {
}
footer a {
color: var(--color-text-contrast);
/* --color-text-contrast is nearly invisible on the footer background; use the
dedicated footer-link token which has 4.5:1 contrast in both schemes. */
color: var(--color-footer-link);
}
/* Tags container */
@ -1335,12 +1365,13 @@ a.tag:focus-visible { @@ -1335,12 +1365,13 @@ a.tag:focus-visible {
}
.author-profile__section-type {
font-size: 0.72rem;
font-size: 0.78rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text);
opacity: 0.75;
/* Use --color-text-mid directly rather than --color-text at opacity: 0.75, which
falls below 3:1 on dark backgrounds; text-mid has 7:1 in both schemes. */
color: var(--color-text-mid);
line-height: 1.35;
padding-top: 0.15em;
}
@ -1386,12 +1417,11 @@ a.tag:focus-visible { @@ -1386,12 +1417,11 @@ a.tag:focus-visible {
}
.author-profile__identity-type {
font-size: 0.72rem;
font-size: 0.78rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text);
opacity: 0.75;
color: var(--color-text-mid);
}
.author-profile__meta-value,
@ -1458,13 +1488,39 @@ a.tag:focus-visible { @@ -1458,13 +1488,39 @@ a.tag:focus-visible {
flex-grow: 0;
}
/* Payment type groups: one label per type with its addresses stacked in the right column */
.author-profile__payment-group {
display: grid;
grid-template-columns: 7.5rem minmax(0, 1fr);
column-gap: 0.5rem;
align-items: start;
margin: 0.35rem 0;
font-size: 0.9rem;
line-height: 1.35;
}
.author-profile__payment-group:first-child {
margin-top: 0;
}
.author-profile__payment-group-values {
list-style: none;
margin: 0;
padding: 0;
min-width: 0;
}
.author-profile__payment-group-value + .author-profile__payment-group-value {
margin-top: 0.3rem;
}
.author-profile__payment-type {
font-size: 0.72rem;
font-size: 0.78rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text);
opacity: 0.75;
color: var(--color-text-mid);
padding-top: 0.15em;
}
.author-profile__jumble {
@ -1722,9 +1778,9 @@ a:focus-visible { @@ -1722,9 +1778,9 @@ a:focus-visible {
font-size: clamp(0.88rem, 4.4vw, 1.12rem);
}
#site-header .header__logo-circle {
width: 36px;
height: 36px;
#site-header .header__logo-banner {
width: 88px;
height: 34px;
}
}

14
assets/styles/article.css

@ -139,9 +139,9 @@ @@ -139,9 +139,9 @@
margin: 2rem 0;
padding-top: 0.5rem;
border-top: 1px solid var(--color-border);
font-size: 0.88rem;
font-size: 1rem;
font-weight: 400;
color: color-mix(in srgb, var(--color-text-mid) 58%, var(--color-bg) 42%);
color: var(--color-text-mid);
font-family: var(--font-family), sans-serif;
}
@ -204,6 +204,7 @@ @@ -204,6 +204,7 @@
margin-left: -30px;
line-height: 1.2;
color: var(--color-secondary);
text-decoration: none;
}
.heading-permalink:hover {
@ -267,6 +268,13 @@ @@ -267,6 +268,13 @@
gap: 0.55rem;
}
/* Empty-state label when the relay fetch returned zero comments */
.comments__empty {
font-size: 0.9rem;
color: var(--color-text-mid);
margin: 0.5rem 0;
}
.comments .card.comment,
.comments-quotes__list .card.comment {
margin-left: 0;
@ -297,7 +305,7 @@ @@ -297,7 +305,7 @@
border-left: 2px solid color-mix(in srgb, var(--color-border) 50%, transparent);
background: color-mix(in srgb, var(--color-bg-light) 55%, transparent);
border-radius: 0 3px 3px 0;
font-size: 0.82em;
font-size: 0.9em;
line-height: 1.45;
color: var(--color-text-mid);
}

2
assets/styles/form.css

@ -29,7 +29,7 @@ input:focus, textarea:focus { @@ -29,7 +29,7 @@ input:focus, textarea:focus {
}
.help-text {
font-size: 0.8rem;
font-size: 0.875rem;
color: var(--color-text);
font-weight: lighter;
}

9
assets/styles/layout.css

@ -696,7 +696,7 @@ aside { @@ -696,7 +696,7 @@ aside {
border-radius: 0;
background: transparent;
line-height: 1.45;
font-size: 0.78rem;
font-size: 0.875rem;
transition: color 0.18s ease, background 0.18s ease;
}
@ -744,7 +744,7 @@ aside { @@ -744,7 +744,7 @@ aside {
/* Match article `bodyHtml` semantics; keep aside scale (`.user-highlight__body` is larger in-article). */
.home-aside-highlights__quote--html.user-highlight__body {
margin: 0 0 0.3rem;
font-size: 0.78rem;
font-size: 0.875rem;
line-height: 1.45;
color: inherit;
}
@ -758,7 +758,7 @@ aside { @@ -758,7 +758,7 @@ aside {
.home-aside-highlights__meta {
display: block;
font-size: 0.78rem;
font-size: 0.875rem;
font-style: normal;
font-weight: 500;
font-family: var(--font-family), system-ui, sans-serif;
@ -1067,9 +1067,8 @@ footer { @@ -1067,9 +1067,8 @@ footer {
.site-footer__syndication-hint {
margin: 0 0 0.75rem;
font-size: 0.9rem;
font-size: 1rem;
color: var(--color-text);
opacity: 0.9;
max-width: 40rem;
}

47
assets/styles/magazine-editor.css

@ -228,16 +228,59 @@ @@ -228,16 +228,59 @@
gap: 0.5rem;
}
/* Grid: two columns regardless of flex quirks inside fieldsets / % widths on inputs */
/* Grid: drag-handle | input | remove-btn */
.magazine-editor__a-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.35rem;
width: 100%;
min-width: 0;
}
/* Drag handle is a <span> (not a <button>) so it does not absorb the pointer-down
that starts the parent row's HTML5 drag. cursor: grab must be set explicitly here
because .magazine-editor__panel button { cursor: pointer } no longer applies. */
.magazine-editor__a-drag-handle {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.1rem 0.15rem;
margin: 0;
color: var(--color-text-mid, rgba(160, 160, 160, 0.55));
cursor: grab;
touch-action: none;
user-select: none;
line-height: 0;
font-size: 0;
}
.magazine-editor__a-drag-handle:active {
cursor: grabbing;
}
.magazine-editor__a-drag-handle:hover {
color: var(--color-text, inherit);
}
.magazine-editor__a-drag-handle svg {
display: block;
}
/* Ghost image: fade the row being dragged */
.magazine-editor__a-row[data-dragging] {
opacity: 0.35;
}
/* Drop-target indicator: a coloured line above or below the target row */
.magazine-editor__a-row[data-drag-before] {
box-shadow: 0 -2px 0 0 var(--color-accent, #5b9) inset;
}
.magazine-editor__a-row[data-drag-after] {
box-shadow: 0 2px 0 0 var(--color-accent, #5b9) inset;
}
.magazine-editor__a-line-field {
min-width: 0;
max-width: 100%;

2
assets/styles/nostr-previews.css

@ -70,7 +70,7 @@ @@ -70,7 +70,7 @@
}
.nostr-preview .card-text {
font-size: 0.9rem;
font-size: 1rem;
color: var(--color-text);
}

13
assets/theme/default/theme-dark.css

@ -60,3 +60,16 @@ html[data-color-scheme="dark"] .home-aside-highlights__item-inner:hover, @@ -60,3 +60,16 @@ html[data-color-scheme="dark"] .home-aside-highlights__item-inner:hover,
html[data-color-scheme="dark"] .home-aside-highlights__item-inner:has(.home-aside-highlights__hit:focus-visible) {
background: #2f2b27;
}
/* Reply toasts: base article.css uses hardcoded light greens/reds; override for dark. */
html[data-color-scheme="dark"] .reply-toast--success {
border-color: #3a8a55;
background: color-mix(in srgb, var(--color-bg) 70%, #2f7a4b 30%);
color: var(--color-text);
}
html[data-color-scheme="dark"] .reply-toast--error {
border-color: #b03030;
background: color-mix(in srgb, var(--color-bg) 70%, #a12b2b 30%);
color: var(--color-text);
}

34
assets/theme/default/theme.css

@ -1,12 +1,40 @@ @@ -1,12 +1,40 @@
[data-theme="light"] {
/*
* imwald / magazine light color scheme.
* Loaded when data-color-scheme="light". Mirror of theme-dark.css: selector must match
* html[data-color-scheme="..."] because data-theme is set to the magazine name ("imwald"),
* not "light". Previously used [data-theme="light"] which never matched.
*/
html[data-color-scheme="light"] {
/* Backgrounds */
--color-bg: #f4f1ee;
--color-bg-light: #e8e4df;
--color-bg-primary: #e0dbd4;
/* Text */
--color-text: #2a2a2a;
--color-text-mid: #3d3a36;
--color-text-contrast: #f4f1ee;
--brand-color: black;
--color-text-contrast: #f4f1ee; /* cream on filled surfaces (buttons, badges) */
/* Accent greens tuned for 4.5:1 on #f4f1ee / #e8e4df:
primary #3d5c38 6.8:1 on cream
secondary #4d6847 5.7:1 on cream */
--color-primary: #3d5c38;
--color-secondary: #4d6847;
--color-primary-strong: #2e4729;
--color-border: #c8c3bd;
--color-border-soft: #d9d4cf;
/* Links */
--color-link: #3a342f;
--color-link-hover: #1a1a1a;
--color-focus-ring: #1a1a1a;
/* Footer */
--color-footer-bg: var(--color-bg-light);
--color-footer-text: var(--color-text);
--color-footer-link: var(--color-secondary);
/* Misc */
--color-shadow: color-mix(in srgb, var(--color-text) 10%, transparent);
--brand-color: black;
--accent-color: var(--color-secondary);
--color-highlight-mark-fg: #1a1a1a;
/* Reading pane: light mix produces a warm off-white (not pure white) */
--article-reading-pane-bg: color-mix(in srgb, var(--color-bg) 70%, #ffffff 30%);
--article-reading-prose-color: color-mix(in srgb, var(--color-text-mid) 35%, var(--color-text) 65%);
}

3
assets/theme/local/.gitignore vendored

@ -1,2 +1 @@ @@ -1,2 +1 @@
icons
theme.css
# All theme/local assets are tracked in this branch (gitcitadel brand assets)

BIN
assets/theme/local/icons/apple-touch-icon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

BIN
assets/theme/local/icons/favicon-96x96.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

BIN
assets/theme/local/icons/favicon.ico

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
assets/theme/local/icons/web-app-manifest-192x192.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
assets/theme/local/icons/web-app-manifest-512x512.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
assets/theme/local/og-image.jpg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 166 KiB

75
assets/theme/local/theme-dark.css

@ -0,0 +1,75 @@ @@ -0,0 +1,75 @@
/*
* GitCitadel dark color scheme.
* Loaded only when data-color-scheme="dark" and config theme_stylesheet_dark is set.
* Palette mirrors gitcitadel-online/static/css/main.css exactly:
* bg-primary #2d2d2d, bg-secondary #1e1e1e, accent #7c9eff, hover #9bb3ff, visited #a58fff.
*/
html[data-color-scheme="dark"] {
/* Backgrounds */
--color-bg: #1e1e1e; /* --bg-secondary from gitcitadel-online */
--color-bg-light: #2d2d2d; /* --bg-primary */
--color-bg-primary: #3a3a3a; /* Slightly lifted surface */
/* Text */
--color-text: #f0f0f0; /* --text-primary */
--color-text-mid: #c0c0c0; /* --text-secondary */
--color-text-contrast: #1e1e1e;
/* Accents — periwinkle/lavender from gitcitadel-online */
--color-primary: #7c9eff; /* --accent-color / --link-color */
--color-secondary: #9bb3ff; /* --link-hover */
--color-primary-strong: #6b8bef;
--color-border: #404040; /* --border-color */
--color-border-soft: #333333;
--color-text-light: var(--color-text-mid);
--color-footer-bg: #1e1e1e;
--color-footer-text: var(--color-text);
--color-footer-link: var(--color-primary);
--color-highlight-mark-fg: #1e1e1e;
--color-link: #7c9eff;
--color-link-hover: #9bb3ff;
--color-link-visited: #a58fff; /* --link-visited: lavender-purple */
--color-focus-ring: #9bb3ff;
--color-shadow: color-mix(in srgb, #000 32%, transparent);
--brand-color: #f0f0f0;
--accent-color: var(--color-secondary);
--article-reading-pane-bg: color-mix(in srgb, var(--color-bg) 30%, var(--color-bg-light) 70%);
--article-reading-prose-color: color-mix(in srgb, var(--color-text-mid) 35%, var(--color-text) 65%);
}
html[data-color-scheme="dark"] a:visited {
color: var(--color-link-visited);
}
html[data-color-scheme="dark"] .article-main p,
html[data-color-scheme="dark"] .article-main ul,
html[data-color-scheme="dark"] .article-main ol,
html[data-color-scheme="dark"] .article-main li {
font-weight: 450;
}
html[data-color-scheme="dark"] .home-aside-highlights__item-inner {
border-left: 2px solid var(--color-primary);
padding-left: 0.5rem;
background: color-mix(in srgb, var(--color-bg-light) 55%, transparent);
}
html[data-color-scheme="dark"] .home-aside-highlights__quote {
color: #d8d4ee;
}
html[data-color-scheme="dark"] .home-aside-highlights__item-inner:hover,
html[data-color-scheme="dark"] .home-aside-highlights__item-inner:has(.home-aside-highlights__hit:focus-visible) {
background: #3a3a3a;
}
/* Reply toasts: override base article.css for dark. */
html[data-color-scheme="dark"] .reply-toast--success {
border-color: #3a8a55;
background: color-mix(in srgb, var(--color-bg) 70%, #2f7a4b 30%);
color: var(--color-text);
}
html[data-color-scheme="dark"] .reply-toast--error {
border-color: #b03030;
background: color-mix(in srgb, var(--color-bg) 70%, #a12b2b 30%);
color: var(--color-text);
}

20
assets/theme/local/theme.css

@ -0,0 +1,20 @@ @@ -0,0 +1,20 @@
/*
* GitCitadel light theme.
* Accent palette mirrors gitcitadel-online: periwinkle blue (#7c9eff) / lavender-purple (#a58fff).
* Light backgrounds use a cool lavender undertone; brand accent is a deep grape-purple.
*/
[data-theme="gitcitadel"] {
--color-bg: #f5f2fb; /* Very light lavender canvas */
--color-bg-light: #ede8f5; /* Soft lavender, slightly deeper */
--color-text: #1a1530; /* Deep purple-navy — strong contrast */
/* ≥4.5:1 on --color-bg and usable on --color-bg-light for secondary copy */
--color-text-mid: #3d3458;
--color-text-contrast: #f5f2fb;
--brand-color: #5c3d8f; /* Deep grape-purple */
--accent-color: #7c5cbf; /* Medium violet (between brand and link) */
--color-link: #5535a0; /* Periwinkle-to-violet (WCAG AA on light bg) */
--color-link-hover: #3d2278;
--color-focus-ring: #7c9eff; /* Matches gitcitadel-online --focus-color */
--color-shadow: color-mix(in srgb, var(--color-text) 10%, transparent);
}

70
compose.hub.yaml

@ -6,26 +6,28 @@ @@ -6,26 +6,28 @@
# docker compose -f compose.hub.yaml exec php php bin/console doctrine:migrations:migrate --no-interaction
# Optional: copy Makefile.hub into the same directory, then: make -f Makefile.hub help
#
# Services: `php` (web), `database` (MySQL), `prewarm` (same image; `app:prewarm` every 10 min — see README).
# Optional: PREWARM_FLAGS in .env (same as dev `cron` service), then `docker compose up -d --force-recreate prewarm`.
# Services: `php` (web), `prewarm` (same app image; `app:prewarm` every 10 min — see README).
# No bundled `database` — this stack uses the imwald hub MySQL (compose project `unfold`, network
# `unfold_default`, container `unfold-mysql`). Start imwald first.
#
# 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.
# Required in .env: APP_SECRET, MYSQL_* matching the imwald stack. Optional DATABASE_HOST (default
# unfold-mysql). 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.
# DEPLOY: copy THIS file + a .env to the server; do NOT reuse the imwald compose.hub.yaml.
# This file has name: gitcitadel (→ containers gitcitadel-php-1 etc.), port 9085.
# Host port: Apache proxies gitcitadel.imwald.eu → 127.0.0.1:9085. HTTP_PUBLISH is set in .env.
#
# 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
# docker build --platform linux/amd64 --target frankenphp_prod -t silberengel/unfold:gitcitadel .
# docker push silberengel/unfold:gitcitadel
#
# Override image: UNFOLD_DOCKER_IMAGE=myregistry/unfold:1.0.0 docker compose -f compose.hub.yaml up -d
name: unfold
name: gitcitadel
services:
php:
image: ${UNFOLD_DOCKER_IMAGE:-silberengel/unfold:latest}
image: ${UNFOLD_DOCKER_IMAGE:-silberengel/unfold:gitcitadel}
pull_policy: always
restart: unless-stopped
environment:
@ -33,36 +35,29 @@ services: @@ -33,36 +35,29 @@ services:
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}
DATABASE_URL: mysql://${MYSQL_USER:-unfold_user}:${MYSQL_PASSWORD:-password}@${DATABASE_HOST:-unfold-mysql}:3306/${MYSQL_DATABASE:-unfold_db}?serverVersion=${MYSQL_VERSION:-8.0.36}&charset=${MYSQL_CHARSET:-utf8mb4}
volumes:
- caddy_data:/data
- caddy_config:/config
ports:
- "${HTTP_PUBLISH:-9080}:80/tcp"
# Caddy/FrankenPHP only listen after the entrypoint finishes DB wait + migrations — allow a slow
# first MySQL + migrate on a small host (avoids "unhealthy" + failed `up` for dependents).
# Liveness: GET /health (see HealthController), not /.
- "${HTTP_PUBLISH:-127.0.0.1:9085}:80/tcp"
networks:
- default
- imwald_db
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1/health", "-o", "/dev/null"]
interval: 10s
timeout: 5s
retries: 10
start_period: 180s
depends_on:
database:
condition: service_healthy
prewarm:
image: ${UNFOLD_DOCKER_IMAGE:-silberengel/unfold:latest}
image: ${UNFOLD_DOCKER_IMAGE:-silberengel/unfold:gitcitadel}
pull_policy: always
restart: unless-stopped
# The app image healthchecks HTTP on :80; this service is CLI-only (no Caddy in this container).
healthcheck:
disable: true
working_dir: /app
# Do not wait on `curl http://php/`: Caddy in the `php` container is often only reachable on
# 127.0.0.1 from *inside* that container, so cross-container HTTP can hang. Wait on the same MySQL
# instead: `php` runs migrations in its entrypoint; the migration table is the readiness signal.
entrypoint: ["/bin/sh", "-c"]
command:
- |
@ -71,7 +66,7 @@ services: @@ -71,7 +66,7 @@ services:
sleep 2
done
until php bin/console dbal:run-sql -q "SELECT 1 FROM doctrine_migration_versions LIMIT 1" 2>/dev/null; do
echo "prewarm: waiting for migrations (php entrypoint)…"
echo "prewarm: waiting for migrations…"
sleep 3
done
while true; do
@ -83,31 +78,20 @@ services: @@ -83,31 +78,20 @@ services:
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}
DATABASE_URL: mysql://${MYSQL_USER:-unfold_user}:${MYSQL_PASSWORD:-password}@${DATABASE_HOST:-unfold-mysql}:3306/${MYSQL_DATABASE:-unfold_db}?serverVersion=${MYSQL_VERSION:-8.0.36}&charset=${MYSQL_CHARSET:-utf8mb4}
PREWARM_FLAGS: ${PREWARM_FLAGS:-}
networks:
- default
- imwald_db
depends_on:
database:
condition: service_healthy
php:
condition: service_started
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
networks:
imwald_db:
external: true
name: unfold_default
volumes:
caddy_data:
caddy_config:
database_data:

4
compose.yaml

@ -17,7 +17,7 @@ services: @@ -17,7 +17,7 @@ services:
# Caddy site address: :80 accepts any Host (needed when the app is reached via localhost:HTTP_PORT).
SERVER_NAME: ${SERVER_NAME:-:80}
# Defaults match .env.dist so a first boot without a .env file creates the same DB user as copying .env.dist later.
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}
DATABASE_URL: mysql://${MYSQL_USER:-unfold_user}:${MYSQL_PASSWORD:-password}@database:3306/${MYSQL_DATABASE:-unfold_db}?serverVersion=${MYSQL_VERSION:-8.0.36}&charset=${MYSQL_CHARSET:-utf8mb4}
volumes:
- caddy_data:/data
- caddy_config:/config
@ -30,7 +30,7 @@ services: @@ -30,7 +30,7 @@ services:
# you can comment out or remove this block. Make sure to update the DATABASE_URL in the php service accordingly.
# ---
database:
image: mysql:${MYSQL_VERSION:-8.0}
image: mysql:${MYSQL_VERSION:-8.0.36}
environment:
MYSQL_DATABASE: ${MYSQL_DATABASE:-unfold_db}
MYSQL_USER: ${MYSQL_USER:-unfold_user}

2
config/packages/cache.yaml

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
framework:
cache:
# Unique name of your app: used to compute stable namespaces for cache keys.
prefix_seed: newsroom/app
prefix_seed: '%magazine_slug%_newsroom/app'
# Use filesystem cache
app: cache.adapter.filesystem

6
config/packages/doctrine.yaml

@ -2,9 +2,9 @@ doctrine: @@ -2,9 +2,9 @@ doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
driver: pdo_mysql
# Pin version so DBAL 4 does not treat the server as "MySQL &lt; 8" and emit deprecations
# on every request (see AbstractMySQLDriver + serverVersion auto-detection).
server_version: '8.0'
# DBAL 4 requires a full x.y.z version string to unambiguously identify MySQL 8.0.x vs 8.4+.
# Using '8.0' (without patch) triggers a deprecation notice on every request.
server_version: '8.0.36'
charset: utf8mb4
default_table_options:
charset: utf8mb4

3
config/packages/monolog.yaml

@ -77,6 +77,9 @@ when@prod: @@ -77,6 +77,9 @@ when@prod:
console:
type: console
process_psr_3_messages: false
# warning+ already goes to stderr as JSON; set this to error so the console
# handler does not duplicate those lines in human-readable form.
level: error
channels: [ "!event", "!doctrine", "!deprecation" ]
deprecation:
type: stream

7
config/services.yaml

@ -77,6 +77,13 @@ services: @@ -77,6 +77,13 @@ services:
App\Service\Nip05VerificationService:
arguments:
$appCache: '@cache.app'
App\Service\CacheService:
tags:
- { name: kernel.reset, method: reset }
App\Service\TenantContext:
arguments:
$magazineSlug: '%magazine_slug%'
when@test:
services:

53
config/unfold.yaml

@ -6,18 +6,22 @@ parameters: @@ -6,18 +6,22 @@ parameters:
# Per-relay WebSocket I/O (seconds) in NostrClient; also default_socket_timeout during app:prewarm.
nostr_relay_request_timeout_sec: 12
name: 'Nostr, Curated Thoughtfully'
short_name: 'Imwald Blog'
description: 'A selection of my own Nostr long-form articles and articles from other authors, selected for the quality of their writing and the depth of their analysis.'
# Stable tenant id for shared MySQL (magazine indices, article visibility, featured authors, admin users).
# Lowercase alnum and hyphens only; must be unique per deployment on the same database.
magazine_slug: 'gitcitadel'
og_headline: 'Nostr, Curated Thoughtfully'
og_subheading: 'Imwald Blog by Laeserin'
name: 'GitCitadel Homepage'
short_name: 'GitCitadel Homepage'
description: 'GitCitadel — Nostr-native open-source software development tools and infrastructure.'
default_relay: 'wss://theforest.nostr1.com'
og_headline: 'GitCitadel Homepage'
og_subheading: 'Nostr-native publishing and development tools'
default_relay: 'wss://thecitadel.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://thecitadel.nostr1.com'
'wss://theforest.nostr1.com'
]
# Kind-0 / profile fetches (author metadata, prewarm). Tried first, then default + article_relays (deduped).
# Also used as a second pass for kind 30040 (magazine category indices) and category long-form ingest
@ -25,29 +29,24 @@ parameters: @@ -25,29 +29,24 @@ parameters:
profile_relays: [
'wss://profiles.nostr1.com'
]
# Example:
# article_relays:
# - 'wss://nos.lol'
# - 'wss://relay.ditto.pub'
# Magazine identity for data-theme=… (CSS hooks). Unrelated to light/dark color scheme.
theme: 'imwald'
theme_color: '#8c2f1c'
theme_bg_color: '#f1ebe4'
theme: 'gitcitadel'
theme_color: '#5c3d8f'
theme_bg_color: '#f5f2fb'
# Per–color-scheme stylesheets: logical asset names under assets/theme/{local,default}/ (see asset_mapper paths).
# imwald: light = editorial cream tokens; dark = second sheet (warm charcoal). Set theme_stylesheet_dark to ''
# to ship only one magazine theme and hide the footer sun/moon control.
# Set theme_stylesheet_dark to '' to disable the footer scheme toggle and ship only one theme.
theme_stylesheet_light: 'theme.css'
theme_stylesheet_dark: 'theme-dark.css'
npub: 'npub1m4ny6hjqzepn4rxknuq94c2gpqzr29ufkkw7ttcxyak7v43n6vvsajc2jl'
npub: 'npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz'
# Kind 30040 magazine root #d (NIP-33). Exposed as `d_tag` for backward compatibility.
d_tag_magazine: 'newsroom-magazine-on-imwald-by-laeserin'
d_tag_magazine: 'gitcitadel-homepage-f57299'
d_tag: '%d_tag_magazine%'
# Whether to show community articles on the home page
community_articles: true
community_articles: false
# Domain for site-assigned NIP-05 for featured (magazine category) authors; must match the host serving /.well-known/nostr.json
nip05_domain: 'blog.imwald.eu'
nip05_domain: 'gitcitadel.imwald.eu'
# Base URL for "Open in Jumble" on author profile (trailing slash optional; npub is appended as /{npub}).
jumble_profile_users_base: 'https://jumble.imwald.eu/users'
# Base for event threads: {base}/{nevent1...} (NIP-19 nevent, not raw hex id).
@ -55,13 +54,13 @@ parameters: @@ -55,13 +54,13 @@ parameters:
# Comma-separated category #d slugs to fetch first in app:prewarm after the root (see MagazineRefresher).
magazine_prewarm_prefer_slugs_empty: ''
magazine_prewarm_prefer_slugs: '%env(default:magazine_prewarm_prefer_slugs_empty:MAGAZINE_PREWARM_PREFER_SLUGS)%'
# Extra category #d slugs to 30040-fetch in prewarm right after prefer (before the rest of roots a tags), so budget runs still hit new categories.
# Extra category #d slugs to 30040-fetch in prewarm right after prefer (before the rest of root's a tags), so budget runs still hit new categories.
magazine_prewarm_also_slugs_empty: ''
magazine_prewarm_also_slugs: '%env(default:magazine_prewarm_also_slugs_empty:MAGAZINE_PREWARM_ALSO_SLUGS)%'
external_links:
- title: "Unfold"
url: "https://git.imwald.eu/silberengel/unfold/src/branch/imwald"
description: "This site’s Unfold source (imwald branch)."
- title: "Decent Newsroom"
url: "https://decentnewsroom.com/mag/newsroom-magazine-on-imwald-by-laeserin"
description: "Decentralized magazine platform. View the magazine on Decent Newsroom."
- title: "GitCitadel on GitHub"
url: "https://github.com/ShadowySupercode/"
description: "GitCitadel open-source repositories."
- title: "Unfold source"
url: "https://git.imwald.eu/silberengel/unfold/src/branch/gitcitadel"
description: "This site's Unfold source (gitcitadel branch)."

26
migrations/Version20260527110000.php

@ -0,0 +1,26 @@ @@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260527110000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add wiki_kinds column to article table for NIP-54 kind 30817 wiki pages';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE article ADD wiki_kinds JSON DEFAULT NULL COMMENT \'NIP-54 wiki: k-tag kind values (null = not a wiki page)\'');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE article DROP COLUMN wiki_kinds');
}
}

53
migrations/Version20260528140000.php

@ -0,0 +1,53 @@ @@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Multi-tenant shared MySQL: magazine_slug scopes site data; article rows stay global with article_magazine links.
*/
final class Version20260528140000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Shared DB multi-tenancy: article_magazine, magazine_slug on featured_author and app_user';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE article_magazine (magazine_slug VARCHAR(64) NOT NULL, article_id INT NOT NULL, INDEX IDX_ARTICLE_MAGAZINE_ARTICLE (article_id), PRIMARY KEY (magazine_slug, article_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE article_magazine ADD CONSTRAINT FK_ARTICLE_MAGAZINE_ARTICLE FOREIGN KEY (article_id) REFERENCES article (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE featured_author ADD magazine_slug VARCHAR(64) NOT NULL DEFAULT \'legacy\'');
$this->addSql('ALTER TABLE featured_author ALTER magazine_slug DROP DEFAULT');
$this->addSql('DROP INDEX UNIQ_8EED8C6CE479AD9 ON featured_author');
$this->addSql('DROP INDEX UNIQ_8EED8C6CEEEB401 ON featured_author');
$this->addSql('CREATE UNIQUE INDEX UNIQ_FEATURED_AUTHOR_MAG_PUBKEY ON featured_author (magazine_slug, pubkey_hex)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_FEATURED_AUTHOR_MAG_LOCAL ON featured_author (magazine_slug, local_part)');
$this->addSql('ALTER TABLE app_user ADD magazine_slug VARCHAR(64) NOT NULL DEFAULT \'legacy\'');
$this->addSql('ALTER TABLE app_user ALTER magazine_slug DROP DEFAULT');
$this->addSql('DROP INDEX UNIQ_88BDF3E95FB8BABB ON app_user');
$this->addSql('CREATE UNIQUE INDEX UNIQ_APP_USER_MAG_NPUB ON app_user (magazine_slug, npub)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE article_magazine DROP FOREIGN KEY FK_ARTICLE_MAGAZINE_ARTICLE');
$this->addSql('DROP TABLE article_magazine');
$this->addSql('DROP INDEX UNIQ_FEATURED_AUTHOR_MAG_PUBKEY ON featured_author');
$this->addSql('DROP INDEX UNIQ_FEATURED_AUTHOR_MAG_LOCAL ON featured_author');
$this->addSql('CREATE UNIQUE INDEX UNIQ_8EED8C6CE479AD9 ON featured_author (pubkey_hex)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_8EED8C6CEEEB401 ON featured_author (local_part)');
$this->addSql('ALTER TABLE featured_author DROP magazine_slug');
$this->addSql('DROP INDEX UNIQ_APP_USER_MAG_NPUB ON app_user');
$this->addSql('CREATE UNIQUE INDEX UNIQ_88BDF3E95FB8BABB ON app_user (npub)');
$this->addSql('ALTER TABLE app_user DROP magazine_slug');
}
}

612
phpstan-baseline.neon

@ -1,17 +1,5 @@ @@ -1,17 +1,5 @@
parameters:
ignoreErrors:
-
message: '#^Instanceof between App\\Entity\\ArticleHighlight and App\\Entity\\ArticleHighlight will always evaluate to true\.$#'
identifier: instanceof.alwaysTrue
count: 1
path: src/Command/ArticleHighlightsAuditCommand.php
-
message: '#^Using nullsafe property access "\?\-\>value" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 1
path: src/Command/ArticleHighlightsAuditCommand.php
-
message: '#^Call to an undefined method Symfony\\Contracts\\Cache\\CacheInterface\:\:getItem\(\)\.$#'
identifier: method.notFound
@ -24,162 +12,6 @@ parameters: @@ -24,162 +12,6 @@ parameters:
count: 1
path: src/Command/NostrEventFromYamlDefinitionCommand.php
-
message: '#^Call to function is_array\(\) with bool\|int\|string\|null will always evaluate to false\.$#'
identifier: function.impossibleType
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Call to function is_string\(\) with string will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 2
path: src/Command/PrewarmCommand.php
-
message: '#^Call to function method_exists\(\) with Symfony\\Component\\Console\\Helper\\ProgressBar and ''clear'' will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Call to function method_exists\(\) with Symfony\\Component\\Console\\Helper\\ProgressBar and ''setMinSecondsBetwee…'' will always evaluate to false\.$#'
identifier: function.impossibleType
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Offset ''categories'' on array\{categories\: int, listed\: int, resolved\: int, missing\: int\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Offset ''categories'' on array\{categories\: list\<array\{slug\: string, title\: string, event_id\: string, listed_total\: int, resolved_total\: int, missing_total\: int, entries\: list\<array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\}\>\}\>, totals\: array\{categories\: int, listed\: int, resolved\: int, missing\: int\}\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Offset ''coordinate'' on array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Offset ''entries'' on array\{slug\: string, title\: string, event_id\: string, listed_total\: int, resolved_total\: int, missing_total\: int, entries\: list\<array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\}\>\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Offset ''event_id'' on array\{slug\: string, title\: string, event_id\: string, listed_total\: int, resolved_total\: int, missing_total\: int, entries\: list\<array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\}\>\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Offset ''kind0_tags'' on array\{content\: stdClass, kind0_tags\: list\<list\<string\>\>, nip30_custom_emojis\: list\<array\{shortcode\: string, url\: string, set\?\: string\}\>\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Offset ''label'' on array\{label\: string, href\: string\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Offset ''listed'' on array\{categories\: int, listed\: int, resolved\: int, missing\: int\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Offset ''listed_total'' on array\{slug\: string, title\: string, event_id\: string, listed_total\: int, resolved_total\: int, missing_total\: int, entries\: list\<array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\}\>\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Offset ''missing'' on array\{categories\: int, listed\: int, resolved\: int, missing\: int\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Offset ''missing_total'' on array\{slug\: string, title\: string, event_id\: string, listed_total\: int, resolved_total\: int, missing_total\: int, entries\: list\<array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\}\>\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Offset ''reason'' on array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Offset ''resolved'' on array\{categories\: int, listed\: int, resolved\: int, missing\: int\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Offset ''resolved_total'' on array\{slug\: string, title\: string, event_id\: string, listed_total\: int, resolved_total\: int, missing_total\: int, entries\: list\<array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\}\>\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Offset ''slug'' on array\{slug\: string, title\: string, event_id\: string, listed_total\: int, resolved_total\: int, missing_total\: int, entries\: list\<array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\}\>\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Offset ''status'' on array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Offset ''title'' on array\{slug\: string, title\: string, event_id\: string, listed_total\: int, resolved_total\: int, missing_total\: int, entries\: list\<array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\}\>\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Offset ''totals'' on array\{categories\: list\<array\{slug\: string, title\: string, event_id\: string, listed_total\: int, resolved_total\: int, missing_total\: int, entries\: list\<array\{coordinate\: string, status\: string, reason\: string, article_title\?\: string\}\>\}\>, totals\: array\{categories\: int, listed\: int, resolved\: int, missing\: int\}\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Offset 0 on non\-empty\-list\<non\-falsy\-string\> on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Strict comparison using \=\=\= between \*NEVER\* and 1 will always evaluate to false\.$#'
identifier: identical.alwaysFalse
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Strict comparison using \=\=\= between array\{\} and array\{\} will always evaluate to true\.$#'
identifier: identical.alwaysTrue
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Using nullsafe property access "\?\-\>value" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 1
path: src/Command/PrewarmCommand.php
-
message: '#^Call to an undefined method Symfony\\Component\\Form\\FormInterface\<mixed\>\:\:getClickedButton\(\)\.$#'
identifier: method.notFound
@ -193,280 +25,10 @@ parameters: @@ -193,280 +25,10 @@ parameters:
path: src/Controller/ArticleController.php
-
message: '#^Call to function is_object\(\) with object will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: src/Controller/ArticleController.php
-
message: '#^Call to function is_object\(\) with stdClass will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: src/Controller/ArticleController.php
-
message: '#^Offset ''comment_reply…'' on array\{list\: array\<int, object\>, quotes\: array\<int, object\>, commentLinks\: array\<string, array\<int, mixed\>\>, quoteLinks\: array\<string, array\<int, mixed\>\>, processedContent\: array\<string, string\>, comment_reply_context\: array\{can_publish\: bool, coordinate\: string, article_event_id\: string\|null, parent_kind\: int, rows\: array\<int, array\<string, mixed\>\>, fragment_url\: string\}\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Controller/ArticleController.php
-
message: '#^Offset ''list'' on array\{list\: array\<int, object\>, quotes\: array\<int, object\>, commentLinks\: array\<string, array\<int, mixed\>\>, quoteLinks\: array\<string, array\<int, mixed\>\>, processedContent\: array\<string, string\>\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Controller/ArticleController.php
-
message: '#^Offset 0 on non\-empty\-list\<string\> in isset\(\) always exists and is not nullable\.$#'
identifier: isset.offset
count: 1
path: src/Controller/ArticleController.php
-
message: '#^Using nullsafe property access "\?\-\>value" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 1
path: src/Controller/ArticleController.php
-
message: '#^Offset ''ok_relays'' on array\{ok\: true, id\: string, relays\: array\<string, mixed\>, ok_relays\: int, total_relays\: int\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Controller/CommentReplyController.php
-
message: '#^Offset ''total_relays'' on array\{ok\: true, id\: string, relays\: array\<string, mixed\>, ok_relays\: int, total_relays\: int\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Controller/CommentReplyController.php
-
message: '#^Negated boolean expression is always false\.$#'
identifier: booleanNot.alwaysFalse
count: 1
path: src/Controller/DefaultController.php
-
message: '#^Call to function is_array\(\) with array\<int, string\> will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: src/Controller/SeoController.php
-
message: '#^Call to function is_string\(\) with string will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: src/Controller/SeoController.php
-
message: '#^Offset ''list'' on array\{list\: list\<App\\Entity\\Article\>, category\: array\{title\: string, summary\: string\}, pagination\: array\{page\: int, per_page\: int, total\: int, last_page\: int\}\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Controller/SeoController.php
-
message: '#^Offset ''summary'' on array\{title\: string, summary\: string\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Controller/SeoController.php
-
message: '#^Offset ''title'' on array\{title\: string, summary\: string\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Controller/SeoController.php
-
message: '#^Call to function is_array\(\) with non\-empty\-array will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: src/Form/DataTransformer/CommaSeparatedToArrayTransformer.php
-
message: '#^Call to function is_string\(\) with non\-empty\-string will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: src/Form/DataTransformer/CommaSeparatedToArrayTransformer.php
-
message: '#^Call to function is_string\(\) with string will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: src/Nostr/MagazineEventKeys.php
-
message: '#^Call to function is_array\(\) with array will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 3
path: src/Nostr/Nip19Codec.php
-
message: '#^Call to function is_array\(\) with array\<mixed\> will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: src/Nostr/Nip22CommentTags.php
-
message: '#^Comparison operation "\>\=" between int\<1, max\> and 1 is always true\.$#'
identifier: greaterOrEqual.alwaysTrue
count: 1
path: src/Nostr/Nip22CommentTags.php
-
message: '#^Instanceof between App\\Entity\\ArticleHighlight and App\\Entity\\ArticleHighlight will always evaluate to true\.$#'
identifier: instanceof.alwaysTrue
count: 2
path: src/Service/ArticleBodyHighlightInjector.php
-
message: '#^Instanceof between DOMElement and DOMElement will always evaluate to true\.$#'
identifier: instanceof.alwaysTrue
count: 1
path: src/Service/ArticleBodyHighlightInjector.php
-
message: '#^Negated boolean expression is always false\.$#'
identifier: booleanNot.alwaysFalse
count: 1
path: src/Service/ArticleBodyHighlightInjector.php
-
message: '#^Strict comparison using \=\=\= between false and DOMElement will always evaluate to false\.$#'
identifier: identical.alwaysFalse
count: 1
path: src/Service/ArticleBodyHighlightInjector.php
-
message: '#^Call to function is_object\(\) with object will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 2
path: src/Service/ArticleCommentThreadLoader.php
-
message: '#^Offset ''partial'' on array\{thread\: array\<int, object\>, quotes\: array\<int, object\>\} on left side of \?\? does not exist\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Service/ArticleCommentThreadLoader.php
-
message: '#^Offset ''quotes'' on array\{thread\: array\<int, object\>, quotes\: array\<int, object\>, partial\?\: bool\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Service/ArticleCommentThreadLoader.php
-
message: '#^Offset ''quotes'' on array\{thread\: array\<int, object\>, quotes\: array\<int, object\>\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Service/ArticleCommentThreadLoader.php
-
message: '#^Offset ''thread'' on array\{thread\: array\<int, object\>, quotes\: array\<int, object\>, partial\?\: bool\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Service/ArticleCommentThreadLoader.php
-
message: '#^Offset ''thread'' on array\{thread\: array\<int, object\>, quotes\: array\<int, object\>\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Service/ArticleCommentThreadLoader.php
-
message: '#^Offset 0 on non\-empty\-list\<string\> on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Service/ArticleCommentThreadLoader.php
-
message: '#^Call to function is_object\(\) with stdClass will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 2
path: src/Service/CacheService.php
-
message: '#^Negated boolean expression is always false\.$#'
identifier: booleanNot.alwaysFalse
count: 1
path: src/Service/CacheService.php
-
message: '#^Offset 0 on non\-empty\-list\<string\> on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Service/CacheService.php
-
message: '#^Parameter \#1 \$array \(non\-empty\-list\<string\>\) of array_values is already a list, call has no effect\.$#'
identifier: arrayValues.list
count: 1
path: src/Service/CacheService.php
-
message: '#^Strict comparison using \!\=\= between non\-empty\-list\<string\> and array\{\} will always evaluate to true\.$#'
identifier: notIdentical.alwaysTrue
count: 1
path: src/Service/CacheService.php
-
message: '#^Comparison operation "\>\=" between 3 and 2 is always true\.$#'
identifier: greaterOrEqual.alwaysTrue
count: 1
path: src/Service/CommentReplyService.php
-
message: '#^Call to function is_object\(\) with object will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: src/Service/HighlightSyncService.php
-
message: '#^Using nullsafe property access "\?\-\>value" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 1
path: src/Service/HighlightSyncService.php
-
message: '#^Call to function is_string\(\) with string will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 3
path: src/Service/MagazineContentService.php
-
message: '#^Offset ''categories'' on array\{categories\: list\<array\{entries\: list\<array\{coordinate\: string, status\: string, reason\: string\}\>\}\>\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Service/MagazineContentService.php
-
message: '#^Offset ''coordinate'' on array\{coordinate\: string, status\: ''missing'', reason\: ''article_not_in_db''\} in isset\(\) always exists and is not nullable\.$#'
identifier: isset.offset
count: 1
path: src/Service/MagazineContentService.php
-
message: '#^Offset ''entries'' on array\{entries\: list\<array\{coordinate\: string, status\: string, reason\: string\}\>\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Service/MagazineContentService.php
-
message: '#^Offset ''reason'' on array\{coordinate\: string, status\: ''missing'', reason\: string\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Service/MagazineContentService.php
-
message: '#^Offset ''status'' on array\{coordinate\: string, status\: string, reason\: string\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Service/MagazineContentService.php
-
message: '#^Offset 0 on non\-empty\-list\<string\> on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
message: '#^PHPDoc tag @param references unknown parameter\: \$rawTags$#'
identifier: parameter.notFound
count: 1
path: src/Service/MagazineContentService.php
path: src/Nostr/Nip10Kind1ArticleReplyTags.php
-
message: '#^Cannot call method __invoke\(\) on callable\.$#'
@ -474,194 +36,26 @@ parameters: @@ -474,194 +36,26 @@ parameters:
count: 4
path: src/Service/MagazineRefresher.php
-
message: '#^Offset ''label'' on array\{label\: string, href\: string, verified\?\: bool\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Service/Nip05VerificationService.php
-
message: '#^Call to function is_object\(\) with object will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: src/Service/Nip09DeletionApplier.php
-
message: '#^Call to an undefined method Symfony\\Component\\Security\\Core\\User\\UserInterface\:\:getRelays\(\)\.$#'
identifier: method.notFound
count: 2
path: src/Service/NostrClient.php
-
message: '#^Call to function is_array\(\) with array will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: src/Service/NostrClient.php
-
message: '#^Call to function is_object\(\) with object will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 5
path: src/Service/NostrClient.php
-
message: '#^Call to function is_string\(\) with string will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 6
path: src/Service/NostrClient.php
-
message: '#^Method App\\Service\\NostrClient\:\:fetchKind5DeletionEventsForAuthors\(\) has invalid return type App\\Service\\stdClass\.$#'
identifier: class.notFound
count: 1
path: src/Service/NostrClient.php
-
message: '#^Offset ''dTags'' on array\{pubkey\: string, kind\: int\<1, max\>, dTags\: non\-empty\-list\<non\-empty\-string\>\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Service/NostrClient.php
-
message: '#^Offset ''kind'' on array\{pubkey\: string, kind\: int\<1, max\>, dTags\: non\-empty\-list\<non\-empty\-string\>\} in isset\(\) always exists and is not nullable\.$#'
identifier: isset.offset
count: 1
path: src/Service/NostrClient.php
-
message: '#^Offset ''pubkey'' on array\{pubkey\: string, kind\: int\<1, max\>, dTags\: non\-empty\-list\<non\-empty\-string\>\} in isset\(\) always exists and is not nullable\.$#'
identifier: isset.offset
count: 1
path: src/Service/NostrClient.php
-
message: '#^Offset ''pubkey'' on array\{pubkey\: string, kind\: int\<1, max\>, dTags\: non\-empty\-list\<non\-empty\-string\>\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Service/NostrClient.php
-
message: '#^PHPDoc tag @return with type swentel\\nostr\\Event\\Event\|null is not subtype of native type stdClass\|null\.$#'
identifier: return.phpDocType
count: 1
path: src/Service/NostrClient.php
-
message: '#^Parameter \#1 \$array \(non\-empty\-list\<string\>\) of array_values is already a list, call has no effect\.$#'
identifier: arrayValues.list
count: 1
path: src/Service/NostrClient.php
-
message: '#^Result of \|\| is always false\.$#'
identifier: booleanOr.alwaysFalse
count: 1
path: src/Service/NostrClient.php
-
message: '#^Strict comparison using \=\=\= between non\-empty\-list\<non\-empty\-string\> and array\{\} will always evaluate to false\.$#'
identifier: identical.alwaysFalse
count: 1
path: src/Service/NostrClient.php
-
message: '#^PHPDoc tag @param for parameter \$event with type ArrayObject\<int, mixed\>\|list\<mixed\> is not subtype of native type object\.$#'
identifier: parameter.phpDocType
count: 1
path: src/Service/NostrShareMenuBuilder.php
-
message: '#^Using nullsafe property access "\?\-\>value" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 1
path: src/Service/NostrShareMenuBuilder.php
-
message: '#^Offset ''label'' on array\{label\: string, href\: string\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Service/ProfileIdentityLinksBuilder.php
-
message: '#^Offset 0 on non\-empty\-list\<string\> on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 2
path: src/Service/ProfileIdentityLinksBuilder.php
-
message: '#^Call to function is_object\(\) with object will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: src/Service/ProfilePaymentLinksBuilder.php
-
message: '#^Offset non\-falsy\-string on array\{\} in isset\(\) does not exist\.$#'
identifier: isset.offset
count: 1
path: src/Service/ProfilePaymentLinksBuilder.php
-
message: '#^Parameter \#1 \$array \(non\-empty\-list\<string\>\) of array_values is already a list, call has no effect\.$#'
identifier: arrayValues.list
count: 1
path: src/Service/ProfilePaymentLinksBuilder.php
-
message: '#^Strict comparison using \!\=\= between non\-empty\-list\<string\> and array\{\} will always evaluate to true\.$#'
identifier: notIdentical.alwaysTrue
count: 1
path: src/Service/ProfilePaymentLinksBuilder.php
-
message: '#^Call to protected method getEntityManager\(\) of class Doctrine\\ORM\\EntityRepository\<object\>\.$#'
identifier: method.protected
count: 1
path: src/Service/TopicIndexService.php
-
message: '#^Property App\\Twig\\Components\\IndexTabs\:\:\$index is never read, only written\.$#'
identifier: property.onlyWritten
count: 1
path: src/Twig/Components/IndexTabs.php
-
message: '#^Call to function method_exists\(\) with App\\Entity\\Event and ''getTags'' will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: src/Twig/Components/Molecules/CategoryLink.php
-
message: '#^Call to function method_exists\(\) with App\\Entity\\Event and ''getTags'' will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: src/Twig/Components/Organisms/FeaturedList.php
-
message: '#^Offset 0 on non\-empty\-list\<string\> on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Util/NostrEventTags.php
-
message: '#^PHPDoc tag @param references unknown parameter\: \$eventIdsLowerOrMixed$#'
identifier: parameter.notFound
count: 1
path: tests/Service/ArticleBodyHighlightInjectorTest.php
-
message: '#^Negated boolean expression is always true\.$#'
identifier: booleanNot.alwaysTrue
count: 1
path: tests/Service/ArticleHighlightCommonMarkPipelineTest.php
-
message: '#^Unreachable statement \- code above always terminates\.$#'
identifier: deadCode.unreachable
count: 1
path: tests/Service/ArticleHighlightCommonMarkPipelineTest.php
-
message: '#^Call to function method_exists\(\) with ''Symfony\\\\Component\\\\Dotenv\\\\Dotenv'' and ''bootEnv'' will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: tests/bootstrap.php

BIN
public/monero.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

5
src/Command/ArticleHighlightsAuditCommand.php

@ -76,7 +76,7 @@ final class ArticleHighlightsAuditCommand extends Command @@ -76,7 +76,7 @@ final class ArticleHighlightsAuditCommand extends Command
$io->title('Article highlights audit: '.$slug);
$io->writeln('Author npub: <info>'.$expectedNpub.'</info>');
$io->writeln('Article id: <info>'.(string) $article->getId().'</info> · kind: <info>'.
($article->getKind()?->value ?? 'null').'</info>');
$article->getKind()->value.'</info>');
$highlights = $this->articleHighlightRepository->findByArticle($article);
$io->writeln('Rows from <comment>findByArticle</comment>: <info>'.\count($highlights).'</info>');
@ -99,9 +99,6 @@ final class ArticleHighlightsAuditCommand extends Command @@ -99,9 +99,6 @@ final class ArticleHighlightsAuditCommand extends Command
$rows = [];
$isolatedOk = 0;
foreach ($highlights as $h) {
if (! $h instanceof ArticleHighlight) {
continue;
}
$eid = \strtolower($h->getEventId());
$one = $this->articleBodyHighlightInjector->inject($html, [$h]);
$found = 1 === \preg_match(

9
src/Command/ElevateUserCommand.php

@ -5,6 +5,7 @@ declare(strict_types=1); @@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Command;
use App\Entity\User;
use App\Repository\UserEntityRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
@ -18,8 +19,10 @@ use Symfony\Component\Console\Output\OutputInterface; @@ -18,8 +19,10 @@ use Symfony\Component\Console\Output\OutputInterface;
)]
class ElevateUserCommand extends Command
{
public function __construct(private readonly EntityManagerInterface $entityManager)
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly UserEntityRepository $userRepository,
) {
parent::__construct();
}
@ -39,7 +42,7 @@ class ElevateUserCommand extends Command @@ -39,7 +42,7 @@ class ElevateUserCommand extends Command
}
/** @var User|null $user */
$user = $this->entityManager->getRepository(User::class)->findOneBy(['npub' => $npub]);
$user = $this->userRepository->findOneByNpub($npub);
if (!$user) {
return Command::FAILURE;
}

104
src/Command/PrewarmCommand.php

@ -105,29 +105,7 @@ final class PrewarmCommand extends Command @@ -105,29 +105,7 @@ final class PrewarmCommand extends Command
} elseif ($phase === 'after_root') {
$hb->silent = true;
$this->cancelPcntlAlarm();
$planned = $p['slugs'] ?? null;
if (!\is_array($planned)) {
$planned = [];
}
if ($planned === []) {
$io->writeln(' <comment>Magazine root has no child <info>a</info> tag categories; only the root index was stored.</comment>');
} else {
$n = \count($planned);
$io->writeln(sprintf(' <comment>Magazine child categories in root</comment> <info>(%d)</info><comment>:</comment>', $n));
foreach ($planned as $slug) {
$s = (string) $slug;
if (strlen($s) > 120) {
$s = substr($s, 0, 117).'…';
}
$io->writeln(sprintf(' · <info>%s</info>', $s));
}
$io->writeln(sprintf(
' <comment>Progress bar: <info>%d</info> steps = <info>1</info> (root) + <info>%d</info> (categor%s).</comment>',
1 + $n,
$n,
$n === 1 ? 'y' : 'ies'
));
}
$io->writeln(' <comment>Magazine root has no child <info>a</info> tag categories; only the root index was stored.</comment>');
$bar = $this->createPrewarmProgressBar(
$io,
max(1, (int) ($p['total_steps'] ?? 1)),
@ -190,13 +168,17 @@ final class PrewarmCommand extends Command @@ -190,13 +168,17 @@ final class PrewarmCommand extends Command
// any later Nostr phase (long-form can exceed that old cap and was causing max-time fatals).
$this->disableCliExecutionTimeLimit();
$io->section('Long-form in DB (category `a` tags — refresh from Nostr)');
$io->section('Long-form in DB (magazine root + category `a` tags — refresh from Nostr)');
try {
$nRoot = $this->magazineContent->ingestLongformForMagazineRootHeadline();
if ($nRoot > 0) {
$io->writeln(sprintf('Magazine root headline strip: refreshed <info>%d</info> long-form coordinate(s).', $nRoot));
}
$n = $this->magazineContent->ingestLongformForAllMagazineCategories();
if ($n === 0) {
$io->note('No category `a` coordinates in the magazine store (or empty category indices).');
} else {
$io->writeln(sprintf('Fetched latest long-form for <info>%d</info> coordinate(s) (new rows + NIP-33 updates).', $n));
if ($n === 0 && $nRoot === 0) {
$io->note('No root or category `a` long-form coordinates in the magazine store (or empty indices).');
} elseif ($n > 0) {
$io->writeln(sprintf('Category indices: fetched latest long-form for <info>%d</info> coordinate(s) (new rows + NIP-33 updates).', $n));
}
$report = $this->magazineContent->buildCategoryArticleDbCoverageReport();
$missingCoords = $this->magazineContent->missingInDbCoordinatesFromCoverageReport($report);
@ -245,7 +227,7 @@ final class PrewarmCommand extends Command @@ -245,7 +227,7 @@ final class PrewarmCommand extends Command
$until = time();
$deletionPubkeys = [];
foreach ($this->articleRepository->findDistinctAuthorPubkeys() as $pk) {
if (\is_string($pk) && 64 === \strlen($pk)) {
if (64 === \strlen($pk)) {
$deletionPubkeys[] = $pk;
}
}
@ -289,7 +271,7 @@ final class PrewarmCommand extends Command @@ -289,7 +271,7 @@ final class PrewarmCommand extends Command
$st['articles_removed'],
$st['magazine_roots'],
$st['magazine_categories'],
$st['magazine_curation_30004'] ?? 0
$st['magazine_curation_30004']
));
} catch (\Throwable $e) {
$this->logger->error('app:prewarm NIP-09 failed', ['exception' => $e]);
@ -322,7 +304,7 @@ final class PrewarmCommand extends Command @@ -322,7 +304,7 @@ final class PrewarmCommand extends Command
}
$pubkeysSeen = [];
foreach ($pubkeys as $pk) {
if (!\is_string($pk) || 64 !== \strlen($pk)) {
if (64 !== \strlen($pk)) {
continue;
}
$h = strtolower($pk);
@ -357,7 +339,7 @@ final class PrewarmCommand extends Command @@ -357,7 +339,7 @@ final class PrewarmCommand extends Command
$fetched = $this->nostrClient->fetchProfilePrewarmWireBundlesForAuthors($chunk, $batchSize);
$n += $this->cacheService->putPrewarmMetadataBatch($chunk, $fetched);
$bar->advance(\count($chunk));
$p0 = (string) ($chunk[0] ?? '');
$p0 = $chunk[0];
$bar->setMessage('Batch up to · '.substr($p0, 0, 8).'…');
}
} catch (\Throwable $e) {
@ -379,6 +361,17 @@ final class PrewarmCommand extends Command @@ -379,6 +361,17 @@ final class PrewarmCommand extends Command
$this->waitForSiteWellKnownBeforeVerification($io, $domain);
}
$io->writeln('Verifying <comment>NIP-05</comment> (HTTPS <comment>/.well-known/nostr.json</comment>, per identifier)…');
$npubsForVerify = [];
foreach ($toWarm as $hex) {
if (64 !== \strlen($hex) || !ctype_xdigit($hex)) {
continue;
}
try {
$npubsForVerify[] = $this->nostrKeyHelper->convertPublicKeyToBech32(strtolower($hex));
} catch (\Throwable) {
}
}
$this->cacheService->prefetchMetadataForNpubs($npubsForVerify);
$nt = 0;
$nv = 0;
foreach ($toWarm as $hex) {
@ -388,7 +381,7 @@ final class PrewarmCommand extends Command @@ -388,7 +381,7 @@ final class PrewarmCommand extends Command
$hex = strtolower($hex);
$npub = $this->nostrKeyHelper->convertPublicKeyToBech32($hex);
$bundle = $this->cacheService->getMetadataBundle($npub);
$rows = $this->profileIdentityLinks->buildNip05($bundle['content'], $bundle['kind0_tags'] ?? []);
$rows = $this->profileIdentityLinks->buildNip05($bundle['content'], $bundle['kind0_tags']);
$fa = $this->featuredAuthorRepository->findOneByPubkeyHex($hex);
if ($fa !== null && $fa->isListed() && $domain !== '') {
$rows = $this->profileIdentityLinks->mergeSiteNip05IntoList(
@ -398,7 +391,7 @@ final class PrewarmCommand extends Command @@ -398,7 +391,7 @@ final class PrewarmCommand extends Command
}
foreach ($rows as $r) {
++$nt;
$label = (string) ($r['label'] ?? '');
$label = $r['label'];
if ($this->nip05Verification->verifyAndCache($hex, $label)) {
++$nv;
}
@ -455,7 +448,7 @@ final class PrewarmCommand extends Command @@ -455,7 +448,7 @@ final class PrewarmCommand extends Command
continue;
}
$kind = $article->getKind()?->value ?? 30023;
$kind = $article->getKind()->value;
$coordinate = $kind.':'.$pubkey.':'.$slug;
$msg = $slug;
if (strlen($msg) > 56) {
@ -661,9 +654,6 @@ final class PrewarmCommand extends Command @@ -661,9 +654,6 @@ final class PrewarmCommand extends Command
private function createPrewarmProgressBar(SymfonyStyle $io, int $max, string $message = ''): ProgressBar
{
$bar = $io->createProgressBar($max);
if (method_exists($bar, 'setMinSecondsBetweenRedraws')) {
$bar->setMinSecondsBetweenRedraws(5.0);
}
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%%'."\n".' <comment>%message%</comment> <info>%elapsed:6s%</info> ');
$bar->setMessage($message);
@ -700,9 +690,7 @@ final class PrewarmCommand extends Command @@ -700,9 +690,7 @@ final class PrewarmCommand extends Command
$max = (int) $bar->getMaxSteps();
$done = (int) $bar->getProgress();
if ($max > 0 && $done < $max && $done === 0) {
if (method_exists($bar, 'clear')) {
$bar->clear();
}
$bar->clear();
} else {
if ($max > 0 && $done < $max && $done > 0) {
$bar->setMaxSteps($done);
@ -776,33 +764,33 @@ final class PrewarmCommand extends Command @@ -776,33 +764,33 @@ final class PrewarmCommand extends Command
private function printCategoryCoverageSummary(SymfonyStyle $io, array $report): void
{
$io->section('Category index -> DB coverage');
$tot = $report['totals'] ?? ['categories' => 0, 'listed' => 0, 'resolved' => 0, 'missing' => 0];
$tot = $report['totals'];
$io->writeln(sprintf(
'Categories: <info>%d</info> · listed coordinates: <info>%d</info> · in DB: <info>%d</info> · missing: <comment>%d</comment>',
(int) ($tot['categories'] ?? 0),
(int) ($tot['listed'] ?? 0),
(int) ($tot['resolved'] ?? 0),
(int) ($tot['missing'] ?? 0),
(int) $tot['categories'],
(int) $tot['listed'],
(int) $tot['resolved'],
(int) $tot['missing'],
));
foreach ($report['categories'] ?? [] as $cat) {
$title = trim((string) ($cat['title'] ?? ''));
$slug = (string) ($cat['slug'] ?? '');
$eventId = (string) ($cat['event_id'] ?? '');
foreach ($report['categories'] as $cat) {
$title = trim((string) $cat['title']);
$slug = (string) $cat['slug'];
$eventId = (string) $cat['event_id'];
$io->writeln(sprintf(
' - <info>%s</info> (%s) · event <comment>%s</comment> · listed <info>%d</info>, in DB <info>%d</info>, missing <comment>%d</comment>',
$title !== '' ? $title : $slug,
$slug,
$eventId !== '' ? $eventId : 'n/a',
(int) ($cat['listed_total'] ?? 0),
(int) ($cat['resolved_total'] ?? 0),
(int) ($cat['missing_total'] ?? 0),
(int) $cat['listed_total'],
(int) $cat['resolved_total'],
(int) $cat['missing_total'],
));
foreach ($cat['entries'] ?? [] as $entry) {
$coord = (string) ($entry['coordinate'] ?? '');
foreach ($cat['entries'] as $entry) {
$coord = (string) $entry['coordinate'];
if ($coord === '') {
continue;
}
$status = (string) ($entry['status'] ?? 'missing');
$status = (string) $entry['status'];
if ($status === 'resolved') {
$titleOut = trim((string) ($entry['article_title'] ?? ''));
$io->writeln(sprintf(
@ -810,8 +798,10 @@ final class PrewarmCommand extends Command @@ -810,8 +798,10 @@ final class PrewarmCommand extends Command
$coord,
$titleOut !== '' ? ' -> '.$titleOut : ''
));
} elseif ($status === 'skipped') {
$io->writeln(sprintf(' · <info>sub-index</info> %s', $coord));
} else {
$reason = (string) ($entry['reason'] ?? 'unknown');
$reason = (string) $entry['reason'];
$io->writeln(sprintf(' - <comment>MISSING</comment> %s (%s)', $coord, $reason));
}
}

2
src/Controller/Administration/RoleController.php

@ -44,7 +44,7 @@ class RoleController extends AbstractController @@ -44,7 +44,7 @@ class RoleController extends AbstractController
}
$role = $form->get('role')->getData();
$user = $userRepository->findOneBy(['npub' => $npub]);
$user = $userRepository->findOneByNpub($npub);
$user->addRole($role);
$em->persist($user);
$em->flush();

58
src/Controller/ArticleController.php

@ -61,16 +61,36 @@ class ArticleController extends AbstractController @@ -61,16 +61,36 @@ class ArticleController extends AbstractController
$articleTitle = substr($articleTitle, 0, 200);
}
$headers = [
'Content-Type' => 'text/html; charset=UTF-8',
'Cache-Control' => 'private, no-store',
];
// Phase-1 fast path: return whatever is in the filesystem cache without touching relays.
// The JS fires this in parallel with the full relay request so readers see cached comments
// immediately (< 100 ms) while the relay fetch continues in the background.
if ($request->query->getBoolean('cached')) {
$cached = $loader->tryLoadFromCacheOnly($coordinate, $articleEventId);
if ($cached === null) {
// Cache miss — return an empty shell; the full relay fetch is already in flight.
// The article template already shows "Loading comments…" as the initial DOM state,
// so there is no need to repeat it here.
return new Response('<div class="comments" data-comments-partial="1"></div>', Response::HTTP_OK, $headers);
}
try {
$data = $this->enrichCommentDataWithReplyContext($cached, $coordinate, $articleEventId, $articleTitle);
return $this->render('components/Organisms/Comments.html.twig', $data, new Response('', Response::HTTP_OK, $headers));
} catch (\Throwable) {
return new Response('<div class="comments"></div>', Response::HTTP_OK, $headers);
}
}
$logger->info('http.fragment.comments_start', [
'coordinate' => $coordinate,
'article_event_hex' => $articleEventId,
]);
$headers = [
'Content-Type' => 'text/html; charset=UTF-8',
'Cache-Control' => 'private, max-age=60',
];
try {
$data = $loader->load($coordinate, $articleEventId);
$data = $this->enrichCommentDataWithReplyContext(
@ -140,7 +160,7 @@ class ArticleController extends AbstractController @@ -140,7 +160,7 @@ class ArticleController extends AbstractController
string $articleTitle
): array {
$coordparts = explode(':', $coordinate, 3);
$articleKind = isset($coordparts[0]) && ctype_digit($coordparts[0]) ? (int) $coordparts[0] : 30023;
$articleKind = ctype_digit($coordparts[0]) ? (int) $coordparts[0] : 30023;
$articleAuthorPubkey = strtolower(trim((string) ($coordparts[1] ?? '')));
$articleReplyTags = null;
@ -170,11 +190,8 @@ class ArticleController extends AbstractController @@ -170,11 +190,8 @@ class ArticleController extends AbstractController
if ($userMayReply) {
/** @var array<int, object> $list */
$list = $data['list'] ?? [];
$list = $data['list'];
foreach ($list as $row) {
if (!\is_object($row)) {
continue;
}
$k = (int) ($row->kind ?? 0);
if ($k !== KindsEnum::COMMENTS->value && $k !== KindsEnum::TEXT_NOTE->value) {
continue;
@ -278,8 +295,7 @@ class ArticleController extends AbstractController @@ -278,8 +295,7 @@ class ArticleController extends AbstractController
$author = $data->pubkey;
$kind = (int) $data->kind;
$allowedKinds = [KindsEnum::LONGFORM->value, KindsEnum::LONGFORM_DRAFT->value];
if (!\in_array($kind, $allowedKinds, true)) {
if (!\in_array($kind, KindsEnum::longformKindValues(), true)) {
throw new \Exception('Not a long form article');
}
@ -306,19 +322,16 @@ class ArticleController extends AbstractController @@ -306,19 +322,16 @@ class ArticleController extends AbstractController
public function article(
string $npub,
string $slug,
EntityManagerInterface $entityManager,
ArticleRepository $articleRepository,
CacheService $cacheService,
ArticleCommentThreadLoader $commentThreadLoader,
ArticleBodyHtmlRenderer $articleBodyHtmlRenderer,
NostrKeyHelper $nostrKeyHelper,
): Response {
$article = $this->loadLatestArticleBySlug($entityManager, $slug);
$article = $articleRepository->findLatestBySlugForTenant($slug, $nostrKeyHelper->convertToHex($npub));
if ($article === null) {
throw $this->createNotFoundException('The article could not be found');
}
if ($nostrKeyHelper->convertToHex($npub) !== strtolower((string) $article->getPubkey())) {
throw $this->createNotFoundException('The article could not be found');
}
return $this->renderArticle(
$article,
@ -376,7 +389,7 @@ class ArticleController extends AbstractController @@ -376,7 +389,7 @@ class ArticleController extends AbstractController
$npub = $nostrKeyHelper->convertPublicKeyToBech32($article->getPubkey());
$author = $cacheService->getMetadata($npub);
$kind = $article->getKind()?->value ?? 30023;
$kind = $article->getKind()->value;
$pubkey = (string) $article->getPubkey();
$articleSlug = (string) $article->getSlug();
$coordinate = $kind.':'.$pubkey.':'.$articleSlug;
@ -395,7 +408,7 @@ class ArticleController extends AbstractController @@ -395,7 +408,7 @@ class ArticleController extends AbstractController
$eid,
$articleTitle
);
$commentReplyContext = $commentsData['comment_reply_context'] ?? $commentReplyContext;
$commentReplyContext = $commentsData['comment_reply_context'];
$commentsPreloaded = true;
}
@ -490,7 +503,7 @@ class ArticleController extends AbstractController @@ -490,7 +503,7 @@ class ArticleController extends AbstractController
}
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)) {
} elseif ($html === '') {
$previewData->type = $descriptor->type;
$html = $this->renderView('components/Molecules/NostrPreviewContent.html.twig', [
'preview' => $previewData,
@ -611,14 +624,15 @@ class ArticleController extends AbstractController @@ -611,14 +624,15 @@ class ArticleController extends AbstractController
$perPage = 25;
$page = max(1, $request->query->getInt('page', 1));
$offset = ($page - 1) * $perPage;
/** @var ArticleRepository $repo */
$repo = $entityManager->getRepository(Article::class);
$total = $repo->count([]);
$total = $repo->countForMagazine();
$lastPage = max(1, (int) ceil($total / $perPage));
if ($page > $lastPage) {
$page = $lastPage;
$offset = ($page - 1) * $perPage;
}
$articles = $repo->findBy([], ['createdAt' => 'DESC'], $perPage, $offset);
$articles = $repo->findForMagazinePaginated($perPage, $offset);
$category = (object) [
'title' => 'Community Articles',

2
src/Controller/AuthorController.php

@ -46,7 +46,7 @@ class AuthorController extends AbstractController @@ -46,7 +46,7 @@ class AuthorController extends AbstractController
$bundle = $cacheService->getMetadataBundle($npub);
$author = $bundle['content'];
$kind0Tags = $bundle['kind0_tags'];
$nip30Emojis = $bundle['nip30_custom_emojis'] ?? [];
$nip30Emojis = $bundle['nip30_custom_emojis'];
$perPage = 25;
$page = max(1, $request->query->getInt('page', 1));
$total = $articleRepository->countByPubkey($pubkey);

4
src/Controller/CommentReplyController.php

@ -57,8 +57,8 @@ final class CommentReplyController extends AbstractController @@ -57,8 +57,8 @@ final class CommentReplyController extends AbstractController
return $this->json([
'ok' => true,
'id' => $out['id'],
'ok_relays' => $out['ok_relays'] ?? null,
'total_relays' => $out['total_relays'] ?? null,
'ok_relays' => $out['ok_relays'],
'total_relays' => $out['total_relays'],
]);
}

3
src/Controller/DefaultController.php

@ -64,9 +64,6 @@ class DefaultController extends AbstractController @@ -64,9 +64,6 @@ class DefaultController extends AbstractController
try {
$embed = new \Embed\Embed();
$info = $embed->get($url);
if (!$info) {
throw new Exception('No OG data found');
}
return $this->render('components/Molecules/OgPreview.html.twig', [
'og' => [

10
src/Controller/SeoController.php

@ -140,12 +140,12 @@ final class SeoController extends AbstractController @@ -140,12 +140,12 @@ final class SeoController extends AbstractController
private function buildRelaysByPubkey(array $names): array
{
$raw = $this->params->get('profile_relays');
if (!\is_array($raw) || $raw === []) {
if ($raw === []) {
return [];
}
$urls = [];
foreach ($raw as $u) {
if (\is_string($u) && (str_starts_with($u, 'wss://') || str_starts_with($u, 'ws://'))) {
if (str_starts_with($u, 'wss://') || str_starts_with($u, 'ws://')) {
$urls[] = $u;
}
}
@ -193,9 +193,9 @@ final class SeoController extends AbstractController @@ -193,9 +193,9 @@ final class SeoController extends AbstractController
}
$site = (string) $this->params->get('name');
$data = $this->magazineContent->getCategoryPageData($slug);
$rawList = $data['list'] ?? [];
$catTitle = (string) ($data['category']['title'] ?? $this->magazineContent->getCategoryDisplayTitle($slug));
$summary = (string) ($data['category']['summary'] ?? '');
$rawList = $data['list'];
$catTitle = (string) ($data['category']['title']);
$summary = (string) ($data['category']['summary']);
$list = array_values(
array_filter(

29
src/Entity/Article.php

@ -9,10 +9,9 @@ use Doctrine\DBAL\Types\Types; @@ -9,10 +9,9 @@ use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
/**
* Entity storing long-form articles
* Needed beyond the Event entity, because of the local functionalities built on top of the original events
* - editor
* NIP-23, kinds 30023, 30024
* Entity storing long-form articles and wiki pages.
* NIP-23 long-form: kinds 30023, 30024.
* NIP-54 wiki: kind 30817 (same Markdown format; adds `k` tags listing affected kinds).
*/
#[ORM\Entity(repositoryClass: ArticleRepository::class)]
class Article
@ -64,6 +63,13 @@ class Article @@ -64,6 +63,13 @@ class Article
#[ORM\Column(nullable: true, enumType: EventStatusEnum::class)]
private ?EventStatusEnum $eventStatus = EventStatusEnum::PREVIEW;
/**
* NIP-54 wiki: `k` tags listing the Nostr kinds this spec affects (e.g. [9740, 9741]).
* Null for non-wiki articles; empty array when none were listed.
*/
#[ORM\Column(type: Types::JSON, nullable: true)]
private ?array $wikiKinds = null;
// Local properties
#[ORM\Column(type: Types::JSON, nullable: true)]
private ?array $currentPlaces;
@ -308,6 +314,21 @@ class Article @@ -308,6 +314,21 @@ class Article
return $this;
}
/**
* @return list<int>|null null = not a wiki page; [] = wiki page with no `k` tags
*/
public function getWikiKinds(): ?array
{
return $this->wikiKinds;
}
public function setWikiKinds(?array $wikiKinds): static
{
$this->wikiKinds = $wikiKinds;
return $this;
}
public function isDraft()
{
return $this->eventStatus === EventStatusEnum::PREVIEW;

41
src/Entity/ArticleMagazine.php

@ -0,0 +1,41 @@ @@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\ArticleMagazineRepository;
use Doctrine\ORM\Mapping as ORM;
/**
* Links a global {@see Article} row to a magazine tenant that ingested or references it.
*/
#[ORM\Entity(repositoryClass: ArticleMagazineRepository::class)]
#[ORM\Table(name: 'article_magazine')]
class ArticleMagazine
{
#[ORM\Id]
#[ORM\Column(length: 64)]
private string $magazineSlug = '';
#[ORM\Id]
#[ORM\ManyToOne(targetEntity: Article::class)]
#[ORM\JoinColumn(name: 'article_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private Article $article;
public function __construct(string $magazineSlug, Article $article)
{
$this->magazineSlug = $magazineSlug;
$this->article = $article;
}
public function getMagazineSlug(): string
{
return $this->magazineSlug;
}
public function getArticle(): Article
{
return $this->article;
}
}

23
src/Entity/FeaturedAuthor.php

@ -14,6 +14,8 @@ use Doctrine\ORM\Mapping as ORM; @@ -14,6 +14,8 @@ use Doctrine\ORM\Mapping as ORM;
*/
#[ORM\Entity(repositoryClass: FeaturedAuthorRepository::class)]
#[ORM\Table(name: 'featured_author')]
#[ORM\UniqueConstraint(name: 'uniq_featured_author_mag_pubkey', columns: ['magazine_slug', 'pubkey_hex'])]
#[ORM\UniqueConstraint(name: 'uniq_featured_author_mag_local', columns: ['magazine_slug', 'local_part'])]
class FeaturedAuthor
{
#[ORM\Id]
@ -21,13 +23,16 @@ class FeaturedAuthor @@ -21,13 +23,16 @@ class FeaturedAuthor
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 64, unique: true)]
#[ORM\Column(length: 64)]
private string $magazineSlug = '';
#[ORM\Column(length: 64)]
private string $pubkeyHex = '';
/**
* NIP-05 local-part (a–z, 0–9, -, _, .) unique across all rows.
* NIP-05 local-part (a–z, 0–9, -, _, .) unique per magazine tenant.
*/
#[ORM\Column(length: 100, unique: true)]
#[ORM\Column(length: 100)]
private string $localPart = '';
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => true])]
@ -46,6 +51,18 @@ class FeaturedAuthor @@ -46,6 +51,18 @@ class FeaturedAuthor
return $this->id;
}
public function getMagazineSlug(): string
{
return $this->magazineSlug;
}
public function setMagazineSlug(string $magazineSlug): static
{
$this->magazineSlug = $magazineSlug;
return $this;
}
public function getPubkeyHex(): string
{
return $this->pubkeyHex;

18
src/Entity/User.php

@ -13,6 +13,7 @@ use Symfony\Component\Security\Core\User\UserInterface; @@ -13,6 +13,7 @@ use Symfony\Component\Security\Core\User\UserInterface;
*/
#[ORM\Entity(repositoryClass: UserEntityRepository::class)]
#[ORM\Table(name: "app_user")]
#[ORM\UniqueConstraint(name: 'uniq_app_user_mag_npub', columns: ['magazine_slug', 'npub'])]
class User implements UserInterface, EquatableInterface
{
#[ORM\Id]
@ -20,7 +21,10 @@ class User implements UserInterface, EquatableInterface @@ -20,7 +21,10 @@ class User implements UserInterface, EquatableInterface
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(unique: true)]
#[ORM\Column(length: 64)]
private string $magazineSlug = '';
#[ORM\Column]
private ?string $npub = null;
#[ORM\Column(type: Types::JSON, nullable: true)]
@ -73,6 +77,16 @@ class User implements UserInterface, EquatableInterface @@ -73,6 +77,16 @@ class User implements UserInterface, EquatableInterface
$this->npub = $npub;
}
public function getMagazineSlug(): string
{
return $this->magazineSlug;
}
public function setMagazineSlug(string $magazineSlug): void
{
$this->magazineSlug = $magazineSlug;
}
public function eraseCredentials(): void
{
$this->metadata = null;
@ -118,6 +132,7 @@ class User implements UserInterface, EquatableInterface @@ -118,6 +132,7 @@ class User implements UserInterface, EquatableInterface
{
return [
'id' => $this->id,
'magazineSlug' => $this->magazineSlug,
'npub' => $this->npub,
'roles' => $this->roles,
'metadata' => $this->metadata,
@ -128,6 +143,7 @@ class User implements UserInterface, EquatableInterface @@ -128,6 +143,7 @@ class User implements UserInterface, EquatableInterface
public function __unserialize(array $data): void
{
$this->id = $data['id'];
$this->magazineSlug = $data['magazineSlug'] ?? '';
$this->npub = $data['npub'];
$this->roles = $data['roles'];
$this->metadata = $data['metadata'];

26
src/Enum/KindsEnum.php

@ -17,8 +17,32 @@ enum KindsEnum: int @@ -17,8 +17,32 @@ enum KindsEnum: int
case CURATION_SET = 30004; // NIP-51
case LONGFORM = 30023; // NIP-23
case LONGFORM_DRAFT = 30024; // NIP-23
case WIKI = 30817; // NIP-54 wiki pages
case PUBLICATION_INDEX = 30040;
case ZAP = 9735; // NIP-57, Zaps
/**
* All kinds stored as long-form articles in the `article` table: 30023, 30024, 30817.
*
* @return list<self>
*/
public static function longformKinds(): array
{
return [self::LONGFORM, self::LONGFORM_DRAFT, self::WIKI];
}
/**
* @return list<int>
*/
public static function longformKindValues(): array
{
return [self::LONGFORM->value, self::LONGFORM_DRAFT->value, self::WIKI->value];
}
case ZAP_REQUEST = 9734; // NIP-57, Zap request
case ZAP = 9735; // NIP-57, Zap receipt (Lightning)
case MONERO_ZAP_RECEIPT = 9736; // Monero zap receipt (Garnet/Nosmero, analogous to 9735)
case PAYMENT_NOTIFICATION = 9740; // NIP-A3, payment notification (superchat sender)
case PAYMENT_ATTESTATION = 9741; // NIP-A3, payment attestation (superchat recipient confirms)
case MONERO_TIP = 1814; // Garnet Monero tip (self-attesting, proof embedded in content JSON)
case HIGHLIGHTS = 9802;
case RELAY_LIST = 10002; // NIP-65, Relay list metadata
case EMOJI_LIST = 10030; // NIP-51 standard list, NIP-30 emoji tags

34
src/Factory/ArticleFactory.php

@ -8,14 +8,15 @@ use App\Enum\KindsEnum; @@ -8,14 +8,15 @@ use App\Enum\KindsEnum;
use InvalidArgumentException;
/**
* Map nostr events of kind 30023 to local article entity
* Map long-form (30023/30024) and wiki (30817) Nostr events to the Article entity.
*/
class ArticleFactory
{
public function createFromLongFormContentEvent($source): Article
{
if ($source->kind !== KindsEnum::LONGFORM->value) {
throw new InvalidArgumentException('Source event kind should be 30023');
$kind = (int) ($source->kind ?? 0);
if (!\in_array($kind, KindsEnum::longformKindValues(), true)) {
throw new InvalidArgumentException('Source event kind must be a longform kind (30023, 30024, 30817), got '.$kind);
}
$entity = new Article();
$entity->setRaw($source);
@ -26,26 +27,30 @@ class ArticleFactory @@ -26,26 +27,30 @@ class ArticleFactory
}
$entity->setCreatedAt($created);
$entity->setContent($source->content);
$entity->setKind(KindsEnum::from($source->kind));
$entity->setKind(KindsEnum::from($kind));
$entity->setPubkey($source->pubkey);
$entity->setSig($source->sig);
$entity->setEventStatus(EventStatusEnum::PUBLISHED);
$entity->setRatingNegative(0);
$entity->setRatingPositive(0);
// process tags
$wikiKinds = $kind === KindsEnum::WIKI->value ? [] : null;
foreach ($source->tags as $tag) {
if (!\is_array($tag) || !isset($tag[0])) {
continue;
}
switch ($tag[0]) {
case 'd':
$entity->setSlug($tag[1]);
$entity->setSlug($tag[1] ?? null);
break;
case 'title':
$entity->setTitle($tag[1]);
$entity->setTitle($tag[1] ?? null);
break;
case 'summary':
$entity->setSummary($tag[1]);
$entity->setSummary($tag[1] ?? null);
break;
case 'image':
$entity->setImage($tag[1]);
$entity->setImage($tag[1] ?? null);
break;
case 'published_at':
$parsed = $this->parseEventTimeValue($tag[1] ?? null);
@ -54,13 +59,22 @@ class ArticleFactory @@ -54,13 +59,22 @@ class ArticleFactory
}
break;
case 't':
$entity->addTopic($tag[1]);
if (isset($tag[1])) {
$entity->addTopic($tag[1]);
}
break;
case 'k':
// NIP-54: `k` tags list the Nostr kinds this wiki page specifies
if ($wikiKinds !== null && isset($tag[1]) && ctype_digit((string) $tag[1])) {
$wikiKinds[] = (int) $tag[1];
}
break;
case 'client':
// used to signal where it was created, ignored for now
break;
}
}
$entity->setWikiKinds($wikiKinds);
return $entity;
}

9
src/Form/DataTransformer/CommaSeparatedToArrayTransformer.php

@ -3,7 +3,6 @@ @@ -3,7 +3,6 @@
namespace App\Form\DataTransformer;
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Exception\TransformationFailedException;
class CommaSeparatedToArrayTransformer implements DataTransformerInterface
{
@ -19,10 +18,6 @@ class CommaSeparatedToArrayTransformer implements DataTransformerInterface @@ -19,10 +18,6 @@ class CommaSeparatedToArrayTransformer implements DataTransformerInterface
return '';
}
if (!is_array($array)) {
throw new TransformationFailedException('Expected an array.');
}
return implode(', ', $array);
}
@ -38,10 +33,6 @@ class CommaSeparatedToArrayTransformer implements DataTransformerInterface @@ -38,10 +33,6 @@ class CommaSeparatedToArrayTransformer implements DataTransformerInterface
return [];
}
if (!is_string($string)) {
throw new TransformationFailedException('Expected a string.');
}
// Split by commas, trim whitespace, and filter out empty values
$items = array_filter(array_map('trim', explode(',', $string)), function ($value) {
return $value !== '';

31
src/Nostr/MagazineEventKeys.php

@ -9,45 +9,58 @@ use App\Service\NostrKeyHelper; @@ -9,45 +9,58 @@ use App\Service\NostrKeyHelper;
/**
* Stable keys for {@see Event} rows: magazine root/category indices, kind-0 profiles, and legacy kind-30004
* curation keys still used by {@see \App\Service\Nip09DeletionApplier} to clean old MySQL rows.
*
* Magazine keys ({@see magazineRoot}, {@see magazineCategory}, {@see magazineCuration30004*}) are prefixed with
* {@see tenantPrefix()} so multiple deployments can share one MySQL. Profile/relay/payto keys stay global.
*/
final class MagazineEventKeys
{
public static function magazineCuration30004(string $npub, string $dTag): string
public static function tenantPrefix(string $magazineSlug): string
{
$s = strtolower(trim($magazineSlug));
if ($s === '' || !preg_match('/^[a-z0-9][a-z0-9-]{0,62}$/', $s)) {
return '';
}
return $s.':';
}
public static function magazineCuration30004(string $magazineSlug, string $npub, string $dTag): string
{
$hex = self::npubToHex($npub);
if ($hex === '') {
return '';
}
return 'mcur:'.$hex.':'.trim($dTag, " \0\x0B\t\n\r");
return self::tenantPrefix($magazineSlug).'mcur:'.$hex.':'.trim($dTag, " \0\x0B\t\n\r");
}
/**
* Same logical row as {@see magazineCuration30004} when `pubkeyHex64` is the site author (from an `a` tag address).
*/
public static function magazineCuration30004FromPubkeyHex(string $pubkeyHex64, string $dTag): string
public static function magazineCuration30004FromPubkeyHex(string $magazineSlug, string $pubkeyHex64, string $dTag): string
{
$pk = strtolower(trim($pubkeyHex64));
if (64 !== \strlen($pk) || !ctype_xdigit($pk)) {
return '';
}
return 'mcur:'.$pk.':'.trim($dTag, " \0\x0B\t\n\r");
return self::tenantPrefix($magazineSlug).'mcur:'.$pk.':'.trim($dTag, " \0\x0B\t\n\r");
}
public static function magazineRoot(string $npub, string $rootDTag): string
public static function magazineRoot(string $magazineSlug, string $npub, string $rootDTag): string
{
$hex = self::npubToHex($npub);
if ($hex === '') {
return '';
}
return 'mr:'.$hex.':'.trim($rootDTag, " \0\x0B\t\n\r");
return self::tenantPrefix($magazineSlug).'mr:'.$hex.':'.trim($rootDTag, " \0\x0B\t\n\r");
}
public static function magazineCategory(string $categoryDTag): string
public static function magazineCategory(string $magazineSlug, string $categoryDTag): string
{
return 'mc:'.trim($categoryDTag, " \0\x0B\t\n\r");
return self::tenantPrefix($magazineSlug).'mc:'.trim($categoryDTag, " \0\x0B\t\n\r");
}
public static function profileKind0(string $authorPubkeyHex64): string
@ -81,6 +94,6 @@ final class MagazineEventKeys @@ -81,6 +94,6 @@ final class MagazineEventKeys
$h = '';
}
return (\is_string($h) && 64 === \strlen($h) && ctype_xdigit($h)) ? strtolower($h) : '';
return (64 === \strlen($h) && ctype_xdigit($h)) ? strtolower($h) : '';
}
}

10
src/Nostr/Nip10Kind1ArticleReplyTags.php

@ -111,7 +111,7 @@ final class Nip10Kind1ArticleReplyTags @@ -111,7 +111,7 @@ final class Nip10Kind1ArticleReplyTags
{
$want = strtolower($eventIdHex);
foreach ($rawTags as $row) {
if (!\is_array($row) || \count($row) < 2) {
if (\count($row) < 2) {
continue;
}
if (strtolower((string) ($row[0] ?? '')) !== 'e') {
@ -141,7 +141,7 @@ final class Nip10Kind1ArticleReplyTags @@ -141,7 +141,7 @@ final class Nip10Kind1ArticleReplyTags
{
$out = [];
foreach ($rawTags as $row) {
if (!\is_array($row) || \count($row) < 2) {
if (\count($row) < 2) {
continue;
}
if (strtolower((string) ($row[0] ?? '')) !== 'p') {
@ -186,7 +186,7 @@ final class Nip10Kind1ArticleReplyTags @@ -186,7 +186,7 @@ final class Nip10Kind1ArticleReplyTags
};
foreach ($rawTags as $row) {
if (!\is_array($row) || !$isE($row) || \count($row) < 2) {
if (!$isE($row) || \count($row) < 2) {
continue;
}
if (($row[3] ?? '') === 'root') {
@ -199,7 +199,7 @@ final class Nip10Kind1ArticleReplyTags @@ -199,7 +199,7 @@ final class Nip10Kind1ArticleReplyTags
if ($articleHex !== null) {
foreach ($rawTags as $row) {
if (!\is_array($row) || !$isE($row) || \count($row) < 2) {
if (!$isE($row) || \count($row) < 2) {
continue;
}
$id = self::normEventId($row[1] ?? null);
@ -211,7 +211,7 @@ final class Nip10Kind1ArticleReplyTags @@ -211,7 +211,7 @@ final class Nip10Kind1ArticleReplyTags
$eIds = [];
foreach ($rawTags as $row) {
if (!\is_array($row) || !$isE($row) || \count($row) < 2) {
if (!$isE($row) || \count($row) < 2) {
continue;
}
$id = self::normEventId($row[1] ?? null);

8
src/Nostr/Nip19Codec.php

@ -32,7 +32,7 @@ final class Nip19Codec @@ -32,7 +32,7 @@ final class Nip19Codec
$out = new \stdClass();
if ($hrp === 'npub' || $hrp === 'nsec') {
if (!\is_array($raw) || !isset($raw[1]) || !\is_array($raw[1])) {
if (!isset($raw[1]) || !\is_array($raw[1])) {
throw new \RuntimeException('Unexpected npub/nsec decode shape');
}
$out->type = $hrp;
@ -48,7 +48,7 @@ final class Nip19Codec @@ -48,7 +48,7 @@ final class Nip19Codec
}
if ($hrp === 'note') {
if (!\is_array($raw) || !isset($raw['event_id']) || !\is_string($raw['event_id'])) {
if (!isset($raw['event_id']) || !\is_string($raw['event_id'])) {
throw new \RuntimeException('Unexpected note decode shape');
}
$out->type = 'note';
@ -60,10 +60,6 @@ final class Nip19Codec @@ -60,10 +60,6 @@ final class Nip19Codec
return $out;
}
if (!\is_array($raw)) {
throw new \RuntimeException('Unexpected NIP-19 decode shape');
}
$out->type = $hrp;
$d = new \stdClass();
if ($hrp === 'nprofile') {

4
src/Nostr/Nip22CommentTags.php

@ -19,7 +19,7 @@ final class Nip22CommentTags @@ -19,7 +19,7 @@ final class Nip22CommentTags
public static function forReplyToArticle(string $coordinate, string $articleAuthorPubkeyHex): array
{
$parts = explode(':', $coordinate, 2);
$k = \count($parts) >= 1 && ctype_digit((string) $parts[0]) ? (string) (int) $parts[0] : '30023';
$k = ctype_digit((string) $parts[0]) ? (string) (int) $parts[0] : '30023';
return [
['A', $coordinate, ''],
@ -90,7 +90,7 @@ final class Nip22CommentTags @@ -90,7 +90,7 @@ final class Nip22CommentTags
{
foreach ([$upper, $lower] as $n) {
foreach ($rawTags as $row) {
if (!\is_array($row) || ($row[0] ?? null) === null) {
if (($row[0] ?? null) === null) {
continue;
}
if ((string) $row[0] === $n) {

46
src/Repository/ArticleHighlightRepository.php

@ -7,6 +7,7 @@ namespace App\Repository; @@ -7,6 +7,7 @@ namespace App\Repository;
use App\Entity\Article;
use App\Entity\ArticleHighlight;
use App\Enum\EventStatusEnum;
use App\Service\TenantContext;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@ -15,14 +16,18 @@ use Doctrine\Persistence\ManagerRegistry; @@ -15,14 +16,18 @@ use Doctrine\Persistence\ManagerRegistry;
*/
class ArticleHighlightRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
public function __construct(
ManagerRegistry $registry,
private readonly TenantContext $tenant,
) {
parent::__construct($registry, ArticleHighlight::class);
}
/**
* Newest highlights across published/archived long-form, for the home aside.
* The home page caps the query (e.g. 100); the template scroller shows roughly ten at a time.
* At most one highlight is returned per article so a single heavily-highlighted
* article cannot flood the sidebar. The most recent highlight for each article
* wins (ORDER BY eventCreatedAt DESC).
*
* @return list<ArticleHighlight>
*/
@ -32,18 +37,49 @@ class ArticleHighlightRepository extends ServiceEntityRepository @@ -32,18 +37,49 @@ class ArticleHighlightRepository extends ServiceEntityRepository
return [];
}
// Fetch a larger pool so that after per-article deduplication we still
// have enough items to fill the sidebar. Cap the raw fetch at 2 000 to
// avoid unbounded memory use on busy sites.
$fetchLimit = min($limit * 20, 2000);
$qb = $this->createQueryBuilder('h')
->innerJoin('h.article', 'a')
->innerJoin(
'App\Entity\ArticleMagazine',
'am',
'WITH',
'am.article = a AND am.magazineSlug = :mag'
)
->where('a.eventStatus IN (:st)')
->setParameter('mag', $this->tenant->getMagazineSlug())
->setParameter('st', [EventStatusEnum::PUBLISHED, EventStatusEnum::ARCHIVED])
->orderBy('h.eventCreatedAt', 'DESC')
->addOrderBy('h.id', 'DESC')
->setMaxResults($limit);
->setMaxResults($fetchLimit);
/** @var list<ArticleHighlight> $rows */
$rows = $qb->getQuery()->getResult();
return $rows;
// Keep only the first (= most recent) highlight per article.
$seen = [];
$out = [];
foreach ($rows as $h) {
$article = $h->getArticle();
$articleId = $article?->getId();
if ($articleId === null) {
continue;
}
if (isset($seen[$articleId])) {
continue;
}
$seen[$articleId] = true;
$out[] = $h;
if (\count($out) >= $limit) {
break;
}
}
return $out;
}
/**

38
src/Repository/ArticleMagazineRepository.php

@ -0,0 +1,38 @@ @@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Article;
use App\Entity\ArticleMagazine;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ArticleMagazine>
*/
class ArticleMagazineRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ArticleMagazine::class);
}
public function link(string $magazineSlug, Article $article): void
{
$id = $article->getId();
if ($id === null) {
return;
}
$existing = $this->findOneBy([
'magazineSlug' => $magazineSlug,
'article' => $article,
]);
if ($existing !== null) {
return;
}
$this->getEntityManager()->persist(new ArticleMagazine($magazineSlug, $article));
$this->getEntityManager()->flush();
}
}

95
src/Repository/ArticleRepository.php

@ -5,15 +5,18 @@ namespace App\Repository; @@ -5,15 +5,18 @@ namespace App\Repository;
use App\Dto\FeaturedArticleCard;
use App\Entity\Article;
use App\Enum\EventStatusEnum;
use App\Service\TenantContext;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Exception;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
class ArticleRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
public function __construct(
ManagerRegistry $registry,
private readonly TenantContext $tenant,
) {
parent::__construct($registry, Article::class);
}
@ -22,7 +25,7 @@ class ArticleRepository extends ServiceEntityRepository @@ -22,7 +25,7 @@ class ArticleRepository extends ServiceEntityRepository
*/
public function searchArticles(string $query, int $limit = 12, int $offset = 0): array
{
$qb = $this->createQueryBuilder('a');
$qb = $this->tenantQueryBuilder('a');
$searchTerms = explode(' ', trim($query));
$conditions = $qb->expr()->orX();
@ -45,8 +48,6 @@ class ArticleRepository extends ServiceEntityRepository @@ -45,8 +48,6 @@ class ArticleRepository extends ServiceEntityRepository
return $qb
->where($conditions)
->andWhere('a.content IS NOT NULL')
->andWhere('LENGTH(a.content) > 250') // Only articles with substantial content
->orderBy('a.createdAt', 'DESC')
->setFirstResult($offset)
->setMaxResults($limit)
@ -56,7 +57,7 @@ class ArticleRepository extends ServiceEntityRepository @@ -56,7 +57,7 @@ class ArticleRepository extends ServiceEntityRepository
public function countSearchArticles(string $query): int
{
$qb = $this->createQueryBuilder('a')
$qb = $this->tenantQueryBuilder('a')
->select('COUNT(a.id)');
$searchTerms = explode(' ', trim($query));
@ -84,8 +85,6 @@ class ArticleRepository extends ServiceEntityRepository @@ -84,8 +85,6 @@ class ArticleRepository extends ServiceEntityRepository
return (int) $qb
->where($conditions)
->andWhere('a.content IS NOT NULL')
->andWhere('LENGTH(a.content) > 250')
->getQuery()
->getSingleScalarResult();
}
@ -106,7 +105,9 @@ class ArticleRepository extends ServiceEntityRepository @@ -106,7 +105,9 @@ class ArticleRepository extends ServiceEntityRepository
$qb
->select('a.id', 'a.slug', 'a.title', 'a.summary', 'a.image', 'a.created_at', 'a.published_at', 'a.pubkey')
->from('article', 'a')
->innerJoin('a', 'article_magazine', 'am', 'am.article_id = a.id AND am.magazine_slug = :mag')
->where($qb->expr()->in('a.slug', ':slugs'))
->setParameter('mag', $this->tenant->getMagazineSlug())
->setParameter('slugs', $slugs, ArrayParameterType::STRING)
->orderBy('a.created_at', 'DESC');
@ -145,7 +146,7 @@ class ArticleRepository extends ServiceEntityRepository @@ -145,7 +146,7 @@ class ArticleRepository extends ServiceEntityRepository
return [];
}
$qb = $this->createQueryBuilder('a');
$qb = $this->tenantQueryBuilder('a');
$orX = $qb->expr()->orX();
foreach ($pairs as $i => $p) {
$pkQ = strtolower((string) $p['pubkey']);
@ -173,13 +174,13 @@ class ArticleRepository extends ServiceEntityRepository @@ -173,13 +174,13 @@ class ArticleRepository extends ServiceEntityRepository
}
/**
* Distinct hex pubkeys for prewarming Nostr profile cache.
* Distinct hex pubkeys for prewarming Nostr profile cache (this magazine tenant only).
*
* @return list<string>
*/
public function findDistinctAuthorPubkeys(): array
{
return $this->createQueryBuilder('a')
return $this->tenantQueryBuilder('a')
->select('a.pubkey')
->distinct()
->where('a.pubkey IS NOT NULL')
@ -188,13 +189,14 @@ class ArticleRepository extends ServiceEntityRepository @@ -188,13 +189,14 @@ class ArticleRepository extends ServiceEntityRepository
->getSingleColumnResult();
}
/** Global lookup by Nostr event id (shared across magazine tenants). */
public function findOneByEventId(string $eventId): ?Article
{
return $this->findOneBy(['eventId' => $eventId]);
}
/**
* Newest row for a NIP-23/24 `d` value (replaceable long-form can leave multiple `article` rows per slug).
* Newest row for a NIP-23/24 `d` value linked to this magazine tenant.
*/
public function findLatestBySlug(string $slug): ?Article
{
@ -203,7 +205,7 @@ class ArticleRepository extends ServiceEntityRepository @@ -203,7 +205,7 @@ class ArticleRepository extends ServiceEntityRepository
return null;
}
return $this->createQueryBuilder('a')
return $this->tenantQueryBuilder('a')
->where('a.slug = :slug')
->setParameter('slug', $slug)
->orderBy('a.createdAt', 'DESC')
@ -212,23 +214,27 @@ class ArticleRepository extends ServiceEntityRepository @@ -212,23 +214,27 @@ class ArticleRepository extends ServiceEntityRepository
->getOneOrNullResult();
}
/**
* Find articles by author's public key
*/
public function findByPubkey(string $pubkey, int $limit = 25): array
public function findLatestBySlugForTenant(string $slug, string $npubHex): ?Article
{
return $this->createQueryBuilder('a')
->where('a.pubkey = :pubkey')
->setParameter('pubkey', $pubkey)
$slug = trim($slug);
if ($slug === '' || $npubHex === '') {
return null;
}
return $this->tenantQueryBuilder('a')
->where('a.slug = :slug')
->andWhere('LOWER(a.pubkey) = :pk')
->setParameter('slug', $slug)
->setParameter('pk', strtolower($npubHex))
->orderBy('a.createdAt', 'DESC')
->setMaxResults($limit)
->setMaxResults(1)
->getQuery()
->getResult();
->getOneOrNullResult();
}
public function findByPubkeyPaginated(string $pubkey, int $limit, int $offset): array
{
return $this->createQueryBuilder('a')
return $this->tenantQueryBuilder('a')
->where('a.pubkey = :pubkey')
->setParameter('pubkey', $pubkey)
->orderBy('a.createdAt', 'DESC')
@ -240,7 +246,7 @@ class ArticleRepository extends ServiceEntityRepository @@ -240,7 +246,7 @@ class ArticleRepository extends ServiceEntityRepository
public function countByPubkey(string $pubkey): int
{
return (int) $this->createQueryBuilder('a')
return (int) $this->tenantQueryBuilder('a')
->select('COUNT(a.id)')
->where('a.pubkey = :pubkey')
->setParameter('pubkey', $pubkey)
@ -248,6 +254,27 @@ class ArticleRepository extends ServiceEntityRepository @@ -248,6 +254,27 @@ class ArticleRepository extends ServiceEntityRepository
->getSingleScalarResult();
}
public function countForMagazine(): int
{
return (int) $this->tenantQueryBuilder('a')
->select('COUNT(a.id)')
->getQuery()
->getSingleScalarResult();
}
/**
* @return list<Article>
*/
public function findForMagazinePaginated(int $limit, int $offset): array
{
return $this->tenantQueryBuilder('a')
->orderBy('a.createdAt', 'DESC')
->setFirstResult($offset)
->setMaxResults($limit)
->getQuery()
->getResult();
}
/**
* Published or archived long-form rows for sitemap/Atom (may include multiple rows per slug);
* callers should dedupe by slug if URLs are slug-only.
@ -256,7 +283,7 @@ class ArticleRepository extends ServiceEntityRepository @@ -256,7 +283,7 @@ class ArticleRepository extends ServiceEntityRepository
*/
public function findPublishedForSyndication(int $limit = 5000): array
{
return $this->createQueryBuilder('a')
return $this->tenantQueryBuilder('a')
->where('a.slug IS NOT NULL')
->andWhere("TRIM(a.slug) != ''")
->andWhere('a.eventStatus IN (:st)')
@ -299,7 +326,7 @@ class ArticleRepository extends ServiceEntityRepository @@ -299,7 +326,7 @@ class ArticleRepository extends ServiceEntityRepository
if ($topicKey === '') {
return [];
}
$qb = $this->createQueryBuilder('a')
$qb = $this->tenantQueryBuilder('a')
->where('a.topics IS NOT NULL')
->andWhere('a.content IS NOT NULL')
->andWhere('LENGTH(a.content) > 250')
@ -350,4 +377,18 @@ class ArticleRepository extends ServiceEntityRepository @@ -350,4 +377,18 @@ class ArticleRepository extends ServiceEntityRepository
return \trim($t);
}
private function tenantQueryBuilder(string $alias = 'a'): QueryBuilder
{
$qb = $this->createQueryBuilder($alias);
$qb->innerJoin(
'App\Entity\ArticleMagazine',
'am',
'WITH',
'am.article = '.$alias.' AND am.magazineSlug = :_magazine_slug'
);
$qb->setParameter('_magazine_slug', $this->tenant->getMagazineSlug());
return $qb;
}
}

31
src/Repository/EventRepository.php

@ -22,4 +22,35 @@ class EventRepository extends ServiceEntityRepository @@ -22,4 +22,35 @@ class EventRepository extends ServiceEntityRepository
{
return $this->findOneBy(['coreRowKey' => $key]);
}
/**
* @param list<string> $keys
*
* @return array<string, Event> keyed by coreRowKey
*/
public function findByCoreRowKeys(array $keys): array
{
$keys = array_values(array_unique(array_filter(
$keys,
static fn (mixed $k): bool => $k !== '',
)));
if ($keys === []) {
return [];
}
/** @var list<Event> $rows */
$rows = $this->createQueryBuilder('e')
->andWhere('e.coreRowKey IN (:keys)')
->setParameter('keys', $keys)
->getQuery()
->getResult();
$out = [];
foreach ($rows as $row) {
$k = $row->getCoreRowKey();
if ($k !== null && $k !== '') {
$out[$k] = $row;
}
}
return $out;
}
}

39
src/Repository/FeaturedAuthorRepository.php

@ -5,6 +5,7 @@ declare(strict_types=1); @@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Repository;
use App\Entity\FeaturedAuthor;
use App\Service\TenantContext;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@ -13,8 +14,10 @@ use Doctrine\Persistence\ManagerRegistry; @@ -13,8 +14,10 @@ use Doctrine\Persistence\ManagerRegistry;
*/
class FeaturedAuthorRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
public function __construct(
ManagerRegistry $registry,
private readonly TenantContext $tenant,
) {
parent::__construct($registry, FeaturedAuthor::class);
}
@ -22,14 +25,19 @@ class FeaturedAuthorRepository extends ServiceEntityRepository @@ -22,14 +25,19 @@ class FeaturedAuthorRepository extends ServiceEntityRepository
{
$h = strtolower($pubkeyHex);
return $this->findOneBy(['pubkeyHex' => $h]);
return $this->findOneBy([
'magazineSlug' => $this->tenant->getMagazineSlug(),
'pubkeyHex' => $h,
]);
}
public function isLocalPartTaken(string $localPart, ?int $exceptId = null): bool
{
$qb = $this->createQueryBuilder('f')
->select('COUNT(f.id)')
->where('f.localPart = :lp')
->where('f.magazineSlug = :mag')
->andWhere('f.localPart = :lp')
->setParameter('mag', $this->tenant->getMagazineSlug())
->setParameter('lp', $localPart);
if ($exceptId !== null) {
$qb->andWhere('f.id != :eid')->setParameter('eid', $exceptId);
@ -44,7 +52,9 @@ class FeaturedAuthorRepository extends ServiceEntityRepository @@ -44,7 +52,9 @@ class FeaturedAuthorRepository extends ServiceEntityRepository
public function findAllListedOrderByLocalPart(): array
{
return $this->createQueryBuilder('f')
->where('f.isListed = :t')
->where('f.magazineSlug = :mag')
->andWhere('f.isListed = :t')
->setParameter('mag', $this->tenant->getMagazineSlug())
->setParameter('t', true)
->orderBy('f.localPart', 'ASC')
->getQuery()
@ -60,7 +70,9 @@ class FeaturedAuthorRepository extends ServiceEntityRepository @@ -60,7 +70,9 @@ class FeaturedAuthorRepository extends ServiceEntityRepository
public function findListedMostRecentlyAdded(int $limit, int $offset = 0): array
{
return $this->createQueryBuilder('f')
->where('f.isListed = :t')
->where('f.magazineSlug = :mag')
->andWhere('f.isListed = :t')
->setParameter('mag', $this->tenant->getMagazineSlug())
->setParameter('t', true)
->orderBy('f.createdAt', 'DESC')
->addOrderBy('f.id', 'DESC')
@ -76,7 +88,9 @@ class FeaturedAuthorRepository extends ServiceEntityRepository @@ -76,7 +88,9 @@ class FeaturedAuthorRepository extends ServiceEntityRepository
public function findListedOrderByLocalPartPaginated(int $limit, int $offset): array
{
return $this->createQueryBuilder('f')
->where('f.isListed = :t')
->where('f.magazineSlug = :mag')
->andWhere('f.isListed = :t')
->setParameter('mag', $this->tenant->getMagazineSlug())
->setParameter('t', true)
->orderBy('f.localPart', 'ASC')
->setFirstResult($offset)
@ -89,10 +103,19 @@ class FeaturedAuthorRepository extends ServiceEntityRepository @@ -89,10 +103,19 @@ class FeaturedAuthorRepository extends ServiceEntityRepository
{
return (int) $this->createQueryBuilder('f')
->select('COUNT(f.id)')
->where('f.isListed = :t')
->where('f.magazineSlug = :mag')
->andWhere('f.isListed = :t')
->setParameter('mag', $this->tenant->getMagazineSlug())
->setParameter('t', true)
->getQuery()
->getSingleScalarResult();
}
/**
* @return list<FeaturedAuthor>
*/
public function findAllForTenant(): array
{
return $this->findBy(['magazineSlug' => $this->tenant->getMagazineSlug()]);
}
}

24
src/Repository/UserEntityRepository.php

@ -3,26 +3,24 @@ @@ -3,26 +3,24 @@
namespace App\Repository;
use App\Entity\User;
use App\Service\TenantContext;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
class UserEntityRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry, private readonly EntityManagerInterface $entityManager)
{
public function __construct(
ManagerRegistry $registry,
private readonly TenantContext $tenant,
) {
parent::__construct($registry, User::class);
}
public function findOrCreateByUniqueField(User $user): User
{
$entity = $this->findOneBy(['npub' => $user->getNpub()]);
if ($entity !== null) {
$user->setId($entity->getId());
} else {
$this->entityManager->persist($user);
}
return $user;
public function findOneByNpub(string $npub): ?User
{
return $this->findOneBy([
'magazineSlug' => $this->tenant->getMagazineSlug(),
'npub' => $npub,
]);
}
}

28
src/Security/UserDTOProvider.php

@ -3,7 +3,9 @@ @@ -3,7 +3,9 @@
namespace App\Security;
use App\Entity\User;
use App\Repository\UserEntityRepository;
use App\Service\CacheService;
use App\Service\TenantContext;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Security\Core\User\UserInterface;
@ -19,10 +21,11 @@ readonly class UserDTOProvider implements UserProviderInterface @@ -19,10 +21,11 @@ readonly class UserDTOProvider implements UserProviderInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private CacheService $cacheService,
private LoggerInterface $logger
)
{
private UserEntityRepository $userRepository,
private CacheService $cacheService,
private LoggerInterface $logger,
private TenantContext $tenant,
) {
}
/**
@ -38,10 +41,13 @@ readonly class UserDTOProvider implements UserProviderInterface @@ -38,10 +41,13 @@ readonly class UserDTOProvider implements UserProviderInterface
throw new \InvalidArgumentException('Invalid user type.');
}
$this->logger->info('Refresh user.', ['user' => $user->getUserIdentifier()]);
$freshUser = $this->entityManager->getRepository(User::class)
->findOneBy(['npub' => $user->getUserIdentifier()]);
$freshUser = $this->userRepository->findOneByNpub($user->getUserIdentifier());
if ($freshUser === null) {
throw new \InvalidArgumentException('User not found for this magazine tenant.');
}
$metadata = $this->cacheService->getMetadata($user->getUserIdentifier());
$freshUser->setMetadata($metadata);
return $freshUser;
}
@ -50,12 +56,6 @@ readonly class UserDTOProvider implements UserProviderInterface @@ -50,12 +56,6 @@ readonly class UserDTOProvider implements UserProviderInterface
*/
public function supportsClass(string $class): bool
{
/**
* Checks if the provider supports the given user class.
*
* @param string $class The class name to check.
* @return bool True if the class is supported, false otherwise.
*/
return $class === User::class;
}
@ -65,11 +65,11 @@ readonly class UserDTOProvider implements UserProviderInterface @@ -65,11 +65,11 @@ readonly class UserDTOProvider implements UserProviderInterface
public function loadUserByIdentifier(string $identifier): UserInterface
{
$this->logger->info('Load user by identifier.', ['identifier' => $identifier]);
// Get or create user
$user = $this->entityManager->getRepository(User::class)->findOneBy(['npub' => $identifier]);
$user = $this->userRepository->findOneByNpub($identifier);
if (!$user) {
$user = new User();
$user->setMagazineSlug($this->tenant->getMagazineSlug());
$user->setNpub($identifier);
$this->entityManager->persist($user);
$this->entityManager->flush();

26
src/Service/ArticleBodyHighlightInjector.php

@ -167,9 +167,6 @@ final class ArticleBodyHighlightInjector @@ -167,9 +167,6 @@ final class ArticleBodyHighlightInjector
$stack[] = $this->dom->documentElement;
while ($stack !== []) {
$el = \array_pop($stack);
if (! $el instanceof DOMElement) {
continue;
}
if ($el->getAttribute('id') === $id) {
return $el;
}
@ -260,9 +257,6 @@ final class ArticleBodyHighlightInjector @@ -260,9 +257,6 @@ final class ArticleBodyHighlightInjector
continue;
}
$span = $this->dom->createElement('span');
if (false === $span) {
continue;
}
$span->setAttribute('id', 'highlight-'.$other);
$span->setAttribute('class', 'user-highlight__fragment-target');
$span->setAttribute('aria-hidden', 'true');
@ -320,8 +314,8 @@ final class ArticleBodyHighlightInjector @@ -320,8 +314,8 @@ final class ArticleBodyHighlightInjector
\usort(
$groups,
static function (array $a, array $b): int {
$ta = $a[0] instanceof ArticleHighlight ? $a[0]->getEventCreatedAt() : 0;
$tb = $b[0] instanceof ArticleHighlight ? $b[0]->getEventCreatedAt() : 0;
$ta = $a[0]->getEventCreatedAt();
$tb = $b[0]->getEventCreatedAt();
return $ta <=> $tb;
}
@ -338,6 +332,19 @@ final class ArticleBodyHighlightInjector @@ -338,6 +332,19 @@ final class ArticleBodyHighlightInjector
*/
private function buildHighlightAuthorsJson(array $group): string
{
$npubsForPrefetch = [];
foreach ($group as $h) {
$pk = $h->getAuthorPubkey();
if (64 !== \strlen($pk) || !ctype_xdigit($pk)) {
continue;
}
try {
$npubsForPrefetch[] = $this->nostrKeyHelper->convertPublicKeyToBech32($pk);
} catch (\Throwable) {
}
}
$this->highlightAuthorMetadata->prefetchMetadataForNpubs($npubsForPrefetch);
$byNpub = [];
foreach ($group as $h) {
$eidH = $h->getEventId();
@ -695,9 +702,6 @@ final class ArticleBodyHighlightInjector @@ -695,9 +702,6 @@ final class ArticleBodyHighlightInjector
$parent->insertBefore($this->dom->createTextNode($before), $ref);
}
$mark = $this->dom->createElement('mark');
if (! $mark) {
return false;
}
$mark->setAttribute('class', 'user-highlight__marker');
if ($firstInReadingOrder) {
$mark->setAttribute('id', 'highlight-'.$eventId);

29
src/Service/ArticleCommentThreadLoader.php

@ -39,6 +39,7 @@ final readonly class ArticleCommentThreadLoader @@ -39,6 +39,7 @@ final readonly class ArticleCommentThreadLoader
* @return array{
* list: array<int, object>,
* quotes: array<int, object>,
* superchats: list<array<string,mixed>>,
* commentLinks: array<string, array<int, mixed>>,
* quoteLinks: array<string, array<int, mixed>>,
* processedContent: array<string, string>
@ -46,6 +47,7 @@ final readonly class ArticleCommentThreadLoader @@ -46,6 +47,7 @@ final readonly class ArticleCommentThreadLoader
*
* Each object in `list` may be enriched with: unfold_reply_blurb, unfold_body, unfold_depth
* (0–3, for UI indentation).
* `superchats` contains attested NIP-A3 kind-9740 items sorted by amount desc.
*/
public function tryLoadFromCacheOnly(string $coordinate, ?string $articleEventHexId = null): ?array
{
@ -78,6 +80,7 @@ final readonly class ArticleCommentThreadLoader @@ -78,6 +80,7 @@ final readonly class ArticleCommentThreadLoader
* @return array{
* list: array<int, object>,
* quotes: array<int, object>,
* superchats: list<array<string,mixed>>,
* commentLinks: array<string, array<int, mixed>>,
* quoteLinks: array<string, array<int, mixed>>,
* processedContent: array<string, string>
@ -108,8 +111,8 @@ final readonly class ArticleCommentThreadLoader @@ -108,8 +111,8 @@ final readonly class ArticleCommentThreadLoader
$item->expiresAfter($partial ? self::PARTIAL_THREAD_CACHE_TTL_SEC : 86400);
$this->logger->info('comments.loader.nostr_ok', [
'nostr_elapsed_ms' => (int) round((microtime(true) - $tNostr) * 1000),
'thread' => \count($out['thread'] ?? []),
'quotes' => \count($out['quotes'] ?? []),
'thread' => \count($out['thread']),
'quotes' => \count($out['quotes']),
'partial' => $partial,
]);
@ -148,11 +151,12 @@ final readonly class ArticleCommentThreadLoader @@ -148,11 +151,12 @@ final readonly class ArticleCommentThreadLoader
}
/**
* @param array{thread: array<int, object>, quotes: array<int, object>} $discussion
* @param array{thread: array<int, object>, quotes: array<int, object>, superchats?: list<array<string,mixed>>, partial?: bool} $discussion
*
* @return array{
* list: array<int, object>,
* quotes: array<int, object>,
* superchats: list<array<string,mixed>>,
* commentLinks: array<string, array<int, mixed>>,
* quoteLinks: array<string, array<int, mixed>>,
* processedContent: array<string, string>
@ -160,12 +164,14 @@ final readonly class ArticleCommentThreadLoader @@ -160,12 +164,14 @@ final readonly class ArticleCommentThreadLoader
*/
private function expandFromDiscussion(array $discussion, float $t0, ?string $articleEventHexId = null): array
{
$list = $discussion['thread'] ?? [];
$quotes = $discussion['quotes'] ?? [];
$list = $discussion['thread'];
$quotes = $discussion['quotes'];
$superchats = $discussion['superchats'] ?? [];
$this->logger->info('comments.loader.cache_resolved', [
'elapsed_since_start_ms' => (int) round((microtime(true) - $t0) * 1000),
'thread_events' => \count($list),
'quote_events' => \count($quotes),
'superchat_count' => \count($superchats),
]);
$this->enrichThreadListForDisplay($list, $articleEventHexId);
@ -196,6 +202,7 @@ final readonly class ArticleCommentThreadLoader @@ -196,6 +202,7 @@ final readonly class ArticleCommentThreadLoader
return [
'list' => $list,
'quotes' => $quotes,
'superchats' => $discussion['superchats'] ?? [],
'commentLinks' => $commentLinks,
'quoteLinks' => $quoteLinks,
'processedContent' => $processedContent,
@ -225,14 +232,10 @@ final readonly class ArticleCommentThreadLoader @@ -225,14 +232,10 @@ final readonly class ArticleCommentThreadLoader
}
};
foreach ($list as $ev) {
if (\is_object($ev)) {
$strip($ev);
}
$strip($ev);
}
foreach ($quotes as $ev) {
if (\is_object($ev)) {
$strip($ev);
}
$strip($ev);
}
}
@ -361,7 +364,7 @@ final readonly class ArticleCommentThreadLoader @@ -361,7 +364,7 @@ final readonly class ArticleCommentThreadLoader
return ['blurb' => null, 'body' => $content];
}
$parts = explode("\n\n", $content, 2);
$first = trim((string) ($parts[0] ?? ''));
$first = trim($parts[0]);
$rest = (string) ($parts[1] ?? '');
if ($first === '' || !str_starts_with($first, '>')) {
return ['blurb' => null, 'body' => $content];
@ -397,7 +400,7 @@ final readonly class ArticleCommentThreadLoader @@ -397,7 +400,7 @@ final readonly class ArticleCommentThreadLoader
continue;
}
$kind = ctype_digit((string) $parts[0]) ? (int) $parts[0] : 0;
if (!\in_array($kind, [30023, 30024], true)) {
if (!\in_array($kind, KindsEnum::longformKindValues(), true)) {
continue;
}
$dTag = trim((string) $parts[2]);

25
src/Service/ArticleMagazineRegistry.php

@ -0,0 +1,25 @@ @@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Article;
use App\Repository\ArticleMagazineRepository;
/**
* Associates ingested long-form rows with the current magazine tenant on a shared database.
*/
final class ArticleMagazineRegistry
{
public function __construct(
private readonly TenantContext $tenant,
private readonly ArticleMagazineRepository $articleMagazineRepository,
) {
}
public function link(Article $article): void
{
$this->articleMagazineRepository->link($this->tenant->getMagazineSlug(), $article);
}
}

9
src/Service/ArticleWorkflowService.php

@ -1,9 +0,0 @@ @@ -1,9 +0,0 @@
<?php
namespace App\Service;
class ArticleWorkflowService
{
}

219
src/Service/CacheService.php

@ -9,20 +9,37 @@ use App\Nostr\MagazineEventKeys; @@ -9,20 +9,37 @@ use App\Nostr\MagazineEventKeys;
use App\Repository\EventRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Service\ResetInterface;
readonly class CacheService implements HighlightAuthorMetadataProvider
final class CacheService implements HighlightAuthorMetadataProvider, ResetInterface
{
/**
* @var array<string, array{content: \stdClass, kind0_tags: list<list<string>>, nip30_custom_emojis: list<array{shortcode: string, url: string, set?: string}>}>
*/
private array $requestBundlesByHex = [];
/** @var array<string, string> lowercase hex pubkey => npub */
private array $pendingHexToNpub = [];
private int $metadataBatchDepth = 0;
public function __construct(
private NostrClient $nostrClient,
private EntityManagerInterface $entityManager,
private EventRepository $eventRepository,
private LoggerInterface $logger,
private NostrKeyHelper $nostrKeyHelper,
private NostrNip65RelayUrls $nip65RelayUrls,
private Nip30EmojiCatalogBuilder $nip30EmojiCatalogBuilder,
) {
}
public function reset(): void
{
$this->requestBundlesByHex = [];
$this->pendingHexToNpub = [];
$this->metadataBatchDepth = 0;
}
public function getMetadata(string $npub): \stdClass
{
return $this->getMetadataBundle($npub)['content'];
@ -37,41 +54,126 @@ readonly class CacheService implements HighlightAuthorMetadataProvider @@ -37,41 +54,126 @@ readonly class CacheService implements HighlightAuthorMetadataProvider
if ($authorHex === null) {
return $this->placeholderMetadataBundle($npub);
}
if (isset($this->requestBundlesByHex[$authorHex])) {
return $this->requestBundlesByHex[$authorHex];
}
$row = $this->eventRepository->findOneByCoreRowKey(MagazineEventKeys::profileKind0($authorHex));
if ($row !== null) {
return $this->bundleFromKind0EventRow($row, $npub);
$bundle = $this->bundleFromKind0EventRow($row, $npub);
$this->requestBundlesByHex[$authorHex] = $bundle;
return $bundle;
}
try {
$ev = $this->nostrClient->getNpubMetadata($npub);
if (!\is_object($ev)) {
return $this->placeholderMetadataBundle($npub);
$this->pendingHexToNpub[$authorHex] = $npub;
$this->runPendingMetadataBatch();
return $this->requestBundlesByHex[$authorHex] ?? $this->placeholderMetadataBundle($npub);
}
/**
* @param list<string> $npubs
*/
public function prefetchMetadataForNpubs(array $npubs): void
{
foreach ($npubs as $npub) {
if ($npub === '') {
continue;
}
$nip30 = $this->nip30EmojiCatalogBuilder->buildMergedCatalog($ev, null, []);
$this->replaceByCoreKey(
MagazineEventKeys::profileKind0($authorHex),
Event::STORAGE_PROFILE_KIND0,
$ev,
$nip30,
);
$tags = self::normalizeEventTagsList($ev->tags ?? null);
$content = $this->decodeKind0ContentObject($ev);
if ($this->isPlaceholderContent($content, $npub)) {
$content = $this->namePlaceholderNpubObject($npub);
$authorHex = $this->npubToAuthorHex64($npub);
if ($authorHex === null || isset($this->requestBundlesByHex[$authorHex])) {
continue;
}
$this->pendingHexToNpub[$authorHex] = $npub;
}
$this->runPendingMetadataBatch();
}
return [
'content' => $content,
'kind0_tags' => $tags,
'nip30_custom_emojis' => $nip30,
];
} catch (\Exception $e) {
$this->logger->warning('Profile metadata fetch failed; using npub placeholder.', [
'npub' => $npub,
'exception' => $e->getPrevious() ?? $e,
/**
* @param list<string> $pubkeyHex 64-char hex pubkeys (any case)
*/
public function prefetchMetadataForPubkeyHexes(array $pubkeyHex): void
{
$npubs = [];
foreach ($pubkeyHex as $hex) {
if (64 !== \strlen($hex) || !ctype_xdigit($hex)) {
continue;
}
try {
$npubs[] = $this->nostrKeyHelper->convertPublicKeyToBech32(strtolower($hex));
} catch (\Throwable) {
}
}
$this->prefetchMetadataForNpubs($npubs);
}
private function runPendingMetadataBatch(): void
{
if ($this->pendingHexToNpub === []) {
return;
}
++$this->metadataBatchDepth;
try {
do {
$this->flushPendingMetadataFetches();
} while ($this->pendingHexToNpub !== []);
} finally {
--$this->metadataBatchDepth;
}
}
private function flushPendingMetadataFetches(): void
{
if ($this->pendingHexToNpub === []) {
return;
}
$pending = $this->pendingHexToNpub;
$this->pendingHexToNpub = [];
$keys = [];
foreach (array_keys($pending) as $hex) {
$keys[] = MagazineEventKeys::profileKind0($hex);
}
$rowsByKey = $this->eventRepository->findByCoreRowKeys($keys);
$relayHex = [];
foreach ($pending as $hex => $npub) {
$key = MagazineEventKeys::profileKind0($hex);
if (isset($rowsByKey[$key])) {
$this->requestBundlesByHex[$hex] = $this->bundleFromKind0EventRow($rowsByKey[$key], $npub);
continue;
}
$relayHex[] = $hex;
}
if ($relayHex === []) {
return;
}
try {
$fetched = $this->nostrClient->fetchProfilePrewarmWireBundlesForAuthors($relayHex);
$this->putPrewarmMetadataBatch($relayHex, $fetched);
} catch (\Throwable $e) {
$this->logger->warning('Profile metadata batch fetch failed.', [
'authors' => \count($relayHex),
'exception' => $e,
]);
}
return $this->placeholderMetadataBundle($npub);
$rowsAfterRelay = $this->eventRepository->findByCoreRowKeys(array_map(
static fn (string $hex): string => MagazineEventKeys::profileKind0($hex),
$relayHex,
));
foreach ($relayHex as $hex) {
$npub = $pending[$hex];
$key = MagazineEventKeys::profileKind0($hex);
if (isset($rowsAfterRelay[$key])) {
$this->requestBundlesByHex[$hex] = $this->bundleFromKind0EventRow($rowsAfterRelay[$key], $npub);
continue;
}
$this->logger->info('Profile metadata fetch failed; using npub placeholder.', [
'npub' => $npub,
]);
$this->requestBundlesByHex[$hex] = $this->placeholderMetadataBundle($npub);
}
}
/**
@ -88,7 +190,7 @@ readonly class CacheService implements HighlightAuthorMetadataProvider @@ -88,7 +190,7 @@ readonly class CacheService implements HighlightAuthorMetadataProvider
continue;
}
$h = strtolower($hex);
if (!isset($bundlesByLowerHex[$h]) || !\is_array($bundlesByLowerHex[$h])) {
if (!isset($bundlesByLowerHex[$h])) {
continue;
}
$bundle = $bundlesByLowerHex[$h];
@ -117,26 +219,6 @@ readonly class CacheService implements HighlightAuthorMetadataProvider @@ -117,26 +219,6 @@ readonly class CacheService implements HighlightAuthorMetadataProvider
return $n;
}
public function getRelays($npub)
{
$authorHex = $this->npubToAuthorHex64($npub);
if ($authorHex === null) {
return [];
}
$key = MagazineEventKeys::relayList10002($authorHex);
$row = $this->eventRepository->findOneByCoreRowKey($key);
if ($row !== null) {
return self::relayWssListFromNip65Tags($row->getTags());
}
$wire = $this->nostrClient->getNpubRelayList10002Wire($npub);
if ($wire === null) {
return [];
}
$this->replaceByCoreKey($key, Event::STORAGE_RELAY_LIST_10002, $wire);
return $this->nip65RelayUrls->wssListFromKind10002Wire($wire);
}
/**
* @return list<list<string>>
*/
@ -154,15 +236,11 @@ readonly class CacheService implements HighlightAuthorMetadataProvider @@ -154,15 +236,11 @@ readonly class CacheService implements HighlightAuthorMetadataProvider
if ($seq === []) {
continue;
}
$r = array_values(
array_map(
static fn (mixed $v): string => (string) $v,
array_values($seq)
)
$r = array_map(
static fn (mixed $v): string => (string) $v,
array_values($seq)
);
if ($r !== [] && (string) ($r[0] ?? '') !== '') {
$out[] = $r;
}
$out[] = $r;
}
return $out;
@ -267,7 +345,7 @@ readonly class CacheService implements HighlightAuthorMetadataProvider @@ -267,7 +345,7 @@ readonly class CacheService implements HighlightAuthorMetadataProvider
private function bundleFromKind0EventRow(Event $row, string $npub): array
{
$content = $this->decodeKind0ContentString($row->getContent());
if (!\is_object($content) || $this->isPlaceholderContent($content, $npub)) {
if ($this->isPlaceholderContent($content, $npub)) {
$content = $this->namePlaceholderNpubObject($npub);
}
@ -283,11 +361,6 @@ readonly class CacheService implements HighlightAuthorMetadataProvider @@ -283,11 +361,6 @@ readonly class CacheService implements HighlightAuthorMetadataProvider
];
}
private function decodeKind0ContentObject(object $ev): \stdClass
{
return $this->decodeKind0ContentString((string) ($ev->content ?? ''));
}
private function decodeKind0ContentString(string $raw): \stdClass
{
try {
@ -326,24 +399,4 @@ readonly class CacheService implements HighlightAuthorMetadataProvider @@ -326,24 +399,4 @@ readonly class CacheService implements HighlightAuthorMetadataProvider
];
}
/**
* @param list<list<string>>|array $tags
* @return list<string>
*/
private static function relayWssListFromNip65Tags(array $tags): array
{
$relays = [];
foreach ($tags as $tag) {
if (!\is_array($tag) || !isset($tag[0], $tag[1])) {
continue;
}
if ((string) $tag[0] === 'r') {
$relays[] = (string) $tag[1];
}
}
return array_filter(array_unique($relays), static function (string $relay) {
return str_starts_with($relay, 'wss:') && !str_contains($relay, 'localhost');
});
}
}

4
src/Service/CommentReplyService.php

@ -91,7 +91,7 @@ final readonly class CommentReplyService @@ -91,7 +91,7 @@ final readonly class CommentReplyService
: '';
$clientParentOk = 64 === \strlen($rawParentAuthor) && ctype_xdigit($rawParentAuthor);
$coordBits = explode(':', $expectedCoordinate, 3);
$articleAuthor = \count($coordBits) >= 2 ? strtolower((string) $coordBits[1]) : '';
$articleAuthor = strtolower((string) $coordBits[1]);
$articleAuthorOk = 64 === \strlen($articleAuthor) && ctype_xdigit($articleAuthor);
if (\in_array((int) $parentKind, [KindsEnum::COMMENTS->value, KindsEnum::TEXT_NOTE->value], true)) {
@ -205,7 +205,7 @@ final readonly class CommentReplyService @@ -205,7 +205,7 @@ final readonly class CommentReplyService
int $parentKind,
string $parentIdHex
): bool {
if (\in_array($parentKind, [KindsEnum::LONGFORM->value, KindsEnum::LONGFORM_DRAFT->value], true)) {
if (\in_array($parentKind, KindsEnum::longformKindValues(), true)) {
foreach ($tags as $row) {
if (!\is_array($row) || ($row[0] ?? null) === null) {
continue;

14
src/Service/FeaturedAuthorListedRows.php

@ -33,9 +33,11 @@ final class FeaturedAuthorListedRows @@ -33,9 +33,11 @@ final class FeaturedAuthorListedRows
return $fromDb;
}
$hexes = \array_slice($this->magazineContent->getAllDistinctCategoryAuthorPubkeyHexes(), 0, $limit);
$this->cacheService->prefetchMetadataForPubkeyHexes($hexes);
$authors = [];
$hexes = $this->magazineContent->getAllDistinctCategoryAuthorPubkeyHexes();
foreach (\array_slice($hexes, 0, $limit) as $hex) {
foreach ($hexes as $hex) {
try {
$npub = $this->nostrKeyHelper->convertPublicKeyToBech32($hex);
} catch (\Throwable) {
@ -52,8 +54,14 @@ final class FeaturedAuthorListedRows @@ -52,8 +54,14 @@ final class FeaturedAuthorListedRows
*/
public function buildListedByLocalPartPage(int $limit, int $offset = 0): array
{
$listed = $this->featuredAuthorRepository->findListedOrderByLocalPartPaginated($limit, $offset);
$this->cacheService->prefetchMetadataForPubkeyHexes(array_map(
static fn ($fa) => $fa->getPubkeyHex(),
$listed,
));
$authors = [];
foreach ($this->featuredAuthorRepository->findListedOrderByLocalPartPaginated($limit, $offset) as $fa) {
foreach ($listed as $fa) {
try {
$npub = $this->nostrKeyHelper->convertPublicKeyToBech32($fa->getPubkeyHex());
} catch (\Throwable) {

4
src/Service/FeaturedAuthorSync.php

@ -22,6 +22,7 @@ final class FeaturedAuthorSync @@ -22,6 +22,7 @@ final class FeaturedAuthorSync
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
private readonly NostrKeyHelper $nostrKeyHelper,
private readonly TenantContext $tenant,
) {
}
@ -40,7 +41,7 @@ final class FeaturedAuthorSync @@ -40,7 +41,7 @@ final class FeaturedAuthorSync
}
$existingByPubkey = [];
foreach ($this->featuredAuthorRepository->findAll() as $row) {
foreach ($this->featuredAuthorRepository->findAllForTenant() as $row) {
$existingByPubkey[strtolower($row->getPubkeyHex())] = $row;
}
$added = 0;
@ -52,6 +53,7 @@ final class FeaturedAuthorSync @@ -52,6 +53,7 @@ final class FeaturedAuthorSync
$row = $existingByPubkey[$hex] ?? null;
if ($row === null) {
$entity = new FeaturedAuthor();
$entity->setMagazineSlug($this->tenant->getMagazineSlug());
$entity->setPubkeyHex($hex);
$base = $this->deriveBaseLocalPart($hex);
$entity->setLocalPart($this->allocateUniqueLocalPart($base));

5
src/Service/HighlightAuthorMetadataProvider.php

@ -10,4 +10,9 @@ namespace App\Service; @@ -10,4 +10,9 @@ namespace App\Service;
interface HighlightAuthorMetadataProvider
{
public function getMetadata(string $npub): \stdClass;
/**
* @param list<string> $npubs
*/
public function prefetchMetadataForNpubs(array $npubs): void;
}

5
src/Service/HighlightSyncService.php

@ -41,15 +41,12 @@ final class HighlightSyncService @@ -41,15 +41,12 @@ final class HighlightSyncService
return 0;
}
$kind = $article->getKind()?->value ?? 30023;
$kind = $article->getKind()->value;
$coordinate = $kind.':'.$pubkey.':'.$slug;
$events = $this->nostrClient->fetchHighlightEventsForArticle($coordinate);
$n = 0;
foreach ($events as $ev) {
if (!\is_object($ev)) {
continue;
}
if ((int) ($ev->kind ?? 0) !== KindsEnum::HIGHLIGHTS->value) {
continue;
}

203
src/Service/MagazineContentService.php

@ -91,7 +91,7 @@ final class MagazineContentService @@ -91,7 +91,7 @@ final class MagazineContentService
if (\count($parts) < 3) {
continue;
}
if ((int) ($parts[0] ?? 0) !== KindsEnum::PUBLICATION_INDEX->value) {
if ((int) $parts[0] !== KindsEnum::PUBLICATION_INDEX->value) {
continue;
}
$cats[] = ['a', $coord];
@ -111,8 +111,8 @@ final class MagazineContentService @@ -111,8 +111,8 @@ final class MagazineContentService
$queue = [];
$enqueued = [];
foreach ($this->getHomeCategoryAIndexTagsFromStoreOnly() as $row) {
$coord = $row[1] ?? '';
if (!\is_string($coord) || $coord === '') {
$coord = $row[1];
if ($coord === '') {
continue;
}
$parts = explode(':', $coord, 3);
@ -130,7 +130,7 @@ final class MagazineContentService @@ -130,7 +130,7 @@ final class MagazineContentService
$out = [];
while ($queue !== []) {
$slug = array_shift($queue);
if (!\is_string($slug) || $slug === '') {
if ($slug === '') {
continue;
}
$out[] = $slug;
@ -200,7 +200,11 @@ final class MagazineContentService @@ -200,7 +200,11 @@ final class MagazineContentService
continue;
}
$parts = explode(':', (string) $seq[1], 3);
if (\count($parts) < 2) {
if (\count($parts) < 3) {
continue;
}
// Only longform article authors are featured authors; skip sub-index (30040) references.
if (!\in_array((int) $parts[0], KindsEnum::longformKindValues(), true)) {
continue;
}
$pk = strtolower((string) $parts[1]);
@ -356,6 +360,74 @@ final class MagazineContentService @@ -356,6 +360,74 @@ final class MagazineContentService
return $n;
}
/**
* Kind **30023** / **30024** / **30817** `a` tags on the magazine **root** index (home headline strip).
* Unlike {@see ingestLongformForAllMagazineCategories}, category nested indices are not walked here.
* Nostr I/O — for {@see PrewarmCommand} / cron and optional refresh before rendering the home strip.
*/
public function ingestLongformForMagazineRootHeadline(): int
{
$coords = $this->collectRootHeadlineLongformCoordinates();
if ($coords === []) {
return 0;
}
$this->nostrClient->ingestLongformForCategoryCoordinates($coords);
return \count($coords);
}
/**
* @return list<string> kind:pubkey:d-tag addresses in root `a` tag order (long-form kinds only)
*/
public function collectRootHeadlineLongformCoordinates(): array
{
$npub = (string) $this->params->get('npub');
$dTag = (string) $this->params->get('d_tag');
$mag = $this->store->getRoot($npub, $dTag);
if ($mag === null) {
$this->ensureRoot30040FromRelays($npub, $dTag);
$mag = $this->store->getRoot($npub, $dTag);
}
if ($mag === null) {
return [];
}
$orderedCoords = [];
$seenAddr = [];
foreach ($mag->getTags() as $tagRow) {
$seq = NostrEventTags::rowToStringList($tagRow);
if ($seq === null) {
continue;
}
$name = strtolower((string) ($seq[0] ?? ''));
if ($name !== 'a' || !isset($seq[1]) || (string) $seq[1] === '') {
continue;
}
$coord = trim((string) $seq[1]);
$parts = explode(':', $coord, 3);
if (\count($parts) < 3) {
continue;
}
$kind = (int) $parts[0];
if (!\in_array($kind, KindsEnum::longformKindValues(), true)) {
continue;
}
$pk = strtolower(trim((string) $parts[1]));
$slug = trim((string) $parts[2]);
if (64 !== \strlen($pk) || !ctype_xdigit($pk) || $slug === '') {
continue;
}
$dedupe = $pk."\0".$slug;
if (isset($seenAddr[$dedupe])) {
continue;
}
$seenAddr[$dedupe] = true;
$orderedCoords[] = $kind.':'.$pk.':'.$slug;
}
return $orderedCoords;
}
/**
* Human-readable prewarm/audit data: what each cached category index (30040) lists and which
* coordinates are unresolved in local MySQL `article`.
@ -370,7 +442,7 @@ final class MagazineContentService @@ -370,7 +442,7 @@ final class MagazineContentService
* missing_total: int,
* entries: list<array{
* coordinate: string,
* status: 'resolved'|'missing',
* status: 'resolved'|'missing'|'skipped',
* reason: string,
* article_title?: string,
* article_slug?: string
@ -446,10 +518,12 @@ final class MagazineContentService @@ -446,10 +518,12 @@ final class MagazineContentService
continue;
}
$kind = (int) ($parts[0] ?? 0);
if (!\in_array($kind, [30023, 30024], true)) {
$entries[] = ['coordinate' => $coordinate, 'status' => 'missing', 'reason' => 'unsupported_kind'];
$missing++;
$kind = (int) $parts[0];
if (!\in_array($kind, KindsEnum::longformKindValues(), true)) {
// kind-30040 nested sub-index links are valid in category `a` tags (e.g. Economy → Bitcoin).
// They are not longform articles and are not ingested here; report them separately
// so they don't inflate the "missing articles" count.
$entries[] = ['coordinate' => $coordinate, 'status' => 'skipped', 'reason' => 'unsupported_kind'];
continue;
}
@ -521,15 +595,15 @@ final class MagazineContentService @@ -521,15 +595,15 @@ final class MagazineContentService
public function missingInDbCoordinatesFromCoverageReport(array $report): array
{
$out = [];
foreach ($report['categories'] ?? [] as $cat) {
foreach ($cat['entries'] ?? [] as $entry) {
if (($entry['status'] ?? '') !== 'missing') {
foreach ($report['categories'] as $cat) {
foreach ($cat['entries'] as $entry) {
if ($entry['status'] !== 'missing') {
continue;
}
if (($entry['reason'] ?? '') !== 'article_not_in_db') {
if ($entry['reason'] !== 'article_not_in_db') {
continue;
}
$coord = isset($entry['coordinate']) ? (string) $entry['coordinate'] : '';
$coord = (string) $entry['coordinate'];
if ($coord !== '') {
$out[] = $coord;
}
@ -562,8 +636,8 @@ final class MagazineContentService @@ -562,8 +636,8 @@ final class MagazineContentService
if (\count($parts) < 3 || trim((string) $parts[2]) === '') {
continue;
}
$kind = (int) ($parts[0] ?? 0);
if (!\in_array($kind, [KindsEnum::LONGFORM->value, KindsEnum::LONGFORM_DRAFT->value], true)) {
$kind = (int) $parts[0];
if (!\in_array($kind, KindsEnum::longformKindValues(), true)) {
continue;
}
$out[] = $coordinate;
@ -696,78 +770,38 @@ final class MagazineContentService @@ -696,78 +770,38 @@ final class MagazineContentService
*/
public function collectFeaturedArticleSlugsForHome(array $categoryATags): array
{
$seen = [];
$out = [];
foreach ($categoryATags as $row) {
$coord = $row[1] ?? '';
if (!\is_string($coord) || $coord === '') {
$coord = $row[1];
if ($coord === '') {
continue;
}
foreach ($this->buildFeaturedWallBlocksForCategoryTree($coord) as $b) {
foreach ($b['cards'] as $card) {
$s = \trim((string) $card->getSlug());
if ($s !== '') {
$out[$s] = true;
if ($s !== '' && !isset($seen[$s])) {
// Use a list, not slug-keyed map: PHP casts numeric-string keys to int.
$seen[$s] = true;
$out[] = $s;
}
}
}
}
return array_keys($out);
return $out;
}
/**
* Home headline strip: kind **30040** magazine root (`npub` + `d_tag`), walking `a` tags **top to bottom**.
* Only kind **30023** / **30024** addresses become tiles; nested **30040** category `a` tags are skipped.
* Only kind **30023** / **30024** / **30817** addresses become tiles; nested **30040** category `a` tags are skipped.
* No strip-level heading — each article’s own title in the template is enough.
*
* @return array{tiles: list<array{article: FeaturedArticleCard, body_html: string}>}
*/
public function buildHomeMagazineRootHeadlineStripData(): array
{
$npub = (string) $this->params->get('npub');
$dTag = (string) $this->params->get('d_tag');
$mag = $this->store->getRoot($npub, $dTag);
if ($mag === null) {
$this->ensureRoot30040FromRelays($npub, $dTag);
$mag = $this->store->getRoot($npub, $dTag);
}
if ($mag === null) {
return ['tiles' => []];
}
$orderedCoords = [];
$seenAddr = [];
foreach ($mag->getTags() as $tagRow) {
$seq = NostrEventTags::rowToStringList($tagRow);
if ($seq === null) {
continue;
}
$name = strtolower((string) ($seq[0] ?? ''));
if ($name !== 'a' || !isset($seq[1]) || (string) $seq[1] === '') {
continue;
}
$coord = trim((string) $seq[1]);
$parts = explode(':', $coord, 3);
if (\count($parts) < 3) {
continue;
}
$kind = (int) ($parts[0] ?? 0);
if (!\in_array($kind, [KindsEnum::LONGFORM->value, KindsEnum::LONGFORM_DRAFT->value], true)) {
continue;
}
$pk = strtolower(trim((string) $parts[1]));
$slug = trim((string) $parts[2]);
if (64 !== \strlen($pk) || !ctype_xdigit($pk) || $slug === '') {
continue;
}
$dedupe = $pk."\0".$slug;
if (isset($seenAddr[$dedupe])) {
continue;
}
$seenAddr[$dedupe] = true;
$orderedCoords[] = $kind.':'.$pk.':'.$slug;
}
$orderedCoords = $this->collectRootHeadlineLongformCoordinates();
if ($orderedCoords === []) {
return ['tiles' => []];
}
@ -777,21 +811,12 @@ final class MagazineContentService @@ -777,21 +811,12 @@ final class MagazineContentService
$parts = explode(':', $coord, 3);
$pairsArg[] = ['pubkey' => strtolower((string) $parts[1]), 'slug' => trim((string) $parts[2])];
}
$indexed = $this->articleRepository->findByAuthorAndSlugIndexed($pairsArg);
$missingCoords = [];
foreach ($pairsArg as $i => $pair) {
$k = strtolower((string) $pair['pubkey'])."\0".trim((string) $pair['slug']);
if (!isset($indexed[$k])) {
$missingCoords[] = $orderedCoords[$i];
}
}
if ($missingCoords !== []) {
try {
$this->nostrClient->ingestLongformForCategoryCoordinates(array_values(array_unique($missingCoords)));
} catch (\Throwable) {
}
$indexed = $this->articleRepository->findByAuthorAndSlugIndexed($pairsArg);
// Always refresh from relays so NIP-33 updates to root headline articles are not stuck on a stale DB row.
try {
$this->nostrClient->ingestLongformForCategoryCoordinates(array_values(array_unique($orderedCoords)));
} catch (\Throwable) {
}
$indexed = $this->articleRepository->findByAuthorAndSlugIndexed($pairsArg);
$tiles = [];
foreach ($pairsArg as $pair) {
@ -827,8 +852,8 @@ final class MagazineContentService @@ -827,8 +852,8 @@ final class MagazineContentService
{
$blocks = [];
foreach ($categoryATags as $row) {
$coord = $row[1] ?? '';
if (!\is_string($coord) || $coord === '') {
$coord = $row[1];
if ($coord === '') {
continue;
}
foreach ($this->buildFeaturedWallBlocksForCategoryTree($coord) as $b) {
@ -947,7 +972,7 @@ final class MagazineContentService @@ -947,7 +972,7 @@ final class MagazineContentService
}
/**
* @return list<string> Article `#d` slugs from kind 30023/30024 `a` tags in index order; follows nested
* @return list<string> Article `#d` slugs from kind 30023/30024/30817 `a` tags in index order; follows nested
* kind-30040 section indices up to {@see $maxDepth} when the store has them.
*/
private function slugsFromCategoryCoord(string $categoryCoord, int $maxA): array
@ -987,12 +1012,12 @@ final class MagazineContentService @@ -987,12 +1012,12 @@ final class MagazineContentService
if (\count($segs) < 3) {
continue;
}
$kind = (int) ($segs[0] ?? 0);
$kind = (int) $segs[0];
$identifier = trim((string) $segs[2]);
if ($identifier === '') {
continue;
}
if (\in_array($kind, [KindsEnum::LONGFORM->value, KindsEnum::LONGFORM_DRAFT->value], true)) {
if (\in_array($kind, KindsEnum::longformKindValues(), true)) {
$slugs[] = $identifier;
if (\count($slugs) >= $maxA) {
return $slugs;
@ -1030,12 +1055,12 @@ final class MagazineContentService @@ -1030,12 +1055,12 @@ final class MagazineContentService
if (\count($segs) < 3) {
continue;
}
$kind = (int) ($segs[0] ?? 0);
$kind = (int) $segs[0];
$identifier = trim((string) $segs[2]);
if ($identifier === '') {
continue;
}
if (\in_array($kind, [KindsEnum::LONGFORM->value, KindsEnum::LONGFORM_DRAFT->value], true)) {
if (\in_array($kind, KindsEnum::longformKindValues(), true)) {
$slugs[] = $identifier;
if (\count($slugs) >= $maxA) {
return $slugs;
@ -1139,7 +1164,7 @@ final class MagazineContentService @@ -1139,7 +1164,7 @@ final class MagazineContentService
if (\count($segs) < 3) {
continue;
}
$kind = (int) ($segs[0] ?? 0);
$kind = (int) $segs[0];
if ($kind !== KindsEnum::PUBLICATION_INDEX->value) {
continue;
}

13
src/Service/MagazineHierarchyPublishService.php

@ -185,12 +185,9 @@ final class MagazineHierarchyPublishService @@ -185,12 +185,9 @@ final class MagazineHierarchyPublishService
if ($kind === KindsEnum::PUBLICATION_INDEX->value && !hash_equals($ownerHex, $pk)) {
return 'Nested 30040 `a` tags must use the magazine owner pubkey';
}
if (!\in_array($kind, [
KindsEnum::PUBLICATION_INDEX->value,
KindsEnum::LONGFORM->value,
KindsEnum::LONGFORM_DRAFT->value,
], true)) {
return 'Unsupported kind in `a` tag (only 30040, 30023, 30024)';
$allowedKinds = array_merge([KindsEnum::PUBLICATION_INDEX->value], KindsEnum::longformKindValues());
if (!\in_array($kind, $allowedKinds, true)) {
return 'Unsupported kind in `a` tag (only 30040, 30023, 30024, 30817)';
}
}
@ -198,7 +195,7 @@ final class MagazineHierarchyPublishService @@ -198,7 +195,7 @@ final class MagazineHierarchyPublishService
}
/**
* Long-form `a` coordinates from this publish batch (30023 / 30024) for immediate DB sync.
* Long-form `a` coordinates from this publish batch (30023 / 30024 / 30817) for immediate DB sync.
*
* @param array<string, NostrWireEvent> $byD
*
@ -222,7 +219,7 @@ final class MagazineHierarchyPublishService @@ -222,7 +219,7 @@ final class MagazineHierarchyPublishService
continue;
}
$kind = (int) $parts[0];
if (!\in_array($kind, [KindsEnum::LONGFORM->value, KindsEnum::LONGFORM_DRAFT->value], true)) {
if (!\in_array($kind, KindsEnum::longformKindValues(), true)) {
continue;
}
$pk = strtolower(trim((string) $parts[1]));

13
src/Service/MagazineIndexStore.php

@ -18,6 +18,7 @@ final class MagazineIndexStore @@ -18,6 +18,7 @@ final class MagazineIndexStore
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly EventRepository $eventRepository,
private readonly TenantContext $tenant,
) {
}
@ -26,7 +27,7 @@ final class MagazineIndexStore @@ -26,7 +27,7 @@ final class MagazineIndexStore
if ($dTag === '') {
return null;
}
$key = MagazineEventKeys::magazineRoot($npub, $dTag);
$key = MagazineEventKeys::magazineRoot($this->tenant->getMagazineSlug(), $npub, $dTag);
if ($key === '') {
return null;
}
@ -39,7 +40,7 @@ final class MagazineIndexStore @@ -39,7 +40,7 @@ final class MagazineIndexStore
if ($slug === '') {
return null;
}
$key = MagazineEventKeys::magazineCategory($slug);
$key = MagazineEventKeys::magazineCategory($this->tenant->getMagazineSlug(), $slug);
return $this->eventRepository->findOneByCoreRowKey($key);
}
@ -49,7 +50,7 @@ final class MagazineIndexStore @@ -49,7 +50,7 @@ final class MagazineIndexStore
if ($dTag === '') {
return;
}
$key = MagazineEventKeys::magazineRoot($npub, $dTag);
$key = MagazineEventKeys::magazineRoot($this->tenant->getMagazineSlug(), $npub, $dTag);
if ($key === '') {
return;
}
@ -61,7 +62,7 @@ final class MagazineIndexStore @@ -61,7 +62,7 @@ final class MagazineIndexStore
if ($slug === '') {
return;
}
$key = MagazineEventKeys::magazineCategory($slug);
$key = MagazineEventKeys::magazineCategory($this->tenant->getMagazineSlug(), $slug);
$this->replaceByCoreKey($key, Event::STORAGE_MAGAZINE_CATEGORY, $event);
}
@ -70,7 +71,7 @@ final class MagazineIndexStore @@ -70,7 +71,7 @@ final class MagazineIndexStore
if ($slug === '') {
return;
}
$key = MagazineEventKeys::magazineCategory($slug);
$key = MagazineEventKeys::magazineCategory($this->tenant->getMagazineSlug(), $slug);
$this->removeByCoreKey($key);
}
@ -79,7 +80,7 @@ final class MagazineIndexStore @@ -79,7 +80,7 @@ final class MagazineIndexStore
if ($dTag === '') {
return;
}
$key = MagazineEventKeys::magazineRoot($npub, $dTag);
$key = MagazineEventKeys::magazineRoot($this->tenant->getMagazineSlug(), $npub, $dTag);
$this->removeByCoreKey($key);
}

4
src/Service/MagazineRefresher.php

@ -203,7 +203,7 @@ final class MagazineRefresher @@ -203,7 +203,7 @@ final class MagazineRefresher
if (\count($parts) < 3) {
continue;
}
if ((int) ($parts[0] ?? 0) !== KindsEnum::PUBLICATION_INDEX->value) {
if ((int) $parts[0] !== KindsEnum::PUBLICATION_INDEX->value) {
continue;
}
$s = trim((string) $parts[2]);
@ -244,7 +244,7 @@ final class MagazineRefresher @@ -244,7 +244,7 @@ final class MagazineRefresher
$relayLabel = (string) (parse_url($defaultRelay, \PHP_URL_HOST) ?: $defaultRelay);
while ($queue !== [] && microtime(true) < $deadline) {
$slug = array_shift($queue);
if (!\is_string($slug) || trim($slug) === '') {
if (trim($slug) === '') {
continue;
}
try {

11
src/Service/MetadataRetrievalException.php

@ -1,11 +0,0 @@ @@ -1,11 +0,0 @@
<?php
namespace App\Service;
class MetadataRetrievalException extends \Exception
{
public function __construct(string $message = "Failed to retrieve metadata", int $code = 0, ?\Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}
}

2
src/Service/Nip05VerificationService.php

@ -47,7 +47,7 @@ final readonly class Nip05VerificationService @@ -47,7 +47,7 @@ final readonly class Nip05VerificationService
$out = [];
$coldDone = 0;
foreach ($rows as $r) {
$label = (string) ($r['label'] ?? '');
$label = (string) $r['label'];
$n = $this->normalizeNip05($label);
if ($n === null) {
$out[] = [...$r, 'verified' => false];

22
src/Service/Nip09DeletionApplier.php

@ -35,6 +35,7 @@ final class Nip09DeletionApplier @@ -35,6 +35,7 @@ final class Nip09DeletionApplier
private readonly ParameterBagInterface $params,
private readonly LoggerInterface $logger,
private readonly NostrKeyHelper $nostrKeyHelper,
private readonly TenantContext $tenant,
) {
}
@ -53,9 +54,6 @@ final class Nip09DeletionApplier @@ -53,9 +54,6 @@ final class Nip09DeletionApplier
$seenArticleIds = [];
foreach ($deletionEvents as $ev) {
if (!\is_object($ev)) {
continue;
}
if ((int) ($ev->kind ?? 0) !== KindsEnum::DELETION_REQUEST->value) {
continue;
}
@ -73,15 +71,13 @@ final class Nip09DeletionApplier @@ -73,15 +71,13 @@ final class Nip09DeletionApplier
}
$declared = $eKinds[$i] ?? null;
if ($declared !== null
&& !\in_array($declared, [
KindsEnum::LONGFORM->value,
KindsEnum::LONGFORM_DRAFT->value,
&& !\in_array($declared, array_merge(KindsEnum::longformKindValues(), [
KindsEnum::PUBLICATION_INDEX->value,
KindsEnum::METADATA->value,
KindsEnum::RELAY_LIST->value,
KindsEnum::PAYMENT_TARGETS->value,
KindsEnum::CURATION_SET->value,
], true)) {
]), true)) {
continue;
}
if ($this->removeArticleByEventIdIfValid($eId, $deletionPubkey, $declared, $seenArticleIds)) {
@ -92,12 +88,10 @@ final class Nip09DeletionApplier @@ -92,12 +88,10 @@ final class Nip09DeletionApplier
if ($this->tryRemoveCoreEventRowByEventId($eId, $deletionPubkey, $declared)) {
continue;
}
if ($declared === null || \in_array($declared, [
KindsEnum::LONGFORM->value,
KindsEnum::LONGFORM_DRAFT->value,
if ($declared === null || \in_array($declared, array_merge(KindsEnum::longformKindValues(), [
KindsEnum::PUBLICATION_INDEX->value,
KindsEnum::CURATION_SET->value,
], true)) {
]), true)) {
$mag = $this->tryRemoveMagazine30040ByEventId($eId, $deletionPubkey);
if ($mag === 1) {
++$roots;
@ -273,7 +267,7 @@ final class Nip09DeletionApplier @@ -273,7 +267,7 @@ final class Nip09DeletionApplier
if ($declaredKind !== null && $k !== null && $declaredKind !== $k) {
return false;
}
if ($k !== null && !\in_array($k, [KindsEnum::LONGFORM->value, KindsEnum::LONGFORM_DRAFT->value], true)) {
if ($k !== null && !\in_array($k, KindsEnum::longformKindValues(), true)) {
return false;
}
$this->entityManager->remove($article);
@ -343,7 +337,7 @@ final class Nip09DeletionApplier @@ -343,7 +337,7 @@ final class Nip09DeletionApplier
return $out;
}
if ($kind === KindsEnum::LONGFORM->value || $kind === KindsEnum::LONGFORM_DRAFT->value) {
if (\in_array($kind, KindsEnum::longformKindValues(), true)) {
if ($d === '') {
return $out;
}
@ -410,7 +404,7 @@ final class Nip09DeletionApplier @@ -410,7 +404,7 @@ final class Nip09DeletionApplier
if ($d === '') {
return $out;
}
$key = MagazineEventKeys::magazineCuration30004FromPubkeyHex($pk, $d);
$key = MagazineEventKeys::magazineCuration30004FromPubkeyHex($this->tenant->getMagazineSlug(), $pk, $d);
if ($key === '') {
return $out;
}

207
src/Service/NostrArticleDiscussionSupport.php

@ -9,7 +9,8 @@ use App\Nostr\Nip19Codec; @@ -9,7 +9,8 @@ use App\Nostr\Nip19Codec;
use swentel\nostr\Filter\Filter;
/**
* REQ {@link Filter}s and tag-matching rules for long-form article discussion (NIP-22 kind 1111, legacy kind 1, quotes).
* REQ {@link Filter}s and tag-matching rules for long-form article discussion (NIP-22 kind 1111, legacy kind 1, quotes)
* and NIP-A3 superchats (kind 9740 payment notifications attested by kind 9741 from the article author).
* Used by {@see NostrClient::getArticleDiscussion()}.
*/
final class NostrArticleDiscussionSupport
@ -85,6 +86,210 @@ final class NostrArticleDiscussionSupport @@ -85,6 +86,210 @@ final class NostrArticleDiscussionSupport
return $filters;
}
/**
* Additional REQ filters that fetch NIP-A3 superchats and Monero tips for an article:
* - kind 9740 (Lightning payment notifications) tagged `#a` with article coordinate
* - kind 9736 (Monero zap receipts) tagged `#a` with article coordinate
* - kind 1814 (Garnet self-attesting Monero tips) tagged `#a` with article coordinate
* - kind 9741 (payment attestations) published by the article author
*
* @return array<int, Filter>
*/
public function createSuperchatFilters(string $coordinate, string $authorPubkeyHex): array
{
if ($authorPubkeyHex === '' || 64 !== \strlen($authorPubkeyHex) || !ctype_xdigit($authorPubkeyHex)) {
return [];
}
$filters = [];
// Lightning payment notifications (9740) — require 9741 attestation.
$fNotif = new Filter();
$fNotif->setKinds([KindsEnum::PAYMENT_NOTIFICATION->value]);
$fNotif->setTag('#a', [$coordinate]);
$fNotif->setLimit(50);
$filters[] = $fNotif;
// Monero zap receipts (9736) — analogous to kind 9735; require 9741 attestation.
$fMoneroZap = new Filter();
$fMoneroZap->setKinds([KindsEnum::MONERO_ZAP_RECEIPT->value]);
$fMoneroZap->setTag('#a', [$coordinate]);
$fMoneroZap->setLimit(50);
$filters[] = $fMoneroZap;
// Garnet Monero tips (1814) — self-attesting; proof is embedded in the JSON content.
$fMoneroTip = new Filter();
$fMoneroTip->setKinds([KindsEnum::MONERO_TIP->value]);
$fMoneroTip->setTag('#a', [$coordinate]);
$fMoneroTip->setLimit(50);
$filters[] = $fMoneroTip;
// Attestations from the article author covering any of the above.
$fAttest = new Filter();
$fAttest->setKinds([KindsEnum::PAYMENT_ATTESTATION->value]);
$fAttest->setAuthors([$authorPubkeyHex]);
$fAttest->setLimit(200);
$filters[] = $fAttest;
return $filters;
}
/**
* Build the superchat item list from three event buckets:
*
* - `$attestRequiredEvents`: kind 9740 (Lightning payto) and kind 9736 (Monero zap receipt).
* Only included when the article author has published a matching kind 9741 attestation.
* - `$selfAttestingEvents`: kind 1814 (Garnet Monero tips).
* These embed a cryptographic Monero payment proof in the JSON `content`, so no 9741 is needed.
* - `$attestations`: kind 9741 events published by the article author.
*
* Items are sorted by payment amount descending (highest superchat first), then newest first.
*
* @param list<object> $attestRequiredEvents kind 9740 / 9736 events
* @param list<object> $selfAttestingEvents kind 1814 events (Garnet Monero tips)
* @param list<object> $attestations kind 9741 events (must be from the article author)
* @param string $authorPubkeyHex hex pubkey of the article author
* @return list<array{id:string,pubkey:string,content:string,amount_msats:int,amount_sats:int,tier:string,payment_type:string,created_at:int}>
*/
public function buildSuperchatItems(
array $attestRequiredEvents,
array $selfAttestingEvents,
array $attestations,
string $authorPubkeyHex,
): array {
$authorHex = strtolower($authorPubkeyHex);
// Build a set of event-IDs the author has attested (covers 9740, 9736, 9735).
$attestedIds = [];
foreach ($attestations as $att) {
if (strtolower((string) ($att->pubkey ?? '')) !== $authorHex) {
continue;
}
foreach ($att->tags ?? [] as $tag) {
if (!\is_array($tag) || \count($tag) < 2) {
continue;
}
if ((string) ($tag[0] ?? '') === 'e') {
$eid = strtolower(trim((string) ($tag[1] ?? '')));
if (64 === \strlen($eid) && ctype_xdigit($eid)) {
$attestedIds[$eid] = true;
}
}
}
}
$items = [];
// Attestation-required events (9740, 9736).
foreach ($attestRequiredEvents as $notif) {
$id = strtolower(trim((string) ($notif->id ?? '')));
if ($id === '' || !isset($attestedIds[$id])) {
continue;
}
$kind = (int) ($notif->kind ?? 0);
$paymentType = $kind === KindsEnum::MONERO_ZAP_RECEIPT->value ? 'monero_zap' : 'lightning';
$items[] = $this->buildSuperchatItemFromNotification($notif, $id, $paymentType);
}
// Self-attesting Monero tips (kind 1814 from Garnet).
foreach ($selfAttestingEvents as $notif) {
$id = strtolower(trim((string) ($notif->id ?? '')));
if ($id === '') {
continue;
}
$items[] = $this->buildSuperchatItemFromMoneroTip($notif, $id);
}
// Sort highest amount first, then newest first as tiebreaker.
usort($items, static function (array $a, array $b): int {
if ($b['amount_msats'] !== $a['amount_msats']) {
return $b['amount_msats'] <=> $a['amount_msats'];
}
return $b['created_at'] <=> $a['created_at'];
});
return $items;
}
/**
* @return array{id:string,pubkey:string,content:string,amount_msats:int,amount_sats:int,tier:string,payment_type:string,created_at:int}
*/
private function buildSuperchatItemFromNotification(object $notif, string $id, string $paymentType): array
{
$amountMsats = 0;
foreach ($notif->tags ?? [] as $tag) {
if (!\is_array($tag) || \count($tag) < 2) {
continue;
}
if ((string) ($tag[0] ?? '') === 'amount' && ctype_digit(trim((string) ($tag[1] ?? '')))) {
$amountMsats = (int) $tag[1];
break;
}
}
return $this->assembleItem($id, $notif, (string) ($notif->content ?? ''), $amountMsats, $paymentType);
}
/**
* Kind 1814 Garnet Monero tip: content is a JSON string with at minimum `{"message": "...", "txid": "..."}`.
* Extract the `message` field as the display content; fall back to the raw content string.
* Amount may be present in an `amount` tag (millisats) or absent.
*
* @return array{id:string,pubkey:string,content:string,amount_msats:int,amount_sats:int,tier:string,payment_type:string,created_at:int}
*/
private function buildSuperchatItemFromMoneroTip(object $notif, string $id): array
{
$rawContent = (string) ($notif->content ?? '');
$message = $rawContent;
try {
$parsed = json_decode($rawContent, true, 10, \JSON_THROW_ON_ERROR);
if (\is_array($parsed) && isset($parsed['message']) && \is_string($parsed['message'])) {
$message = $parsed['message'];
}
} catch (\JsonException) {
// keep raw content as message
}
$amountMsats = 0;
foreach ($notif->tags ?? [] as $tag) {
if (!\is_array($tag) || \count($tag) < 2) {
continue;
}
if ((string) ($tag[0] ?? '') === 'amount' && ctype_digit(trim((string) ($tag[1] ?? '')))) {
$amountMsats = (int) $tag[1];
break;
}
}
return $this->assembleItem($id, $notif, $message, $amountMsats, 'monero_tip');
}
/**
* @return array{id:string,pubkey:string,content:string,amount_msats:int,amount_sats:int,tier:string,payment_type:string,created_at:int}
*/
private function assembleItem(string $id, object $notif, string $content, int $amountMsats, string $paymentType): array
{
$amountSats = (int) round($amountMsats / 1000);
$tier = match (true) {
$amountSats >= 100_000 => 'gold',
$amountSats >= 10_000 => 'silver',
$amountSats >= 1_000 => 'bronze',
default => '',
};
return [
'id' => $id,
'pubkey' => (string) ($notif->pubkey ?? ''),
'content' => $content,
'amount_msats' => $amountMsats,
'amount_sats' => $amountSats,
'tier' => $tier,
'payment_type' => $paymentType,
'created_at' => (int) ($notif->created_at ?? 0),
];
}
public function eventIsNip22ArticleThreadReply(object $event, string $coordinate): bool
{
if ((int) ($event->kind ?? 0) !== KindsEnum::COMMENTS->value) {

313
src/Service/NostrClient.php

@ -17,7 +17,6 @@ use swentel\nostr\Relay\Relay; @@ -17,7 +17,6 @@ use swentel\nostr\Relay\Relay;
use swentel\nostr\Relay\RelaySet;
use swentel\nostr\Request\Request;
use swentel\nostr\Subscription\Subscription;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
/**
* Main integration point for swentel/nostr against configured relays: long-form fetch, kind-0 profile
@ -58,7 +57,6 @@ class NostrClient @@ -58,7 +57,6 @@ class NostrClient
private readonly EntityManagerInterface $entityManager,
private readonly ManagerRegistry $managerRegistry,
private readonly ArticleFactory $articleFactory,
private readonly TokenStorageInterface $tokenStorage,
private readonly LoggerInterface $logger,
private readonly string $projectDir,
private readonly NostrRelayRequestFactory $relayRequestFactory,
@ -119,7 +117,7 @@ class NostrClient @@ -119,7 +117,7 @@ class NostrClient
$out = $base;
foreach ($pubkeys as $pk) {
foreach ($this->authorRelayCache->getAuthorNip65RelaysList($pk) as $wss) {
if (!\is_string($wss) || $wss === '' || isset($seen[$wss])) {
if ($wss === '' || isset($seen[$wss])) {
continue;
}
$seen[$wss] = true;
@ -140,66 +138,6 @@ class NostrClient @@ -140,66 +138,6 @@ class NostrClient
return $this->relayListFactory->getNostrLandAggrReaderCacheSuffix();
}
/**
* 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->relayListFactory->getProfileMetadataQueryRelayUrlList();
$relaysTriedStr = implode(', ', array_map(NostrRelayQuery::relayLogLabel(...), $relaysTried));
$relaySet = $this->relayListFactory->getRelaySetForProfileMetadataFetch();
$chunks = array_chunk($authorPubkeyHex, $authorsPerRequest);
foreach ($chunks as $i => $chunk) {
$t0 = microtime(true);
$request = $this->nostrRelayQuery->createNostrRequest(
defaultRelaySet: $this->defaultRelaySet,
kinds: [KindsEnum::METADATA],
filters: ['authors' => $chunk],
relaySet: $relaySet
);
$events = $this->nostrRelayQuery->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),
]);
foreach ($this->wireMerge->mergeKind0EventsByReplaceableAddress($events) as $addr => $ev) {
if (!\is_object($ev) || !isset($ev->content)) {
continue;
}
$pk = \substr($addr, 2);
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;
}
/**
* Batched kind-0 fetch: one REQ per chunk; returns latest wire event per author (for DB persistence).
*
@ -228,7 +166,7 @@ class NostrClient @@ -228,7 +166,7 @@ class NostrClient
{
$authorPubkeyHex = \array_values(\array_unique(\array_filter(
$authorPubkeyHex,
static fn (mixed $h): bool => \is_string($h) && 64 === \strlen($h),
static fn (string $h): bool => 64 === \strlen($h),
)));
if ($authorPubkeyHex === []) {
return [];
@ -327,7 +265,7 @@ class NostrClient @@ -327,7 +265,7 @@ class NostrClient
): array {
$authorPubkeyHex = \array_values(\array_unique(\array_filter(
$authorPubkeyHex,
static fn (mixed $h): bool => \is_string($h) && 64 === \strlen($h),
static fn (string $h): bool => 64 === \strlen($h),
)));
if ($authorPubkeyHex === [] || $since >= $until) {
return [];
@ -381,47 +319,6 @@ class NostrClient @@ -381,47 +319,6 @@ class NostrClient
return array_values($byId);
}
/**
* @throws \Exception
*/
public function getNpubMetadata($npub): \stdClass
{
$relaysTried = $this->relayListFactory->capSequentialRelaysForProfileFetches($this->relayListFactory->getProfileMetadataQueryRelayUrlList());
$relaysTriedStr = implode(', ', array_map(NostrRelayQuery::relayLogLabel(...), $relaysTried));
$relaySet = $this->relayListFactory->relaySetFromDistinctUrlList($relaysTried);
$this->logger->info(sprintf('Getting metadata for npub (relays: %s)', $relaysTriedStr), ['npub' => $npub, 'relays' => $relaysTried]);
$request = $this->nostrRelayQuery->createNostrRequest(
defaultRelaySet: $this->defaultRelaySet,
kinds: [KindsEnum::METADATA],
filters: ['authors' => [$npub]],
relaySet: $relaySet
);
$events = $this->nostrRelayQuery->processResponse(
$request->send(),
function ($received) {
$this->logger->debug('nostr.metadata.relay_event', ['event' => $received]);
return $received;
},
);
if (empty($events)) {
throw new \Exception('No metadata for npub '.$npub.' (relays: '.$relaysTriedStr.')');
}
$byAddr = $this->wireMerge->mergeKind0EventsByReplaceableAddress($events);
$authorHex = $this->wireMerge->npubToHexPubkey($npub);
if ($authorHex === null) {
throw new \Exception('Invalid npub for metadata: '.$npub);
}
$key = '0:'.$authorHex;
if (!isset($byAddr[$key])) {
throw new \Exception('No kind-0 metadata for npub '.$npub.' (relays: '.$relaysTriedStr.')');
}
return $byAddr[$key];
}
/**
* NIP-A3 kind 10133: payment target events; NIP kind-range 10_000–19_999 is replaceable by
* (kind, pubkey), so multi-relay results are merged to the live revision per
@ -431,6 +328,10 @@ class NostrClient @@ -431,6 +328,10 @@ class NostrClient
*/
public function getKind10133PaymentTargetEventsForNpub(string $npub, int $limit = 20): array
{
$authorHex = $this->wireMerge->authorIdentToHexLower($npub);
if ($authorHex === null) {
return [];
}
$relaysTried = $this->relayListFactory->capSequentialRelaysForProfileFetches($this->relayListFactory->getProfileMetadataQueryRelayUrlList());
$relaysTriedStr = implode(', ', array_map(NostrRelayQuery::relayLogLabel(...), $relaysTried));
$relaySet = $this->relayListFactory->relaySetFromDistinctUrlList($relaysTried);
@ -438,7 +339,7 @@ class NostrClient @@ -438,7 +339,7 @@ class NostrClient
$request = $this->nostrRelayQuery->createNostrRequest(
defaultRelaySet: $this->defaultRelaySet,
kinds: [KindsEnum::PAYMENT_TARGETS],
filters: ['authors' => [$npub], 'limit' => max(1, min(50, $limit))],
filters: ['authors' => [$authorHex], 'limit' => max(1, min(50, $limit))],
relaySet: $relaySet
);
$events = $this->nostrRelayQuery->processResponse(
@ -461,43 +362,6 @@ class NostrClient @@ -461,43 +362,6 @@ class NostrClient
return $this->wireMerge->mergeNip33ParameterizedWireEvents($events);
}
public function getNpubLongForm($npub): void
{
$subscription = new Subscription();
$subscriptionId = $subscription->setId();
$filter = new Filter();
$filter->setKinds([KindsEnum::LONGFORM]);
$filter->setAuthors([$npub]);
$filter->setSince(strtotime('-6 months')); // too much?
$requestMessage = new RequestMessage($subscriptionId, [$filter]);
// if user is logged in, use their settings
/* @var $user */
$user = $this->tokenStorage->getToken()?->getUser();
$relays = $this->defaultRelaySet;
if ($user && $user->getRelays()) {
$relays = new RelaySet();
foreach ($user->getRelays() as $relayArr) {
if ($relayArr[2] == 'write') {
$relays->addRelay(new Relay($relayArr[1]));
}
}
}
$request = $this->relayRequestFactory->createTimedRequest($relays, $requestMessage);
$wrappers = $this->nostrRelayQuery->processResponse($request->send(), function (object $event) {
$w = new \stdClass();
$w->event = $event;
return $w;
});
if ($wrappers !== []) {
$this->saveLongFormContent($wrappers);
}
// TODO handle relays that require auth
}
public function publishEvent(Event $event, array $relays): array
{
$eventMessage = new EventMessage($event);
@ -512,7 +376,7 @@ class NostrClient @@ -512,7 +376,7 @@ class NostrClient
$relaySet->setMessage($eventMessage);
$this->relayRequestFactory->applySocketTimeoutToRelaySet($relaySet);
$sent = $relaySet->send();
if (\is_array($sent) && \array_key_exists($relayWss, $sent)) {
if (\array_key_exists($relayWss, $sent)) {
$results[$relayWss] = $sent[$relayWss];
} else {
$results[$relayWss] = $sent;
@ -561,7 +425,7 @@ class NostrClient @@ -561,7 +425,7 @@ class NostrClient
$subscription = new Subscription();
$subscriptionId = $subscription->setId();
$filter = new Filter();
$filter->setKinds([KindsEnum::LONGFORM]);
$filter->setKinds(KindsEnum::longformKindValues());
$filter->setSince($since);
$filter->setUntil($until);
$requestMessage = new RequestMessage($subscriptionId, [$filter]);
@ -594,13 +458,20 @@ class NostrClient @@ -594,13 +458,20 @@ class NostrClient
}
$relaysTriedStr = implode(', ', array_map(NostrRelayQuery::relayLogLabel(...), $relaysTried));
$authorHex = $this->wireMerge->authorIdentToHexLower($author);
if ($authorHex === null) {
$this->logger->warning('nostr.longform_naddr.invalid_author', ['author' => $author]);
return;
}
try {
// Create request using the helper method for forest relay set
$request = $this->nostrRelayQuery->createNostrRequest(
defaultRelaySet: $this->defaultRelaySet,
kinds: [$kind],
filters: [
'authors' => [$author],
'authors' => [$authorHex],
'tag' => ['#d', [$slug]]
],
relaySet: $authorRelaySet
@ -613,9 +484,8 @@ class NostrClient @@ -613,9 +484,8 @@ class NostrClient
if (!empty($events)) {
$kindI = (int) $kind;
$authorH = $this->wireMerge->authorIdentToHexLower($author);
$event = $this->wireMerge->isNip33ParameterizedKind($kindI) && $authorH !== null
? $this->wireMerge->pickLatestNip33ParameterizedForQuery($events, $kindI, $authorH, (string) $slug)
$event = $this->wireMerge->isNip33ParameterizedKind($kindI)
? $this->wireMerge->pickLatestNip33ParameterizedForQuery($events, $kindI, $authorHex, (string) $slug)
: null;
if ($event === null) {
$event = $events[0];
@ -770,9 +640,13 @@ class NostrClient @@ -770,9 +640,13 @@ class NostrClient
if (empty($pubkey) || empty($identifier)) {
return null;
}
$authorHex = $this->wireMerge->authorIdentToHexLower($pubkey);
if ($authorHex === null) {
return null;
}
// Try author's relays first
$authorRelays = empty($relays) ? $this->authorRelayCache->getTopReputableRelaysForAuthor($pubkey) : $relays;
$authorRelays = empty($relays) ? $this->authorRelayCache->getTopReputableRelaysForAuthor($authorHex) : $relays;
$relaySet = $this->relayListFactory->createRelaySetMergedWithArticleList($authorRelays);
// Create request using the helper method
@ -780,7 +654,7 @@ class NostrClient @@ -780,7 +654,7 @@ class NostrClient
defaultRelaySet: $this->defaultRelaySet,
kinds: [$kind],
filters: [
'authors' => [$pubkey],
'authors' => [$authorHex],
'tag' => ['#d', [$identifier]]
],
relaySet: $relaySet
@ -800,14 +674,12 @@ class NostrClient @@ -800,14 +674,12 @@ class NostrClient
defaultRelaySet: $this->defaultRelaySet,
kinds: [$kind],
filters: [
'authors' => [$pubkey],
'authors' => [$authorHex],
'tag' => ['#d', [$identifier]]
]
);
$events = $this->nostrRelayQuery->processResponse($request->send(), function($event) {
return $event;
});
$events = $this->nostrRelayQuery->processResponse($request->send(), static fn (object $e) => $e);
return !empty($events) ? $events[0] : null;
}
@ -843,10 +715,14 @@ class NostrClient @@ -843,10 +715,14 @@ class NostrClient
*/
public function getNpubRelayList10002Wire($npub): ?object
{
$authorHex = $this->wireMerge->authorIdentToHexLower($npub);
if ($authorHex === null) {
return null;
}
$request = $this->nostrRelayQuery->createNostrRequest(
defaultRelaySet: $this->defaultRelaySet,
kinds: [KindsEnum::RELAY_LIST],
filters: ['authors' => [$npub]],
filters: ['authors' => [$authorHex]],
relaySet: $this->defaultRelaySet
);
$response = $this->nostrRelayQuery->processResponse($request->send(), function ($received) {
@ -858,7 +734,7 @@ class NostrClient @@ -858,7 +734,7 @@ class NostrClient
$merged = $this->wireMerge->mergeNip33ParameterizedWireEvents($response);
$k10002 = (int) KindsEnum::RELAY_LIST->value;
foreach ($merged as $e) {
if (\is_object($e) && (int) ($e->kind ?? 0) === $k10002) {
if ((int) ($e->kind ?? 0) === $k10002) {
return $e;
}
}
@ -886,7 +762,7 @@ class NostrClient @@ -886,7 +762,7 @@ class NostrClient
* @param string $coordinate kind:pubkey:d-identifier (e.g. longform address)
* @param null|string $rootEventHexId Published article event id (hex) for #e / #q matching
*
* @return array{thread: array<int, object>, quotes: array<int, object>, partial?: bool}
* @return array{thread: array<int, object>, quotes: array<int, object>, superchats: list<array<string,mixed>>, partial?: bool}
*/
public function getArticleDiscussion(string $coordinate, ?string $rootEventHexId = null): array
{
@ -921,6 +797,9 @@ class NostrClient @@ -921,6 +797,9 @@ class NostrClient
}
$filters = $this->articleDiscussion->createArticleDiscussionFilters($coordinate, $rootEventHexId);
foreach ($this->articleDiscussion->createSuperchatFilters($coordinate, $pubkey) as $sf) {
$filters[] = $sf;
}
$subscription = new Subscription();
$subscriptionId = $subscription->setId();
$requestMessage = new RequestMessage($subscriptionId, $filters);
@ -954,9 +833,14 @@ class NostrClient @@ -954,9 +833,14 @@ class NostrClient
$this->logger->warning('nostr.article_discussion.sequential_fallback', [
'relays' => $forSeq,
]);
// Use a shorter per-relay timeout for the web sequential fallback so one slow
// relay does not hold up the HTTP response for 3 × 12 s = 36 s.
// CLI prewarm still uses the full configured timeout via the normal path.
$seqTimeoutSec = min(6, $this->relayFanout->getRelayRequestTimeoutSec());
$response = $this->relayFanout->sendSequential(
$this->relayListFactory->relaySetFromDistinctUrlList($forSeq),
$requestMessage
$requestMessage,
$seqTimeoutSec
);
}
}
@ -1001,9 +885,24 @@ class NostrClient @@ -1001,9 +885,24 @@ class NostrClient
$all = array_values($byId);
$thread = [];
$threadIds = [];
$attestRequiredSuperchats = []; // kind 9740 (Lightning payto) + kind 9736 (Monero zap receipt)
$selfAttestingSuperchats = []; // kind 1814 (Garnet Monero tip, proof embedded)
$attestations9741 = [];
foreach ($all as $event) {
$kind = (int) ($event->kind ?? 0);
if ($kind === KindsEnum::PAYMENT_NOTIFICATION->value || $kind === KindsEnum::MONERO_ZAP_RECEIPT->value) {
$attestRequiredSuperchats[] = $event;
continue;
}
if ($kind === KindsEnum::MONERO_TIP->value) {
$selfAttestingSuperchats[] = $event;
continue;
}
if ($kind === KindsEnum::PAYMENT_ATTESTATION->value) {
$attestations9741[] = $event;
continue;
}
if ($kind === KindsEnum::COMMENTS->value && $this->articleDiscussion->eventIsNip22ArticleThreadReply($event, $coordinate)) {
$thread[] = $event;
$threadIds[(string) $event->id] = true;
@ -1016,17 +915,33 @@ class NostrClient @@ -1016,17 +915,33 @@ class NostrClient
}
}
$superchatKinds = [
KindsEnum::PAYMENT_NOTIFICATION->value,
KindsEnum::MONERO_ZAP_RECEIPT->value,
KindsEnum::MONERO_TIP->value,
KindsEnum::PAYMENT_ATTESTATION->value,
];
$quotes = [];
foreach ($all as $event) {
$id = (string) ($event->id ?? '');
if ($id === '' || isset($threadIds[$id])) {
continue;
}
if (\in_array((int) ($event->kind ?? 0), $superchatKinds, true)) {
continue;
}
if ($this->articleDiscussion->eventIsArticleQuote($event, $coordinate, $rootEventHexId)) {
$quotes[] = $event;
}
}
$superchats = $this->articleDiscussion->buildSuperchatItems(
$attestRequiredSuperchats,
$selfAttestingSuperchats,
$attestations9741,
$pubkey,
);
$sortAsc = static function ($a, $b): int {
return ((int) ($a->created_at ?? 0)) <=> ((int) ($b->created_at ?? 0));
};
@ -1039,12 +954,13 @@ class NostrClient @@ -1039,12 +954,13 @@ class NostrClient
$this->logger->info('nostr.article_discussion.done', [
'thread_count' => \count($thread),
'quotes_count' => \count($quotes),
'superchat_count' => \count($superchats),
'partial' => $partial,
'responded_relays' => $respondedRelayCount,
'planned_relays' => \count($plannedRelayUrls),
]);
return ['thread' => $thread, 'quotes' => $quotes, 'partial' => $partial];
return ['thread' => $thread, 'quotes' => $quotes, 'superchats' => $superchats, 'partial' => $partial];
}
/**
@ -1162,7 +1078,7 @@ class NostrClient @@ -1162,7 +1078,7 @@ class NostrClient
$seen = [];
$out = [];
foreach (array_merge($this->relayListFactory->getConfiguredArticleRelayUrlList(), $relayUrls) as $relayUrl) {
if (!\is_string($relayUrl) || $relayUrl === '' || isset($seen[$relayUrl])) {
if ($relayUrl === '' || isset($seen[$relayUrl])) {
continue;
}
$seen[$relayUrl] = true;
@ -1210,59 +1126,13 @@ class NostrClient @@ -1210,59 +1126,13 @@ class NostrClient
});
}
/**
* @throws \Exception
*/
public function getLongFormContentForPubkey(string $ident): array
{
$authorRelays = $this->authorRelayCache->getTopReputableRelaysForAuthor($ident);
$base = $this->relayListFactory->getConfiguredArticleRelayUrlList();
$merged = $authorRelays !== [] ? array_merge($base, $authorRelays) : $base;
$seen = [];
$deduped = [];
foreach ($merged as $url) {
if (!\is_string($url) || $url === '' || isset($seen[$url])) {
continue;
}
$seen[$url] = true;
$deduped[] = $url;
}
$capped = $this->relayListFactory->capSequentialRelaysForProfileFetches($deduped);
$relaySet = $this->relayListFactory->relaySetFromDistinctUrlList($capped);
// Create request using the helper method
$request = $this->nostrRelayQuery->createNostrRequest(
defaultRelaySet: $this->defaultRelaySet,
kinds: [KindsEnum::LONGFORM],
filters: [
'authors' => [$ident],
'limit' => 10
],
relaySet: $relaySet
);
$events = $this->nostrRelayQuery->processResponse(
$request->send(),
static fn (object $event) => $event,
);
foreach ($this->wireMerge->mergeNip33ParameterizedWireEvents($events) as $event) {
if (!\is_object($event)) {
continue;
}
$article = $this->articleFactory->createFromLongFormContentEvent($event);
$this->saveEachArticleToTheDatabase($article);
}
return [];
}
public function getArticles(array $slugs): array
{
$articles = [];
$subscription = new Subscription();
$subscriptionId = $subscription->setId();
$filter = new Filter();
$filter->setKinds([KindsEnum::LONGFORM]);
$filter->setKinds(KindsEnum::longformKindValues());
$filter->setTag('#d', $slugs);
$requestMessage = new RequestMessage($subscriptionId, [$filter]);
@ -1478,6 +1348,10 @@ class NostrClient @@ -1478,6 +1348,10 @@ class NostrClient
return;
}
if ($this->longformArticleStore->isEventIdAlreadyStored($newId)) {
$existing = $this->longformArticleStore->findByEventId($newId);
if ($existing !== null) {
$this->longformArticleStore->linkExistingArticle($existing);
}
$this->logger->info('[longform_ingest] saveEachArticle: skip, DB already has this exact event id (no work)', [
'eventId' => $newId,
'slug' => $article->getSlug(),
@ -1534,6 +1408,7 @@ class NostrClient @@ -1534,6 +1408,7 @@ class NostrClient
}
try {
$this->entityManager->flush();
$this->longformArticleStore->linkExistingArticle($incumbent);
} catch (\Exception $e) {
$this->logger->error('[longform_ingest] saveEachArticle: flush after update failed: '.$e->getMessage());
$this->managerRegistry->resetManager();
@ -1550,6 +1425,7 @@ class NostrClient @@ -1550,6 +1425,7 @@ class NostrClient
'dbCreatedAt' => $iTs,
'seenCreatedAt' => $cTs,
]);
$this->longformArticleStore->linkExistingArticle($incumbent);
} elseif ((string) $incumbent->getEventId() !== $newId) {
$this->logger->notice('[longform_ingest] saveEachArticle: inconclusive supersedes (different ids) — check relays / d-tag match', [
'address' => $pubkey.':…:'.$this->wireMerge->longformIngestShortSlug($slug),
@ -1713,7 +1589,7 @@ class NostrClient @@ -1713,7 +1589,7 @@ class NostrClient
defaultRelaySet: $this->defaultRelaySet,
relaySet: $relaySet,
kinds: [KindsEnum::PUBLICATION_INDEX],
filters: ['authors' => [(string) $npub], 'tag' => ['#d', [(string) $dTag]]],
filters: ['authors' => [$authorHex], 'tag' => ['#d', [(string) $dTag]]],
);
$this->logger->info(sprintf('Magazine index query (relays: %s)', $relaysForLog), [
'npub' => $npub,
@ -1838,7 +1714,7 @@ class NostrClient @@ -1838,7 +1714,7 @@ class NostrClient
$this->logger->info('[longform_ingest] ingestLongform: start', [
'address_count' => \count($addresses),
'relays' => $relaysForLog,
'addresses_sample' => \array_values(\array_slice($addresses, 0, 15)),
'addresses_sample' => \array_slice($addresses, 0, 15),
]);
$groups = [];
foreach ($addresses as $c) {
@ -1865,10 +1741,7 @@ class NostrClient @@ -1865,10 +1741,7 @@ class NostrClient
'group_count' => \count($groups),
]);
foreach ($groups as $gkey => $g) {
$dTags = array_values(array_unique($g['dTags'] ?? []));
if ($dTags === [] || !isset($g['pubkey'], $g['kind'])) {
continue;
}
$dTags = array_values(array_unique($g['dTags']));
$kindEnum = KindsEnum::tryFrom((int) $g['kind']);
if ($kindEnum === null) {
$this->logger->notice('[longform_ingest] skip group: unknown kind', ['kind' => $g['kind']]);
@ -1912,7 +1785,7 @@ class NostrClient @@ -1912,7 +1785,7 @@ class NostrClient
'sample_up_to_25' => $rawSample,
]);
if ($rawCount === 0) {
$this->logger->warning('[longform_ingest] ingestLongform: no EVENT rows returned for this filter (check relay index / author filter / #d list)', [
$this->logger->notice('[longform_ingest] ingestLongform: no EVENT rows returned for this filter — trying fallback queries', [
'group_key' => $gkey,
'authors_filter' => $g['pubkey'],
]);
@ -2016,9 +1889,6 @@ class NostrClient @@ -2016,9 +1889,6 @@ class NostrClient
$merged = $this->wireMerge->mergeNip33ParameterizedWireEvents($events);
$mergedDetail = [];
foreach ($merged as $ev) {
if (!\is_object($ev)) {
continue;
}
$mergedDetail[] = $this->wireMerge->longformIngestEventWireSummary($ev);
}
$this->logger->info('[longform_ingest] ingestLongform: after mergeNip33ParameterizedWireEvents', [
@ -2033,9 +1903,6 @@ class NostrClient @@ -2033,9 +1903,6 @@ class NostrClient
}
$seenAddresses = [];
foreach ($merged as $event) {
if (!\is_object($event)) {
continue;
}
$addr = $this->wireMerge->nip33ParameterizedReplaceableAddress($event);
if ($addr !== null) {
$seenAddresses[$addr] = true;
@ -2070,7 +1937,7 @@ class NostrClient @@ -2070,7 +1937,7 @@ class NostrClient
sprintf('[longform_ingest] ingestLongform: exception in group %s: %s', (string) $gkey, $e->getMessage()),
[
'message' => $e->getMessage(),
'pubkey' => $g['pubkey'] ?? null,
'pubkey' => $g['pubkey'],
'trace' => $e->getTraceAsString(),
'relays' => $relaysForLog,
],

9
src/Service/NostrKeyHelper.php

@ -29,13 +29,4 @@ final readonly class NostrKeyHelper @@ -29,13 +29,4 @@ final readonly class NostrKeyHelper
return $this->key->convertPublicKeyToBech32($key);
}
public function convertPrivateKeyToBech32(string $key): string
{
return $this->key->convertPrivateKeyToBech32($key);
}
public function generatePrivateKey(): string
{
return $this->key->generatePrivateKey();
}
}

6
src/Service/NostrKind5DeletionFilter.php

@ -43,14 +43,12 @@ final class NostrKind5DeletionFilter @@ -43,14 +43,12 @@ final class NostrKind5DeletionFilter
*/
private function storedKindValues(): array
{
return [
return array_merge(KindsEnum::longformKindValues(), [
KindsEnum::METADATA->value,
KindsEnum::RELAY_LIST->value,
KindsEnum::PAYMENT_TARGETS->value,
KindsEnum::LONGFORM->value,
KindsEnum::LONGFORM_DRAFT->value,
KindsEnum::PUBLICATION_INDEX->value,
KindsEnum::CURATION_SET->value,
];
]);
}
}

17
src/Service/NostrLongformArticleStore.php

@ -22,6 +22,7 @@ final class NostrLongformArticleStore @@ -22,6 +22,7 @@ final class NostrLongformArticleStore
private readonly ManagerRegistry $managerRegistry,
private readonly LoggerInterface $logger,
private readonly NostrWireEventMerge $wireMerge,
private readonly ArticleMagazineRegistry $articleMagazineRegistry,
) {
}
@ -34,6 +35,15 @@ final class NostrLongformArticleStore @@ -34,6 +35,15 @@ final class NostrLongformArticleStore
return $this->entityManager->getRepository(Article::class)->findOneBy(['eventId' => $eventId]) !== null;
}
public function findByEventId(string $eventId): ?Article
{
if ($eventId === '') {
return null;
}
return $this->entityManager->getRepository(Article::class)->findOneBy(['eventId' => $eventId]);
}
public function findLatestByAuthorAndSlug(string $pubkey, string $slug): ?Article
{
$pubkey = strtolower($pubkey);
@ -87,6 +97,7 @@ final class NostrLongformArticleStore @@ -87,6 +97,7 @@ final class NostrLongformArticleStore
$target->setPublishedAt($source->getPublishedAt());
}
$target->setTopics($source->getTopics());
$target->setWikiKinds($source->getWikiKinds());
if ($source->getKind() !== null) {
$target->setKind($source->getKind());
}
@ -105,6 +116,7 @@ final class NostrLongformArticleStore @@ -105,6 +116,7 @@ final class NostrLongformArticleStore
]);
$this->entityManager->persist($article);
$this->entityManager->flush();
$this->articleMagazineRegistry->link($article);
} catch (\Exception $e) {
$this->logger->error('[longform_ingest] persistNewArticle failed: '.$e->getMessage(), [
'reason' => $reason,
@ -113,4 +125,9 @@ final class NostrLongformArticleStore @@ -113,4 +125,9 @@ final class NostrLongformArticleStore
$this->managerRegistry->resetManager();
}
}
public function linkExistingArticle(Article $article): void
{
$this->articleMagazineRegistry->link($article);
}
}

12
src/Service/NostrRelayFanoutTransport.php

@ -35,6 +35,11 @@ final readonly class NostrRelayFanoutTransport @@ -35,6 +35,11 @@ final readonly class NostrRelayFanoutTransport
) {
}
public function getRelayRequestTimeoutSec(): int
{
return $this->relayRequestFactory->getRelayRequestTimeoutSec();
}
/**
* @param list<string> $relayUrls
*
@ -58,9 +63,9 @@ final readonly class NostrRelayFanoutTransport @@ -58,9 +63,9 @@ final readonly class NostrRelayFanoutTransport
*
* @return array<string, mixed> Same shape as {@see Request::send()}
*/
public function sendSequential(RelaySet $relaySet, RequestMessage $requestMessage): array
public function sendSequential(RelaySet $relaySet, RequestMessage $requestMessage, ?int $overrideTimeoutSec = null): array
{
$request = $this->relayRequestFactory->createTimedRequest($relaySet, $requestMessage);
$request = $this->relayRequestFactory->createTimedRequest($relaySet, $requestMessage, $overrideTimeoutSec);
return $request->send();
}
@ -144,7 +149,7 @@ final readonly class NostrRelayFanoutTransport @@ -144,7 +149,7 @@ final readonly class NostrRelayFanoutTransport
}
if (microtime(true) >= $deadlineAt) {
foreach ($pending as $wss => $p) {
$this->logger->warning('nostr.article_discussion.relay_worker_soft_timeout', [
$this->logger->info('nostr.article_discussion.relay_worker_soft_timeout', [
'relay' => $wss,
'soft_deadline_sec' => self::DISCUSSION_PARALLEL_SOFT_DEADLINE_SEC,
]);
@ -167,6 +172,7 @@ final readonly class NostrRelayFanoutTransport @@ -167,6 +172,7 @@ final readonly class NostrRelayFanoutTransport
* One line per relay after {@see Request::send()}: errors vs message-type counts (EVENT, EOSE, …).
*
* @param array<string, mixed> $response
* @param int|null $overrideTimeoutSec when set, overrides the configured per-relay WebSocket timeout
*/
public function logWireResponseSummary(string $context, array $response): void
{

14
src/Service/NostrRelayQuery.php

@ -101,7 +101,10 @@ final readonly class NostrRelayQuery @@ -101,7 +101,10 @@ final readonly class NostrRelayQuery
foreach ($relayRes as $item) {
try {
if (!\is_object($item)) {
$this->logger->warning(sprintf(
// Non-object relay responses (connection drops, bad HTTP status, etc.)
// are expected for dead or misconfigured relays; INFO keeps them out of
// the warning stream without losing them from the log file.
$this->logger->info(sprintf(
'Invalid response item from %s',
self::relayLogLabel($relayUrl)
), [
@ -123,7 +126,9 @@ final readonly class NostrRelayQuery @@ -123,7 +126,9 @@ final readonly class NostrRelayQuery
}
break;
case 'AUTH':
$this->logger->warning(sprintf(
// AUTH challenges are expected from paid/restricted relays; we do not
// support NIP-42 signing, so this is a no-op but not an error.
$this->logger->info(sprintf(
'Relay %s requires authentication',
self::relayLogLabel($relayUrl)
), [
@ -133,8 +138,11 @@ final readonly class NostrRelayQuery @@ -133,8 +138,11 @@ final readonly class NostrRelayQuery
break;
case 'ERROR':
case 'NOTICE':
// Relay-level ERROR/NOTICE messages (rate limits, dropped connections,
// etc.) are external signals; INFO keeps them observable without
// polluting the warning tier.
$msg = (string) ($item->message ?? 'No message');
$this->logger->warning(sprintf(
$this->logger->info(sprintf(
'[%s] %s: %s',
self::relayLogLabel($relayUrl),
$item->type,

6
src/Service/NostrRelayRequestFactory.php

@ -26,12 +26,14 @@ final readonly class NostrRelayRequestFactory @@ -26,12 +26,14 @@ final readonly class NostrRelayRequestFactory
/**
* {@see Request::setTimeout()} drives per-relay WebSocket I/O for {@see Request::send()}.
*
* @param int|null $overrideTimeoutSec when set, uses this instead of the configured default
*/
public function createTimedRequest(RelaySet $relaySet, RequestMessage $requestMessage): Request
public function createTimedRequest(RelaySet $relaySet, RequestMessage $requestMessage, ?int $overrideTimeoutSec = null): Request
{
$request = new Request($relaySet, $requestMessage);
return $request->setTimeout($this->relayRequestTimeoutSec);
return $request->setTimeout($overrideTimeoutSec ?? $this->relayRequestTimeoutSec);
}
/**

8
src/Service/NostrShareMenuBuilder.php

@ -169,8 +169,10 @@ final class NostrShareMenuBuilder @@ -169,8 +169,10 @@ final class NostrShareMenuBuilder
if ($npub === '' || $slug === '' || !str_starts_with($npub, 'npub1')) {
return $this->siteWithRootMenu();
}
$list = $this->articleRepository->findBy(['slug' => $slug], ['createdAt' => 'DESC'], 1);
$article = $list[0] ?? null;
$article = $this->articleRepository->findLatestBySlugForTenant(
$slug,
$this->nostrKeyHelper->convertToHex($npub),
);
if ($article === null) {
return $this->siteWithRootMenu();
}
@ -184,7 +186,7 @@ final class NostrShareMenuBuilder @@ -184,7 +186,7 @@ final class NostrShareMenuBuilder
private function fromArticle(Article $article): NostrShareMenuContext
{
$npub = $this->nostrKeyHelper->convertPublicKeyToBech32((string) $article->getPubkey());
$kind = (int) ($article->getKind()?->value ?? 30023);
$kind = $article->getKind()->value;
$d = (string) ($article->getSlug() ?? '');
if ($d === '') {
return new NostrShareMenuContext(

22
src/Service/NostrWireEventMerge.php

@ -375,16 +375,18 @@ final readonly class NostrWireEventMerge @@ -375,16 +375,18 @@ final readonly class NostrWireEventMerge
if ($raw instanceof PublicationEventEntity) {
return $raw;
}
if (!\is_object($raw)) {
return null;
}
try {
$data = json_decode(json_encode($raw, \JSON_THROW_ON_ERROR), true, 512, \JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return null;
}
if (!\is_array($data)) {
if (\is_array($raw)) {
$data = $raw;
} elseif (\is_object($raw)) {
try {
$data = json_decode(json_encode($raw, \JSON_THROW_ON_ERROR), true, 512, \JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return null;
}
if (!\is_array($data)) {
return null;
}
} else {
return null;
}
$entity = new PublicationEventEntity();

10
src/Service/ProfileIdentityLinksBuilder.php

@ -78,8 +78,8 @@ final class ProfileIdentityLinksBuilder @@ -78,8 +78,8 @@ final class ProfileIdentityLinksBuilder
}
$seen[$id] = true;
$parts = explode('@', $id, 2);
$local = $parts[0] ?? '';
$domain = $parts[1] ?? '';
$local = $parts[0];
$domain = $parts[1];
if ($local === '' || $domain === '' || str_contains($domain, ' ')) {
continue;
}
@ -110,14 +110,14 @@ final class ProfileIdentityLinksBuilder @@ -110,14 +110,14 @@ final class ProfileIdentityLinksBuilder
}
$seen = [];
foreach ($rows as $r) {
$seen[strtolower((string) ($r['label'] ?? ''))] = true;
$seen[strtolower((string) $r['label'])] = true;
}
if (isset($seen[$siteNip05])) {
return $rows;
}
$parts = explode('@', $siteNip05, 2);
$local = $parts[0] ?? '';
$domain = $parts[1] ?? '';
$local = $parts[0];
$domain = $parts[1];
if ($local === '' || $domain === '' || str_contains($domain, ' ')) {
return $rows;
}

26
src/Service/ProfilePaymentLinksBuilder.php

@ -48,16 +48,14 @@ final class ProfilePaymentLinksBuilder @@ -48,16 +48,14 @@ final class ProfilePaymentLinksBuilder
if ($resolved['lightning_address'] !== null) {
$addr = $resolved['lightning_address'];
$norm = 'la:'.strtolower($addr);
if (!isset($seen[$norm])) {
$seen[$norm] = true;
$rows[] = [
'type' => self::TYPE_LIGHTNING_ADDRESS,
'type_label' => 'Lightning',
'label' => $addr,
'href' => 'lightning:'.$addr,
'sort' => 0,
];
}
$seen[$norm] = true;
$rows[] = [
'type' => self::TYPE_LIGHTNING_ADDRESS,
'type_label' => 'Lightning',
'label' => $addr,
'href' => 'lightning:'.$addr,
'sort' => 0,
];
}
if ($resolved['lnurl_pay'] !== null) {
@ -207,7 +205,7 @@ final class ProfilePaymentLinksBuilder @@ -207,7 +205,7 @@ final class ProfilePaymentLinksBuilder
{
$out = [];
foreach ($kind10133Events as $ev) {
if (!\is_object($ev) || (int) ($ev->kind ?? 0) !== KindsEnum::PAYMENT_TARGETS->value) {
if ((int) ($ev->kind ?? 0) !== KindsEnum::PAYMENT_TARGETS->value) {
continue;
}
$tags = self::normalizeTagsArray($ev->tags ?? null);
@ -296,12 +294,10 @@ final class ProfilePaymentLinksBuilder @@ -296,12 +294,10 @@ final class ProfilePaymentLinksBuilder
$r = array_values(
array_map(
static fn (mixed $v): string => (string) $v,
array_values($seq)
$seq
)
);
if ($r !== []) {
$out[] = $r;
}
$out[] = $r;
}
return $out;

25
src/Service/TenantContext.php

@ -0,0 +1,25 @@ @@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Service;
/**
* Current deployment's magazine tenant id (from config/unfold.yaml).
* Scopes magazine indices, article visibility, featured authors, and admin users on a shared MySQL.
*/
final readonly class TenantContext
{
public function __construct(
private string $magazineSlug,
) {
if ($magazineSlug === '' || !preg_match('/^[a-z0-9][a-z0-9-]{0,62}$/', $magazineSlug)) {
throw new \InvalidArgumentException('magazine_slug must be 1–63 lowercase alnum/hyphen characters.');
}
}
public function getMagazineSlug(): string
{
return $this->magazineSlug;
}
}

5
src/Service/TopicIndexService.php

@ -16,6 +16,7 @@ final class TopicIndexService @@ -16,6 +16,7 @@ final class TopicIndexService
public function __construct(
private readonly ArticleRepository $articleRepository,
private readonly MagazineContentService $magazineContent,
private readonly TenantContext $tenant,
) {
}
@ -32,7 +33,7 @@ final class TopicIndexService @@ -32,7 +33,7 @@ final class TopicIndexService
);
$featured = [];
foreach ($slugs as $s) {
$s = \strtolower(\trim($s));
$s = \strtolower(\trim((string) $s));
if ($s !== '') {
$featured[$s] = true;
}
@ -40,11 +41,13 @@ final class TopicIndexService @@ -40,11 +41,13 @@ final class TopicIndexService
$rows = $conn->fetchAllAssociative(
'SELECT a.slug, a.topics FROM article a
INNER JOIN article_magazine am ON am.article_id = a.id AND am.magazine_slug = :mag
WHERE a.topics IS NOT NULL
AND a.content IS NOT NULL
AND CHAR_LENGTH(a.content) > 250
AND a.event_status IN (:st)',
[
'mag' => $this->tenant->getMagazineSlug(),
'st' => [EventStatusEnum::PUBLISHED->value, EventStatusEnum::ARCHIVED->value],
],
[

46
src/Twig/ArticleCardCoverExtension.php

@ -19,9 +19,9 @@ final class ArticleCardCoverExtension extends AbstractExtension @@ -19,9 +19,9 @@ final class ArticleCardCoverExtension extends AbstractExtension
{
/**
* Used when the article has no image and the author has no (or no usable) NIP-01 {@see picture} URL.
* Same asset as the header mark so empty hero slots read as the site, not a blank gray field.
* The portrait painting is shown at low opacity with a CSS pattern overlay (see `.card-header--no-cover`).
*/
private const DEFAULT_PACKAGE_IMAGE = 'icons/favicon-96x96.png';
private const DEFAULT_PACKAGE_IMAGE = 'laeserin_logo.png';
private const OG_FALLBACK_PACKAGE_IMAGE = 'og-image.jpg';
@ -42,11 +42,34 @@ final class ArticleCardCoverExtension extends AbstractExtension @@ -42,11 +42,34 @@ final class ArticleCardCoverExtension extends AbstractExtension
{
return [
new TwigFunction('article_card_cover', $this->articleCardCover(...)),
new TwigFunction('prefetch_article_card_covers', $this->prefetchArticleCardCovers(...)),
new TwigFunction('article_og_image', $this->articleOgImage(...)),
new TwigFunction('site_og_image', $this->siteOgImage(...)),
];
}
/**
* Batch kind-0 profile lookups before a list of cards (one relay REQ per chunk, not per tile).
*
* @param iterable<mixed> $items Rows with optional `pubkey` (64-char hex)
*/
public function prefetchArticleCardCovers(iterable $items): void
{
$hexes = [];
foreach ($items as $item) {
if (\is_object($item) && isset($item->article)) {
$item = $item->article;
} elseif (\is_array($item) && isset($item['article'])) {
$item = $item['article'];
}
$hex = $this->pubkeyHexFromItem($item);
if ($hex !== null) {
$hexes[] = $hex;
}
}
$this->cacheService->prefetchMetadataForPubkeyHexes($hexes);
}
/**
* Branded site Open Graph image (home, category lists, base layout default): not tied to any article or author.
*
@ -167,4 +190,23 @@ final class ArticleCardCoverExtension extends AbstractExtension @@ -167,4 +190,23 @@ final class ArticleCardCoverExtension extends AbstractExtension
{
return $this->packages->getUrl(self::DEFAULT_PACKAGE_IMAGE);
}
private function pubkeyHexFromItem(mixed $item): ?string
{
$raw = null;
if (\is_object($item) && isset($item->pubkey)) {
$raw = $item->pubkey;
} elseif (\is_array($item) && isset($item['pubkey'])) {
$raw = $item['pubkey'];
}
if (!\is_string($raw)) {
return null;
}
$hex = strtolower(trim($raw));
if (64 !== \strlen($hex) || !ctype_xdigit($hex)) {
return null;
}
return $hex;
}
}

3
src/Twig/Components/IndexTabs.php

@ -14,8 +14,6 @@ class IndexTabs @@ -14,8 +14,6 @@ class IndexTabs
{
use DefaultActionTrait;
private $index;
#[LiveProp(writable: true)]
public int $activeTab = 1; // Default active tab
@ -35,7 +33,6 @@ class IndexTabs @@ -35,7 +33,6 @@ class IndexTabs
public function mount(EventEntity $index): void
{
$this->index = $index;
foreach ($index->getTags() as $tag) {
if (array_key_first($tag) === 'a') {
$ref = $tag[1];

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save