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], ], [