From 093759151fba2c42636e3ef079c1d23bbe7f96c8 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 27 May 2026 17:10:11 +0200 Subject: [PATCH 1/4] change relay settings --- config/unfold.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/unfold.yaml b/config/unfold.yaml index d10aabc..3c91571 100644 --- a/config/unfold.yaml +++ b/config/unfold.yaml @@ -13,11 +13,11 @@ parameters: og_headline: 'Nostr, Curated Thoughtfully' og_subheading: 'Imwald Blog by Laeserin' - default_relay: 'wss://theforest.nostr1.com' + 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 From e24cc4b8b500fa01bfe79240db70afa62594d30e Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 27 May 2026 18:36:09 +0200 Subject: [PATCH 2/4] fix slug bug --- src/Service/MagazineContentService.php | 9 ++++++--- src/Service/TopicIndexService.php | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/Service/MagazineContentService.php b/src/Service/MagazineContentService.php index 49a4e68..7887187 100644 --- a/src/Service/MagazineContentService.php +++ b/src/Service/MagazineContentService.php @@ -702,6 +702,7 @@ final class MagazineContentService */ public function collectFeaturedArticleSlugsForHome(array $categoryATags): array { + $seen = []; $out = []; foreach ($categoryATags as $row) { $coord = $row[1]; @@ -711,14 +712,16 @@ final class MagazineContentService 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; } /** diff --git a/src/Service/TopicIndexService.php b/src/Service/TopicIndexService.php index 37d5164..fcddeac 100644 --- a/src/Service/TopicIndexService.php +++ b/src/Service/TopicIndexService.php @@ -32,7 +32,7 @@ final class TopicIndexService ); $featured = []; foreach ($slugs as $s) { - $s = \strtolower(\trim($s)); + $s = \strtolower(\trim((string) $s)); if ($s !== '') { $featured[$s] = true; } From 1a35ca003fd4fd646c1648fb8562623cdaece593 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 27 May 2026 19:39:22 +0200 Subject: [PATCH 3/4] correct prewarm --- src/Command/PrewarmCommand.php | 14 ++- src/Service/MagazineContentService.php | 132 ++++++++++++++----------- 2 files changed, 83 insertions(+), 63 deletions(-) diff --git a/src/Command/PrewarmCommand.php b/src/Command/PrewarmCommand.php index 5d2242e..b3b229b 100644 --- a/src/Command/PrewarmCommand.php +++ b/src/Command/PrewarmCommand.php @@ -168,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 %d 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 %d 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 %d coordinate(s) (new rows + NIP-33 updates).', $n)); } $report = $this->magazineContent->buildCategoryArticleDbCoverageReport(); $missingCoords = $this->magazineContent->missingInDbCoordinatesFromCoverageReport($report); diff --git a/src/Service/MagazineContentService.php b/src/Service/MagazineContentService.php index 7887187..b94814f 100644 --- a/src/Service/MagazineContentService.php +++ b/src/Service/MagazineContentService.php @@ -360,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 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`. @@ -733,50 +801,7 @@ final class MagazineContentService */ 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]; - 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; - } - + $orderedCoords = $this->collectRootHeadlineLongformCoordinates(); if ($orderedCoords === []) { return ['tiles' => []]; } @@ -786,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) { From 4cb4cc1b927205a76e1d55418cfa1d518e29e6c5 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 28 May 2026 06:52:40 +0200 Subject: [PATCH 4/4] make the database sharable --- README.md | 17 +++- assets/controllers/progress_bar_controller.js | 16 +++- .../user_highlight_tooltip_controller.js | 23 +++-- config/packages/cache.yaml | 2 +- config/services.yaml | 4 + config/unfold.yaml | 4 + migrations/Version20260528140000.php | 53 +++++++++++ src/Command/ElevateUserCommand.php | 9 +- .../Administration/RoleController.php | 2 +- src/Controller/ArticleController.php | 12 ++- src/Entity/ArticleMagazine.php | 41 +++++++++ src/Entity/FeaturedAuthor.php | 23 ++++- src/Entity/User.php | 18 +++- src/Nostr/MagazineEventKeys.php | 29 +++++-- src/Repository/ArticleHighlightRepository.php | 14 ++- src/Repository/ArticleMagazineRepository.php | 38 ++++++++ src/Repository/ArticleRepository.php | 87 ++++++++++++++++--- src/Repository/FeaturedAuthorRepository.php | 39 +++++++-- src/Repository/UserEntityRepository.php | 15 +++- src/Security/UserDTOProvider.php | 28 +++--- src/Service/ArticleMagazineRegistry.php | 25 ++++++ src/Service/FeaturedAuthorSync.php | 4 +- src/Service/MagazineIndexStore.php | 13 +-- src/Service/Nip09DeletionApplier.php | 3 +- src/Service/NostrClient.php | 6 ++ src/Service/NostrLongformArticleStore.php | 16 ++++ src/Service/NostrShareMenuBuilder.php | 6 +- src/Service/TenantContext.php | 25 ++++++ src/Service/TopicIndexService.php | 3 + 29 files changed, 487 insertions(+), 88 deletions(-) create mode 100644 migrations/Version20260528140000.php create mode 100644 src/Entity/ArticleMagazine.php create mode 100644 src/Repository/ArticleMagazineRepository.php create mode 100644 src/Service/ArticleMagazineRegistry.php create mode 100644 src/Service/TenantContext.php diff --git a/README.md b/README.md index c667f42..d47a7e7 100644 --- a/README.md +++ b/README.md @@ -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 | 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,6 +123,17 @@ 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) diff --git a/assets/controllers/progress_bar_controller.js b/assets/controllers/progress_bar_controller.js index f227cc5..970f61a 100644 --- a/assets/controllers/progress_bar_controller.js +++ b/assets/controllers/progress_bar_controller.js @@ -103,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); } diff --git a/assets/controllers/user_highlight_tooltip_controller.js b/assets/controllers/user_highlight_tooltip_controller.js index 6a7f52d..dc27a1a 100644 --- a/assets/controllers/user_highlight_tooltip_controller.js +++ b/assets/controllers/user_highlight_tooltip_controller.js @@ -58,7 +58,8 @@ export default class extends Controller { 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; } @@ -75,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; } @@ -96,7 +97,7 @@ export default class extends Controller { this._scheduleHide(); }; - this._onFocus = (e) => { + this._onFocus ??= (e) => { const t = e.target; if (!(t instanceof Element)) { return; @@ -107,7 +108,7 @@ 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; @@ -125,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(); } @@ -181,9 +188,7 @@ 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(); if (this.tip) { this.tip.removeEventListener('mouseenter', this._onTipEnter); diff --git a/config/packages/cache.yaml b/config/packages/cache.yaml index 3a53955..e729618 100644 --- a/config/packages/cache.yaml +++ b/config/packages/cache.yaml @@ -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 diff --git a/config/services.yaml b/config/services.yaml index 90579d6..34d2e7d 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -81,6 +81,10 @@ services: tags: - { name: kernel.reset, method: reset } + App\Service\TenantContext: + arguments: + $magazineSlug: '%magazine_slug%' + when@test: services: Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler: diff --git a/config/unfold.yaml b/config/unfold.yaml index 3c91571..c643c91 100644 --- a/config/unfold.yaml +++ b/config/unfold.yaml @@ -6,6 +6,10 @@ parameters: # 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: 'imwald' + 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.' diff --git a/migrations/Version20260528140000.php b/migrations/Version20260528140000.php new file mode 100644 index 0000000..0ccf176 --- /dev/null +++ b/migrations/Version20260528140000.php @@ -0,0 +1,53 @@ +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'); + } +} diff --git a/src/Command/ElevateUserCommand.php b/src/Command/ElevateUserCommand.php index 0b21de1..58969ff 100644 --- a/src/Command/ElevateUserCommand.php +++ b/src/Command/ElevateUserCommand.php @@ -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; )] 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 } /** @var User|null $user */ - $user = $this->entityManager->getRepository(User::class)->findOneBy(['npub' => $npub]); + $user = $this->userRepository->findOneByNpub($npub); if (!$user) { return Command::FAILURE; } diff --git a/src/Controller/Administration/RoleController.php b/src/Controller/Administration/RoleController.php index d6d3840..2de4a49 100644 --- a/src/Controller/Administration/RoleController.php +++ b/src/Controller/Administration/RoleController.php @@ -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(); diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index df56627..63d4cd8 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -322,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, @@ -627,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', diff --git a/src/Entity/ArticleMagazine.php b/src/Entity/ArticleMagazine.php new file mode 100644 index 0000000..9b9d006 --- /dev/null +++ b/src/Entity/ArticleMagazine.php @@ -0,0 +1,41 @@ +magazineSlug = $magazineSlug; + $this->article = $article; + } + + public function getMagazineSlug(): string + { + return $this->magazineSlug; + } + + public function getArticle(): Article + { + return $this->article; + } +} diff --git a/src/Entity/FeaturedAuthor.php b/src/Entity/FeaturedAuthor.php index 7b4a1d4..689b3d3 100644 --- a/src/Entity/FeaturedAuthor.php +++ b/src/Entity/FeaturedAuthor.php @@ -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 #[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 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; diff --git a/src/Entity/User.php b/src/Entity/User.php index a3423af..858cf7f 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -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 #[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 $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 { 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 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']; diff --git a/src/Nostr/MagazineEventKeys.php b/src/Nostr/MagazineEventKeys.php index ea1e1ab..c96a976 100644 --- a/src/Nostr/MagazineEventKeys.php +++ b/src/Nostr/MagazineEventKeys.php @@ -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 diff --git a/src/Repository/ArticleHighlightRepository.php b/src/Repository/ArticleHighlightRepository.php index 2b4c4cb..fe72692 100644 --- a/src/Repository/ArticleHighlightRepository.php +++ b/src/Repository/ArticleHighlightRepository.php @@ -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,8 +16,10 @@ 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); } @@ -41,7 +44,14 @@ class ArticleHighlightRepository extends ServiceEntityRepository $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') diff --git a/src/Repository/ArticleMagazineRepository.php b/src/Repository/ArticleMagazineRepository.php new file mode 100644 index 0000000..d2dee2f --- /dev/null +++ b/src/Repository/ArticleMagazineRepository.php @@ -0,0 +1,38 @@ + + */ +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(); + } +} diff --git a/src/Repository/ArticleRepository.php b/src/Repository/ArticleRepository.php index 3374e10..8f833b8 100644 --- a/src/Repository/ArticleRepository.php +++ b/src/Repository/ArticleRepository.php @@ -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 */ 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(); @@ -56,7 +59,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)); @@ -106,7 +109,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 +150,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 +178,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 */ public function findDistinctAuthorPubkeys(): array { - return $this->createQueryBuilder('a') + return $this->tenantQueryBuilder('a') ->select('a.pubkey') ->distinct() ->where('a.pubkey IS NOT NULL') @@ -188,13 +193,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,9 +209,27 @@ 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') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + } + + public function findLatestBySlugForTenant(string $slug, string $npubHex): ?Article + { + $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(1) ->getQuery() @@ -214,7 +238,7 @@ class ArticleRepository extends ServiceEntityRepository 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') @@ -226,7 +250,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) @@ -234,6 +258,27 @@ class ArticleRepository extends ServiceEntityRepository ->getSingleScalarResult(); } + public function countForMagazine(): int + { + return (int) $this->tenantQueryBuilder('a') + ->select('COUNT(a.id)') + ->getQuery() + ->getSingleScalarResult(); + } + + /** + * @return list
+ */ + 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. @@ -242,7 +287,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)') @@ -285,7 +330,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') @@ -336,4 +381,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; + } } diff --git a/src/Repository/FeaturedAuthorRepository.php b/src/Repository/FeaturedAuthorRepository.php index e135a9d..2c67cd6 100644 --- a/src/Repository/FeaturedAuthorRepository.php +++ b/src/Repository/FeaturedAuthorRepository.php @@ -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; */ 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 { $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 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 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 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 { 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 + */ + public function findAllForTenant(): array + { + return $this->findBy(['magazineSlug' => $this->tenant->getMagazineSlug()]); + } } diff --git a/src/Repository/UserEntityRepository.php b/src/Repository/UserEntityRepository.php index e1e5492..54b2130 100644 --- a/src/Repository/UserEntityRepository.php +++ b/src/Repository/UserEntityRepository.php @@ -3,13 +3,24 @@ namespace App\Repository; use App\Entity\User; +use App\Service\TenantContext; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; class UserEntityRepository extends ServiceEntityRepository { - public function __construct(ManagerRegistry $registry) - { + public function __construct( + ManagerRegistry $registry, + private readonly TenantContext $tenant, + ) { parent::__construct($registry, User::class); } + + public function findOneByNpub(string $npub): ?User + { + return $this->findOneBy([ + 'magazineSlug' => $this->tenant->getMagazineSlug(), + 'npub' => $npub, + ]); + } } diff --git a/src/Security/UserDTOProvider.php b/src/Security/UserDTOProvider.php index 55d3b6f..086723d 100644 --- a/src/Security/UserDTOProvider.php +++ b/src/Security/UserDTOProvider.php @@ -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 { 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 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 */ 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 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(); diff --git a/src/Service/ArticleMagazineRegistry.php b/src/Service/ArticleMagazineRegistry.php new file mode 100644 index 0000000..76d5bad --- /dev/null +++ b/src/Service/ArticleMagazineRegistry.php @@ -0,0 +1,25 @@ +articleMagazineRepository->link($this->tenant->getMagazineSlug(), $article); + } +} diff --git a/src/Service/FeaturedAuthorSync.php b/src/Service/FeaturedAuthorSync.php index 834e9d8..7355d9f 100644 --- a/src/Service/FeaturedAuthorSync.php +++ b/src/Service/FeaturedAuthorSync.php @@ -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 } $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 $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)); diff --git a/src/Service/MagazineIndexStore.php b/src/Service/MagazineIndexStore.php index 6fbe72c..b7d6275 100644 --- a/src/Service/MagazineIndexStore.php +++ b/src/Service/MagazineIndexStore.php @@ -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 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 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 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 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 if ($slug === '') { return; } - $key = MagazineEventKeys::magazineCategory($slug); + $key = MagazineEventKeys::magazineCategory($this->tenant->getMagazineSlug(), $slug); $this->removeByCoreKey($key); } @@ -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); } diff --git a/src/Service/Nip09DeletionApplier.php b/src/Service/Nip09DeletionApplier.php index ae9db03..0c9e6d5 100644 --- a/src/Service/Nip09DeletionApplier.php +++ b/src/Service/Nip09DeletionApplier.php @@ -35,6 +35,7 @@ final class Nip09DeletionApplier private readonly ParameterBagInterface $params, private readonly LoggerInterface $logger, private readonly NostrKeyHelper $nostrKeyHelper, + private readonly TenantContext $tenant, ) { } @@ -403,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; } diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index a09707c..691281b 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -1348,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(), @@ -1404,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(); @@ -1420,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), diff --git a/src/Service/NostrLongformArticleStore.php b/src/Service/NostrLongformArticleStore.php index 75d66ff..0c530ca 100644 --- a/src/Service/NostrLongformArticleStore.php +++ b/src/Service/NostrLongformArticleStore.php @@ -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 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); @@ -106,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, @@ -114,4 +125,9 @@ final class NostrLongformArticleStore $this->managerRegistry->resetManager(); } } + + public function linkExistingArticle(Article $article): void + { + $this->articleMagazineRegistry->link($article); + } } diff --git a/src/Service/NostrShareMenuBuilder.php b/src/Service/NostrShareMenuBuilder.php index 5537a2a..e4fed2d 100644 --- a/src/Service/NostrShareMenuBuilder.php +++ b/src/Service/NostrShareMenuBuilder.php @@ -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(); } diff --git a/src/Service/TenantContext.php b/src/Service/TenantContext.php new file mode 100644 index 0000000..9073af9 --- /dev/null +++ b/src/Service/TenantContext.php @@ -0,0 +1,25 @@ +magazineSlug; + } +} diff --git a/src/Service/TopicIndexService.php b/src/Service/TopicIndexService.php index fcddeac..8086658 100644 --- a/src/Service/TopicIndexService.php +++ b/src/Service/TopicIndexService.php @@ -16,6 +16,7 @@ final class TopicIndexService public function __construct( private readonly ArticleRepository $articleRepository, private readonly MagazineContentService $magazineContent, + private readonly TenantContext $tenant, ) { } @@ -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], ], [