diff --git a/.env.dist b/.env.dist index 249a001..7c14fe1 100644 --- a/.env.dist +++ b/.env.dist @@ -46,6 +46,8 @@ MYSQL_ROOT_PASSWORD=root_password # PREWARM_FLAGS= # Comma-separated magazine category #d slugs to refresh first when app:prewarm runs out of time before all categories (see MagazineRefresher). # MAGAZINE_PREWARM_PREFER_SLUGS= +# compose.hub.yaml (gitcitadel): no bundled DB — uses imwald MySQL on Docker network unfold_default. +# DATABASE_HOST=unfold-mysql # compose.hub.yaml: Apache reverse-proxies to 127.0.0.1:9085 (gitcitadel.imwald.eu vhost). HTTP_PUBLISH=127.0.0.1:9085 # Optional: silence verbose Symfony deprecation output in the CLI. See Symfony docs for values (max[direct]=N, etc.). diff --git a/README.md b/README.md index 9e63245..1353fff 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) @@ -131,9 +142,9 @@ The app runs as a **pre-built** image (no app source on the server). The server | Topic | Notes | |-------|--------| -| `compose.hub.yaml` | Compose project **`gitcitadel`** (containers e.g. `gitcitadel-php-1`). Defines **`php`** (FrankenPHP) + **`database`** (MySQL) + **`prewarm`** (same app image: **`app:prewarm` every 10 minutes**, like dev’s `docker/cron`). Optional: disable `prewarm` in Compose if you prefer a host `cron` only. | +| `compose.hub.yaml` | Compose project **`gitcitadel`** (containers e.g. `gitcitadel-php-1`). **`php`** + **`prewarm`** only — **no bundled MySQL**; connects to the imwald hub DB (`unfold-mysql` on network `unfold_default`). Start the **imwald** hub stack first. | | HTTP | **`HTTP_PUBLISH`** in `.env` maps **host** port → container **80** (default **`127.0.0.1:9085`** for `gitcitadel.imwald.eu`). Put a reverse proxy (e.g. Apache) in front; set **`TRUSTED_PROXIES`** to match your proxy (often include `127.0.0.0/8` and the Docker bridge CIDR, e.g. `172.16.0.0/12`). | -| Secrets | Real **`APP_SECRET`** and **`MYSQL_*`** (or external DB via `DATABASE_URL` if you change the file). Do not commit production `.env`. | +| Secrets | Real **`APP_SECRET`** and **`MYSQL_*`** must match the **imwald** hub stack (same DB user/database). Optional **`DATABASE_HOST`** (default `unfold-mysql`). Do not commit production `.env`. | | `PREWARM_FLAGS` | Optional extra CLI args for the hub **`prewarm`** service (and dev **`cron`**). After editing `.env`, run `docker compose -f compose.hub.yaml up -d --force-recreate prewarm`. | ### Build, tag, and push (on your machine or CI) 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/compose.hub.yaml b/compose.hub.yaml index fdcc47e..26cb8bf 100644 --- a/compose.hub.yaml +++ b/compose.hub.yaml @@ -6,11 +6,12 @@ # docker compose -f compose.hub.yaml exec php php bin/console doctrine:migrations:migrate --no-interaction # Optional: copy Makefile.hub into the same directory, then: make -f Makefile.hub help # -# Services: `php` (web), `database` (MySQL), `prewarm` (same image; `app:prewarm` every 10 min — see README). -# Optional: PREWARM_FLAGS in .env (same as dev `cron` service), then `docker compose up -d --force-recreate prewarm`. +# Services: `php` (web), `prewarm` (same app image; `app:prewarm` every 10 min — see README). +# No bundled `database` — this stack uses the imwald hub MySQL (compose project `unfold`, network +# `unfold_default`, container `unfold-mysql`). Start imwald first. # -# Required in .env: APP_SECRET. Set MYSQL_* (or replace DATABASE_URL after editing this file) if you -# use the bundled database. For TLS in front, set TRUSTED_PROXIES to include your reverse proxy CIDR. +# Required in .env: APP_SECRET, MYSQL_* matching the imwald stack. Optional DATABASE_HOST (default +# unfold-mysql). For TLS in front, set TRUSTED_PROXIES to include your reverse proxy CIDR. # # DEPLOY: copy THIS file + a .env to the server; do NOT reuse the imwald compose.hub.yaml. # This file has name: gitcitadel (→ containers gitcitadel-php-1 etc.), port 9085. @@ -34,36 +35,29 @@ services: APP_SECRET: ${APP_SECRET} TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.0/8,10.0.0.0/8} SERVER_NAME: ${SERVER_NAME:-:80} - DATABASE_URL: mysql://${MYSQL_USER:-unfold_user}:${MYSQL_PASSWORD:-password}@database:3306/${MYSQL_DATABASE:-unfold_db}?serverVersion=${MYSQL_VERSION:-8.0.36}&charset=${MYSQL_CHARSET:-utf8mb4} + DATABASE_URL: mysql://${MYSQL_USER:-unfold_user}:${MYSQL_PASSWORD:-password}@${DATABASE_HOST:-unfold-mysql}:3306/${MYSQL_DATABASE:-unfold_db}?serverVersion=${MYSQL_VERSION:-8.0.36}&charset=${MYSQL_CHARSET:-utf8mb4} volumes: - caddy_data:/data - caddy_config:/config ports: - "${HTTP_PUBLISH:-127.0.0.1:9085}:80/tcp" - # Caddy/FrankenPHP only listen after the entrypoint finishes DB wait + migrations — allow a slow - # first MySQL + migrate on a small host (avoids "unhealthy" + failed `up` for dependents). - # Liveness: GET /health (see HealthController), not /. + networks: + - default + - imwald_db healthcheck: test: ["CMD", "curl", "-fsS", "http://127.0.0.1/health", "-o", "/dev/null"] interval: 10s timeout: 5s retries: 10 start_period: 180s - depends_on: - database: - condition: service_healthy prewarm: image: ${UNFOLD_DOCKER_IMAGE:-silberengel/unfold:gitcitadel} pull_policy: always restart: unless-stopped - # The app image healthchecks HTTP on :80; this service is CLI-only (no Caddy in this container). healthcheck: disable: true working_dir: /app - # Do not wait on `curl http://php/`: Caddy in the `php` container is often only reachable on - # 127.0.0.1 from *inside* that container, so cross-container HTTP can hang. Wait on the same MySQL - # instead: `php` runs migrations in its entrypoint; the migration table is the readiness signal. entrypoint: ["/bin/sh", "-c"] command: - | @@ -72,7 +66,7 @@ services: sleep 2 done until php bin/console dbal:run-sql -q "SELECT 1 FROM doctrine_migration_versions LIMIT 1" 2>/dev/null; do - echo "prewarm: waiting for migrations (php entrypoint)…" + echo "prewarm: waiting for migrations…" sleep 3 done while true; do @@ -84,31 +78,20 @@ services: APP_SECRET: ${APP_SECRET} TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.0/8,10.0.0.0/8} SERVER_NAME: ${SERVER_NAME:-:80} - DATABASE_URL: mysql://${MYSQL_USER:-unfold_user}:${MYSQL_PASSWORD:-password}@database:3306/${MYSQL_DATABASE:-unfold_db}?serverVersion=${MYSQL_VERSION:-8.0.36}&charset=${MYSQL_CHARSET:-utf8mb4} + DATABASE_URL: mysql://${MYSQL_USER:-unfold_user}:${MYSQL_PASSWORD:-password}@${DATABASE_HOST:-unfold-mysql}:3306/${MYSQL_DATABASE:-unfold_db}?serverVersion=${MYSQL_VERSION:-8.0.36}&charset=${MYSQL_CHARSET:-utf8mb4} PREWARM_FLAGS: ${PREWARM_FLAGS:-} + networks: + - default + - imwald_db depends_on: - database: - condition: service_healthy php: condition: service_started - database: - image: mysql:${MYSQL_VERSION:-8.0.36} - restart: unless-stopped - environment: - MYSQL_DATABASE: ${MYSQL_DATABASE:-unfold_db} - MYSQL_USER: ${MYSQL_USER:-unfold_user} - MYSQL_PASSWORD: ${MYSQL_PASSWORD:-password} - MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root_password} - healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] - timeout: 5s - retries: 5 - start_period: 60s - volumes: - - database_data:/var/lib/mysql:rw +networks: + imwald_db: + external: true + name: unfold_default volumes: caddy_data: caddy_config: - database_data: 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 04c226a..93acde9 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: 'gitcitadel' + name: 'GitCitadel Homepage' short_name: 'GitCitadel Homepage' description: 'GitCitadel — Nostr-native open-source software development tools and infrastructure.' @@ -13,7 +17,7 @@ parameters: og_headline: 'GitCitadel Homepage' og_subheading: 'Nostr-native publishing and development tools' - 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: [ 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], ], [