@ -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 (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 |
| 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`, **`magazine_slug`** (tenant id for shared MySQL), **relays** (`default_relay`, `article_relays`, `profile_relays`), theme | `config/unfold.yaml` (imported as Symfony parameters) |
| Site title, `npub`, `d_tag`, **relays** (`default_relay`, `article_relays`, `profile_relays`), theme | `config/unfold.yaml` (imported as Symfony parameters) |
| `MAGAZINE_PREWARM_PREFER_SLUGS` | `.env` / `.env.local` — optional comma-separated category slugs to prioritize in `app:prewarm` magazine phase (after the root). Use when the relay time budget would otherwise skip your updated category. |
| `DATABASE_URL`, `APP_SECRET`, `HTTP_PORT`, `MYSQL_*`, optional **`PREWARM_FLAGS`** (for the Docker `cron` service) | `.env` / `.env.local` (see `.env.dist`) |
| Cache pool definitions (`cache.replies`, `cache.drafts`, `cache.app`) | `config/packages/cache.yaml` |
@ -123,46 +123,37 @@ For a full **Nostr backfill** + one-shot prewarm, use **`make prewarm`** (or a h
@@ -123,46 +123,37 @@ 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:gitcitadel`** (GitCitadel site on the **`gitcitadel`** branch); 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:latest`; override with **`UNFOLD_DOCKER_IMAGE`**.
| Topic | Notes |
|-------|--------|
| `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`. |
| `compose.hub.yaml` | Defines **`php`** (FrankenPHP) + **`database`** (MySQL) + **`prewarm`** (same app image: **`app:prewarm` every 10 minutes**, like dev’s `docker/cron`). Optional: disable `prewarm` in Compose if you prefer a host `cron` only. |
| HTTP | **`HTTP_PUBLISH`** in `.env` maps **host** port → container **80** (default **9080**). Put a reverse proxy (e.g. Apache) in front; set **`TRUSTED_PROXIES`** to match your proxy (often include `127.0.0.0/8` and the Docker bridge CIDR, e.g. `172.16.0.0/12`). |
| Secrets | Real **`APP_SECRET`** and **`MYSQL_*`** (or external DB via `DATABASE_URL` if you change the file). Do not commit production `.env`. |
| `PREWARM_FLAGS` | Optional extra CLI args for the hub **`prewarm`** service (and dev **`cron`**). After editing `.env`, run `docker compose -f compose.hub.yaml up -d --force-recreate prewarm`. |
### Build, tag, and push (on your machine or CI)
From the **repository root**on the **`gitcitadel`** branch (same `Dockerfile` as local prod):
From the **repository root** (same `Dockerfile` as local prod):
```bash
# GitCitadel hub image (default in compose.hub.yaml)
- Use **`linux/amd64`** if the server is amd64; use **`arm64`** (or a matching `--platform`) for arm servers.
- 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`.
- The image name must match what the server will pull: either keep**`UNFOLD_DOCKER_IMAGE=YOUR_REGISTRY/unfold:TAG`** in server `.env`, or push to the default name **`silberengel/unfold:latest`**.
### Deploy on the server (pull, up, migrate)
@ -194,7 +185,7 @@ make -f Makefile.hub backfill # up + migrate + articles-get + prewarm-onc
@@ -194,7 +185,7 @@ make -f Makefile.hub backfill # up + migrate + articles-get + prewarm-onc
# Per-relay WebSocket I/O (seconds) in NostrClient; also default_socket_timeout during app:prewarm.
nostr_relay_request_timeout_sec:12
# 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'
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.'
name:'GitCitadel Homepage'
short_name:'GitCitadel Homepage'
description:'GitCitadel — Nostr-native open-source software development tools and infrastructure.'
og_headline:'Nostr, Curated Thoughtfully'
og_subheading:'Imwald Blog by Laeserin'
og_headline:'GitCitadel Homepage'
og_subheading:'Nostr-native publishing and development tools'
default_relay:'wss://thecitadel.nostr1.com'
default_relay:'wss://theforest.nostr1.com'
# Extra wss:// URLs for article sync (articles:get), comment threads (NIP-22 / getArticleDiscussion),
# and any request that merges the default set with author-specific relays. default_relay is first; duplicates ignored.
* 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');
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\(\)\.$#'
message: '#^PHPDoc tag @param references unknown parameter\: \$rawTags$#'
identifier: parameter.notFound
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\.$#'
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
count: 1
path: src/Service/MagazineContentService.php
-
message: '#^Cannot call method __invoke\(\) on callable\.$#'
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\.$#'
@ -57,8 +57,8 @@ final class CommentReplyController extends AbstractController
@@ -57,8 +57,8 @@ final class CommentReplyController extends AbstractController
@ -54,126 +37,41 @@ final class CacheService implements HighlightAuthorMetadataProvider, ResetInterf
@@ -54,126 +37,41 @@ final class CacheService implements HighlightAuthorMetadataProvider, ResetInterf
if ($authorHex === null) {
return $this->placeholderMetadataBundle($npub);
}
if (isset($this->requestBundlesByHex[$authorHex])) {
@ -190,7 +88,7 @@ final class CacheService implements HighlightAuthorMetadataProvider, ResetInterf
@@ -190,7 +88,7 @@ final class CacheService implements HighlightAuthorMetadataProvider, ResetInterf
continue;
}
$h = strtolower($hex);
if (!isset($bundlesByLowerHex[$h])) {
if (!isset($bundlesByLowerHex[$h]) || !\is_array($bundlesByLowerHex[$h])) {
continue;
}
$bundle = $bundlesByLowerHex[$h];
@ -219,6 +117,26 @@ final class CacheService implements HighlightAuthorMetadataProvider, ResetInterf
@@ -219,6 +117,26 @@ final class CacheService implements HighlightAuthorMetadataProvider, ResetInterf
@ -236,11 +154,15 @@ final class CacheService implements HighlightAuthorMetadataProvider, ResetInterf
@@ -236,11 +154,15 @@ final class CacheService implements HighlightAuthorMetadataProvider, ResetInterf
if ($seq === []) {
continue;
}
$r = array_map(
static fn (mixed $v): string => (string) $v,
array_values($seq)
$r = array_values(
array_map(
static fn (mixed $v): string => (string) $v,
array_values($seq)
)
);
$out[] = $r;
if ($r !== [] && (string) ($r[0] ?? '') !== '') {
$out[] = $r;
}
}
return $out;
@ -345,7 +267,7 @@ final class CacheService implements HighlightAuthorMetadataProvider, ResetInterf
@@ -345,7 +267,7 @@ final class CacheService implements HighlightAuthorMetadataProvider, ResetInterf
private function bundleFromKind0EventRow(Event $row, string $npub): array
@ -361,6 +283,11 @@ final class CacheService implements HighlightAuthorMetadataProvider, ResetInterf
@@ -361,6 +283,11 @@ final class CacheService implements HighlightAuthorMetadataProvider, ResetInterf
];
}
private function decodeKind0ContentObject(object $ev): \stdClass
private function decodeKind0ContentString(string $raw): \stdClass
{
try {
@ -399,4 +326,24 @@ final class CacheService implements HighlightAuthorMetadataProvider, ResetInterf
@@ -399,4 +326,24 @@ final class CacheService implements HighlightAuthorMetadataProvider, ResetInterf
];
}
/**
* @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) {
@ -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.
* The portrait painting is shown at low opacity with a CSS pattern overlay (see `.card-header--no-cover`).
* Same asset as the header mark so empty hero slots read as the site, not a blank gray field.
@ -42,34 +42,11 @@ final class ArticleCardCoverExtension extends AbstractExtension
@@ -42,34 +42,11 @@ 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
* Branded site Open Graph image (home, category lists, base layout default): not tied to any article or author.
*
@ -190,23 +167,4 @@ final class ArticleCardCoverExtension extends AbstractExtension
@@ -190,23 +167,4 @@ final class ArticleCardCoverExtension extends AbstractExtension