Browse Source

make the database sharable

gitcitadel
Silberengel 2 weeks ago
parent
commit
4cb4cc1b92
  1. 17
      README.md
  2. 16
      assets/controllers/progress_bar_controller.js
  3. 21
      assets/controllers/user_highlight_tooltip_controller.js
  4. 2
      config/packages/cache.yaml
  5. 4
      config/services.yaml
  6. 4
      config/unfold.yaml
  7. 53
      migrations/Version20260528140000.php
  8. 9
      src/Command/ElevateUserCommand.php
  9. 2
      src/Controller/Administration/RoleController.php
  10. 12
      src/Controller/ArticleController.php
  11. 41
      src/Entity/ArticleMagazine.php
  12. 23
      src/Entity/FeaturedAuthor.php
  13. 18
      src/Entity/User.php
  14. 29
      src/Nostr/MagazineEventKeys.php
  15. 14
      src/Repository/ArticleHighlightRepository.php
  16. 38
      src/Repository/ArticleMagazineRepository.php
  17. 87
      src/Repository/ArticleRepository.php
  18. 39
      src/Repository/FeaturedAuthorRepository.php
  19. 15
      src/Repository/UserEntityRepository.php
  20. 26
      src/Security/UserDTOProvider.php
  21. 25
      src/Service/ArticleMagazineRegistry.php
  22. 4
      src/Service/FeaturedAuthorSync.php
  23. 13
      src/Service/MagazineIndexStore.php
  24. 3
      src/Service/Nip09DeletionApplier.php
  25. 6
      src/Service/NostrClient.php
  26. 16
      src/Service/NostrLongformArticleStore.php
  27. 6
      src/Service/NostrShareMenuBuilder.php
  28. 25
      src/Service/TenantContext.php
  29. 3
      src/Service/TopicIndexService.php

17
README.md

@ -10,8 +10,8 @@ A Symfony + FrankenPHP site that **reads Nostr long-form articles (kind 30023)**
| Data | Storage | | Data | Storage |
|------|---------| |------|---------|
| Published articles (30023/24) | **MySQL** `article` table (from `articles:get` / relay sync) | | 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` (filled by `app:prewarm` and on-demand fetches) | | 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) | | 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` | | 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 | | 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 | | 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. | | `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`) | | `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` | | 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`). **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) ## Production / Hub (remote server)

16
assets/controllers/progress_bar_controller.js

@ -103,15 +103,27 @@ export default class extends Controller {
} }
handleTouchStart(event) { handleTouchStart(event) {
const touch = event.changedTouches[0]; const touch = event.changedTouches?.[0];
if (!touch) {
return;
}
this.touchStartX = touch.screenX; this.touchStartX = touch.screenX;
this.touchStartY = touch.screenY; this.touchStartY = touch.screenY;
} }
handleTouchEnd(event) { 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 dx = Math.abs(touch.screenX - this.touchStartX);
const dy = Math.abs(touch.screenY - this.touchStartY); const dy = Math.abs(touch.screenY - this.touchStartY);
this.touchStartX = undefined;
this.touchStartY = undefined;
if (dx < 10 && dy < 10) { if (dx < 10 && dy < 10) {
this.handleInteraction(event); this.handleInteraction(event);
} }

21
assets/controllers/user_highlight_tooltip_controller.js

@ -58,7 +58,8 @@ export default class extends Controller {
this._hideT = 0; this._hideT = 0;
this._inTip = false; this._inTip = false;
this._onOver = (e) => { // Stable handler refs (??=) so reconnect without disconnect does not stack listeners.
this._onOver ??= (e) => {
if (!(e instanceof MouseEvent)) { if (!(e instanceof MouseEvent)) {
return; return;
} }
@ -75,7 +76,7 @@ export default class extends Controller {
this._show(/** @type {HTMLElement} */ (m), e); this._show(/** @type {HTMLElement} */ (m), e);
} }
}; };
this._onOut = (e) => { this._onOut ??= (e) => {
if (!(e instanceof MouseEvent)) { if (!(e instanceof MouseEvent)) {
return; return;
} }
@ -96,7 +97,7 @@ export default class extends Controller {
this._scheduleHide(); this._scheduleHide();
}; };
this._onFocus = (e) => { this._onFocus ??= (e) => {
const t = e.target; const t = e.target;
if (!(t instanceof Element)) { if (!(t instanceof Element)) {
return; return;
@ -107,7 +108,7 @@ export default class extends Controller {
this._show(/** @type {HTMLElement} */ (m), e); this._show(/** @type {HTMLElement} */ (m), e);
} }
}; };
this._onBlur = (e) => { this._onBlur ??= (e) => {
const t = e.target; const t = e.target;
if (!(t instanceof Node)) { if (!(t instanceof Node)) {
return; return;
@ -125,21 +126,27 @@ export default class extends Controller {
this._scheduleHide(); 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('mouseover', this._onOver);
this.element.addEventListener('mouseout', this._onOut); this.element.addEventListener('mouseout', this._onOut);
this.element.addEventListener('focusin', this._onFocus); this.element.addEventListener('focusin', this._onFocus);
this.element.addEventListener('focusout', this._onBlur); this.element.addEventListener('focusout', this._onBlur);
this._onResize = () => { this._onResize ??= () => {
if (this.activeMark) { if (this.activeMark) {
this._place(this.activeMark); this._place(this.activeMark);
} }
}; };
window.removeEventListener('resize', this._onResize);
window.addEventListener('resize', this._onResize); window.addEventListener('resize', this._onResize);
this._onHashChange = () => { this._onHashChange ??= () => {
this._scrollToHashHighlight(); this._scrollToHashHighlight();
}; };
window.removeEventListener('hashchange', this._onHashChange);
window.addEventListener('hashchange', this._onHashChange); window.addEventListener('hashchange', this._onHashChange);
this._scrollToHashHighlight(); this._scrollToHashHighlight();
} }
@ -181,9 +188,7 @@ export default class extends Controller {
this.element.removeEventListener('focusin', this._onFocus); this.element.removeEventListener('focusin', this._onFocus);
this.element.removeEventListener('focusout', this._onBlur); this.element.removeEventListener('focusout', this._onBlur);
window.removeEventListener('resize', this._onResize); window.removeEventListener('resize', this._onResize);
if (this._onHashChange) {
window.removeEventListener('hashchange', this._onHashChange); window.removeEventListener('hashchange', this._onHashChange);
}
this._cancelHide(); this._cancelHide();
if (this.tip) { if (this.tip) {
this.tip.removeEventListener('mouseenter', this._onTipEnter); this.tip.removeEventListener('mouseenter', this._onTipEnter);

2
config/packages/cache.yaml

@ -1,7 +1,7 @@
framework: framework:
cache: cache:
# Unique name of your app: used to compute stable namespaces for cache keys. # 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 # Use filesystem cache
app: cache.adapter.filesystem app: cache.adapter.filesystem

4
config/services.yaml

@ -81,6 +81,10 @@ services:
tags: tags:
- { name: kernel.reset, method: reset } - { name: kernel.reset, method: reset }
App\Service\TenantContext:
arguments:
$magazineSlug: '%magazine_slug%'
when@test: when@test:
services: services:
Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler: Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler:

4
config/unfold.yaml

@ -6,6 +6,10 @@ parameters:
# Per-relay WebSocket I/O (seconds) in NostrClient; also default_socket_timeout during app:prewarm. # Per-relay WebSocket I/O (seconds) in NostrClient; also default_socket_timeout during app:prewarm.
nostr_relay_request_timeout_sec: 12 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' name: 'Nostr, Curated Thoughtfully'
short_name: 'Imwald Blog' 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.' 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.'

53
migrations/Version20260528140000.php

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

9
src/Command/ElevateUserCommand.php

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

2
src/Controller/Administration/RoleController.php

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

12
src/Controller/ArticleController.php

@ -322,19 +322,16 @@ class ArticleController extends AbstractController
public function article( public function article(
string $npub, string $npub,
string $slug, string $slug,
EntityManagerInterface $entityManager, ArticleRepository $articleRepository,
CacheService $cacheService, CacheService $cacheService,
ArticleCommentThreadLoader $commentThreadLoader, ArticleCommentThreadLoader $commentThreadLoader,
ArticleBodyHtmlRenderer $articleBodyHtmlRenderer, ArticleBodyHtmlRenderer $articleBodyHtmlRenderer,
NostrKeyHelper $nostrKeyHelper, NostrKeyHelper $nostrKeyHelper,
): Response { ): Response {
$article = $this->loadLatestArticleBySlug($entityManager, $slug); $article = $articleRepository->findLatestBySlugForTenant($slug, $nostrKeyHelper->convertToHex($npub));
if ($article === null) { if ($article === null) {
throw $this->createNotFoundException('The article could not be found'); 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( return $this->renderArticle(
$article, $article,
@ -627,14 +624,15 @@ class ArticleController extends AbstractController
$perPage = 25; $perPage = 25;
$page = max(1, $request->query->getInt('page', 1)); $page = max(1, $request->query->getInt('page', 1));
$offset = ($page - 1) * $perPage; $offset = ($page - 1) * $perPage;
/** @var ArticleRepository $repo */
$repo = $entityManager->getRepository(Article::class); $repo = $entityManager->getRepository(Article::class);
$total = $repo->count([]); $total = $repo->countForMagazine();
$lastPage = max(1, (int) ceil($total / $perPage)); $lastPage = max(1, (int) ceil($total / $perPage));
if ($page > $lastPage) { if ($page > $lastPage) {
$page = $lastPage; $page = $lastPage;
$offset = ($page - 1) * $perPage; $offset = ($page - 1) * $perPage;
} }
$articles = $repo->findBy([], ['createdAt' => 'DESC'], $perPage, $offset); $articles = $repo->findForMagazinePaginated($perPage, $offset);
$category = (object) [ $category = (object) [
'title' => 'Community Articles', 'title' => 'Community Articles',

41
src/Entity/ArticleMagazine.php

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

23
src/Entity/FeaturedAuthor.php

@ -14,6 +14,8 @@ use Doctrine\ORM\Mapping as ORM;
*/ */
#[ORM\Entity(repositoryClass: FeaturedAuthorRepository::class)] #[ORM\Entity(repositoryClass: FeaturedAuthorRepository::class)]
#[ORM\Table(name: 'featured_author')] #[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 class FeaturedAuthor
{ {
#[ORM\Id] #[ORM\Id]
@ -21,13 +23,16 @@ class FeaturedAuthor
#[ORM\Column] #[ORM\Column]
private ?int $id = null; private ?int $id = null;
#[ORM\Column(length: 64, unique: true)] #[ORM\Column(length: 64)]
private string $magazineSlug = '';
#[ORM\Column(length: 64)]
private string $pubkeyHex = ''; 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 = ''; private string $localPart = '';
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => true])] #[ORM\Column(type: Types::BOOLEAN, options: ['default' => true])]
@ -46,6 +51,18 @@ class FeaturedAuthor
return $this->id; 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 public function getPubkeyHex(): string
{ {
return $this->pubkeyHex; return $this->pubkeyHex;

18
src/Entity/User.php

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

29
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 * 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. * 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 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); $hex = self::npubToHex($npub);
if ($hex === '') { if ($hex === '') {
return ''; 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). * 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)); $pk = strtolower(trim($pubkeyHex64));
if (64 !== \strlen($pk) || !ctype_xdigit($pk)) { if (64 !== \strlen($pk) || !ctype_xdigit($pk)) {
return ''; 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); $hex = self::npubToHex($npub);
if ($hex === '') { if ($hex === '') {
return ''; 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 public static function profileKind0(string $authorPubkeyHex64): string

14
src/Repository/ArticleHighlightRepository.php

@ -7,6 +7,7 @@ namespace App\Repository;
use App\Entity\Article; use App\Entity\Article;
use App\Entity\ArticleHighlight; use App\Entity\ArticleHighlight;
use App\Enum\EventStatusEnum; use App\Enum\EventStatusEnum;
use App\Service\TenantContext;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
@ -15,8 +16,10 @@ use Doctrine\Persistence\ManagerRegistry;
*/ */
class ArticleHighlightRepository extends ServiceEntityRepository class ArticleHighlightRepository extends ServiceEntityRepository
{ {
public function __construct(ManagerRegistry $registry) public function __construct(
{ ManagerRegistry $registry,
private readonly TenantContext $tenant,
) {
parent::__construct($registry, ArticleHighlight::class); parent::__construct($registry, ArticleHighlight::class);
} }
@ -41,7 +44,14 @@ class ArticleHighlightRepository extends ServiceEntityRepository
$qb = $this->createQueryBuilder('h') $qb = $this->createQueryBuilder('h')
->innerJoin('h.article', 'a') ->innerJoin('h.article', 'a')
->innerJoin(
'App\Entity\ArticleMagazine',
'am',
'WITH',
'am.article = a AND am.magazineSlug = :mag'
)
->where('a.eventStatus IN (:st)') ->where('a.eventStatus IN (:st)')
->setParameter('mag', $this->tenant->getMagazineSlug())
->setParameter('st', [EventStatusEnum::PUBLISHED, EventStatusEnum::ARCHIVED]) ->setParameter('st', [EventStatusEnum::PUBLISHED, EventStatusEnum::ARCHIVED])
->orderBy('h.eventCreatedAt', 'DESC') ->orderBy('h.eventCreatedAt', 'DESC')
->addOrderBy('h.id', 'DESC') ->addOrderBy('h.id', 'DESC')

38
src/Repository/ArticleMagazineRepository.php

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

87
src/Repository/ArticleRepository.php

@ -5,15 +5,18 @@ namespace App\Repository;
use App\Dto\FeaturedArticleCard; use App\Dto\FeaturedArticleCard;
use App\Entity\Article; use App\Entity\Article;
use App\Enum\EventStatusEnum; use App\Enum\EventStatusEnum;
use App\Service\TenantContext;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Exception; use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
class ArticleRepository extends ServiceEntityRepository class ArticleRepository extends ServiceEntityRepository
{ {
public function __construct(ManagerRegistry $registry) public function __construct(
{ ManagerRegistry $registry,
private readonly TenantContext $tenant,
) {
parent::__construct($registry, Article::class); 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 public function searchArticles(string $query, int $limit = 12, int $offset = 0): array
{ {
$qb = $this->createQueryBuilder('a'); $qb = $this->tenantQueryBuilder('a');
$searchTerms = explode(' ', trim($query)); $searchTerms = explode(' ', trim($query));
$conditions = $qb->expr()->orX(); $conditions = $qb->expr()->orX();
@ -56,7 +59,7 @@ class ArticleRepository extends ServiceEntityRepository
public function countSearchArticles(string $query): int public function countSearchArticles(string $query): int
{ {
$qb = $this->createQueryBuilder('a') $qb = $this->tenantQueryBuilder('a')
->select('COUNT(a.id)'); ->select('COUNT(a.id)');
$searchTerms = explode(' ', trim($query)); $searchTerms = explode(' ', trim($query));
@ -106,7 +109,9 @@ class ArticleRepository extends ServiceEntityRepository
$qb $qb
->select('a.id', 'a.slug', 'a.title', 'a.summary', 'a.image', 'a.created_at', 'a.published_at', 'a.pubkey') ->select('a.id', 'a.slug', 'a.title', 'a.summary', 'a.image', 'a.created_at', 'a.published_at', 'a.pubkey')
->from('article', 'a') ->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')) ->where($qb->expr()->in('a.slug', ':slugs'))
->setParameter('mag', $this->tenant->getMagazineSlug())
->setParameter('slugs', $slugs, ArrayParameterType::STRING) ->setParameter('slugs', $slugs, ArrayParameterType::STRING)
->orderBy('a.created_at', 'DESC'); ->orderBy('a.created_at', 'DESC');
@ -145,7 +150,7 @@ class ArticleRepository extends ServiceEntityRepository
return []; return [];
} }
$qb = $this->createQueryBuilder('a'); $qb = $this->tenantQueryBuilder('a');
$orX = $qb->expr()->orX(); $orX = $qb->expr()->orX();
foreach ($pairs as $i => $p) { foreach ($pairs as $i => $p) {
$pkQ = strtolower((string) $p['pubkey']); $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<string> * @return list<string>
*/ */
public function findDistinctAuthorPubkeys(): array public function findDistinctAuthorPubkeys(): array
{ {
return $this->createQueryBuilder('a') return $this->tenantQueryBuilder('a')
->select('a.pubkey') ->select('a.pubkey')
->distinct() ->distinct()
->where('a.pubkey IS NOT NULL') ->where('a.pubkey IS NOT NULL')
@ -188,13 +193,14 @@ class ArticleRepository extends ServiceEntityRepository
->getSingleColumnResult(); ->getSingleColumnResult();
} }
/** Global lookup by Nostr event id (shared across magazine tenants). */
public function findOneByEventId(string $eventId): ?Article public function findOneByEventId(string $eventId): ?Article
{ {
return $this->findOneBy(['eventId' => $eventId]); 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 public function findLatestBySlug(string $slug): ?Article
{ {
@ -203,9 +209,27 @@ class ArticleRepository extends ServiceEntityRepository
return null; 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') ->where('a.slug = :slug')
->andWhere('LOWER(a.pubkey) = :pk')
->setParameter('slug', $slug) ->setParameter('slug', $slug)
->setParameter('pk', strtolower($npubHex))
->orderBy('a.createdAt', 'DESC') ->orderBy('a.createdAt', 'DESC')
->setMaxResults(1) ->setMaxResults(1)
->getQuery() ->getQuery()
@ -214,7 +238,7 @@ class ArticleRepository extends ServiceEntityRepository
public function findByPubkeyPaginated(string $pubkey, int $limit, int $offset): array public function findByPubkeyPaginated(string $pubkey, int $limit, int $offset): array
{ {
return $this->createQueryBuilder('a') return $this->tenantQueryBuilder('a')
->where('a.pubkey = :pubkey') ->where('a.pubkey = :pubkey')
->setParameter('pubkey', $pubkey) ->setParameter('pubkey', $pubkey)
->orderBy('a.createdAt', 'DESC') ->orderBy('a.createdAt', 'DESC')
@ -226,7 +250,7 @@ class ArticleRepository extends ServiceEntityRepository
public function countByPubkey(string $pubkey): int public function countByPubkey(string $pubkey): int
{ {
return (int) $this->createQueryBuilder('a') return (int) $this->tenantQueryBuilder('a')
->select('COUNT(a.id)') ->select('COUNT(a.id)')
->where('a.pubkey = :pubkey') ->where('a.pubkey = :pubkey')
->setParameter('pubkey', $pubkey) ->setParameter('pubkey', $pubkey)
@ -234,6 +258,27 @@ class ArticleRepository extends ServiceEntityRepository
->getSingleScalarResult(); ->getSingleScalarResult();
} }
public function countForMagazine(): int
{
return (int) $this->tenantQueryBuilder('a')
->select('COUNT(a.id)')
->getQuery()
->getSingleScalarResult();
}
/**
* @return list<Article>
*/
public function findForMagazinePaginated(int $limit, int $offset): array
{
return $this->tenantQueryBuilder('a')
->orderBy('a.createdAt', 'DESC')
->setFirstResult($offset)
->setMaxResults($limit)
->getQuery()
->getResult();
}
/** /**
* Published or archived long-form rows for sitemap/Atom (may include multiple rows per slug); * 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. * 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 public function findPublishedForSyndication(int $limit = 5000): array
{ {
return $this->createQueryBuilder('a') return $this->tenantQueryBuilder('a')
->where('a.slug IS NOT NULL') ->where('a.slug IS NOT NULL')
->andWhere("TRIM(a.slug) != ''") ->andWhere("TRIM(a.slug) != ''")
->andWhere('a.eventStatus IN (:st)') ->andWhere('a.eventStatus IN (:st)')
@ -285,7 +330,7 @@ class ArticleRepository extends ServiceEntityRepository
if ($topicKey === '') { if ($topicKey === '') {
return []; return [];
} }
$qb = $this->createQueryBuilder('a') $qb = $this->tenantQueryBuilder('a')
->where('a.topics IS NOT NULL') ->where('a.topics IS NOT NULL')
->andWhere('a.content IS NOT NULL') ->andWhere('a.content IS NOT NULL')
->andWhere('LENGTH(a.content) > 250') ->andWhere('LENGTH(a.content) > 250')
@ -336,4 +381,18 @@ class ArticleRepository extends ServiceEntityRepository
return \trim($t); 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;
}
} }

39
src/Repository/FeaturedAuthorRepository.php

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

15
src/Repository/UserEntityRepository.php

@ -3,13 +3,24 @@
namespace App\Repository; namespace App\Repository;
use App\Entity\User; use App\Entity\User;
use App\Service\TenantContext;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
class UserEntityRepository extends ServiceEntityRepository class UserEntityRepository extends ServiceEntityRepository
{ {
public function __construct(ManagerRegistry $registry) public function __construct(
{ ManagerRegistry $registry,
private readonly TenantContext $tenant,
) {
parent::__construct($registry, User::class); parent::__construct($registry, User::class);
} }
public function findOneByNpub(string $npub): ?User
{
return $this->findOneBy([
'magazineSlug' => $this->tenant->getMagazineSlug(),
'npub' => $npub,
]);
}
} }

26
src/Security/UserDTOProvider.php

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

25
src/Service/ArticleMagazineRegistry.php

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

4
src/Service/FeaturedAuthorSync.php

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

13
src/Service/MagazineIndexStore.php

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

3
src/Service/Nip09DeletionApplier.php

@ -35,6 +35,7 @@ final class Nip09DeletionApplier
private readonly ParameterBagInterface $params, private readonly ParameterBagInterface $params,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
private readonly NostrKeyHelper $nostrKeyHelper, private readonly NostrKeyHelper $nostrKeyHelper,
private readonly TenantContext $tenant,
) { ) {
} }
@ -403,7 +404,7 @@ final class Nip09DeletionApplier
if ($d === '') { if ($d === '') {
return $out; return $out;
} }
$key = MagazineEventKeys::magazineCuration30004FromPubkeyHex($pk, $d); $key = MagazineEventKeys::magazineCuration30004FromPubkeyHex($this->tenant->getMagazineSlug(), $pk, $d);
if ($key === '') { if ($key === '') {
return $out; return $out;
} }

6
src/Service/NostrClient.php

@ -1348,6 +1348,10 @@ class NostrClient
return; return;
} }
if ($this->longformArticleStore->isEventIdAlreadyStored($newId)) { 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)', [ $this->logger->info('[longform_ingest] saveEachArticle: skip, DB already has this exact event id (no work)', [
'eventId' => $newId, 'eventId' => $newId,
'slug' => $article->getSlug(), 'slug' => $article->getSlug(),
@ -1404,6 +1408,7 @@ class NostrClient
} }
try { try {
$this->entityManager->flush(); $this->entityManager->flush();
$this->longformArticleStore->linkExistingArticle($incumbent);
} catch (\Exception $e) { } catch (\Exception $e) {
$this->logger->error('[longform_ingest] saveEachArticle: flush after update failed: '.$e->getMessage()); $this->logger->error('[longform_ingest] saveEachArticle: flush after update failed: '.$e->getMessage());
$this->managerRegistry->resetManager(); $this->managerRegistry->resetManager();
@ -1420,6 +1425,7 @@ class NostrClient
'dbCreatedAt' => $iTs, 'dbCreatedAt' => $iTs,
'seenCreatedAt' => $cTs, 'seenCreatedAt' => $cTs,
]); ]);
$this->longformArticleStore->linkExistingArticle($incumbent);
} elseif ((string) $incumbent->getEventId() !== $newId) { } elseif ((string) $incumbent->getEventId() !== $newId) {
$this->logger->notice('[longform_ingest] saveEachArticle: inconclusive supersedes (different ids) — check relays / d-tag match', [ $this->logger->notice('[longform_ingest] saveEachArticle: inconclusive supersedes (different ids) — check relays / d-tag match', [
'address' => $pubkey.':…:'.$this->wireMerge->longformIngestShortSlug($slug), 'address' => $pubkey.':…:'.$this->wireMerge->longformIngestShortSlug($slug),

16
src/Service/NostrLongformArticleStore.php

@ -22,6 +22,7 @@ final class NostrLongformArticleStore
private readonly ManagerRegistry $managerRegistry, private readonly ManagerRegistry $managerRegistry,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
private readonly NostrWireEventMerge $wireMerge, 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; 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 public function findLatestByAuthorAndSlug(string $pubkey, string $slug): ?Article
{ {
$pubkey = strtolower($pubkey); $pubkey = strtolower($pubkey);
@ -106,6 +116,7 @@ final class NostrLongformArticleStore
]); ]);
$this->entityManager->persist($article); $this->entityManager->persist($article);
$this->entityManager->flush(); $this->entityManager->flush();
$this->articleMagazineRegistry->link($article);
} catch (\Exception $e) { } catch (\Exception $e) {
$this->logger->error('[longform_ingest] persistNewArticle failed: '.$e->getMessage(), [ $this->logger->error('[longform_ingest] persistNewArticle failed: '.$e->getMessage(), [
'reason' => $reason, 'reason' => $reason,
@ -114,4 +125,9 @@ final class NostrLongformArticleStore
$this->managerRegistry->resetManager(); $this->managerRegistry->resetManager();
} }
} }
public function linkExistingArticle(Article $article): void
{
$this->articleMagazineRegistry->link($article);
}
} }

6
src/Service/NostrShareMenuBuilder.php

@ -169,8 +169,10 @@ final class NostrShareMenuBuilder
if ($npub === '' || $slug === '' || !str_starts_with($npub, 'npub1')) { if ($npub === '' || $slug === '' || !str_starts_with($npub, 'npub1')) {
return $this->siteWithRootMenu(); return $this->siteWithRootMenu();
} }
$list = $this->articleRepository->findBy(['slug' => $slug], ['createdAt' => 'DESC'], 1); $article = $this->articleRepository->findLatestBySlugForTenant(
$article = $list[0] ?? null; $slug,
$this->nostrKeyHelper->convertToHex($npub),
);
if ($article === null) { if ($article === null) {
return $this->siteWithRootMenu(); return $this->siteWithRootMenu();
} }

25
src/Service/TenantContext.php

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

3
src/Service/TopicIndexService.php

@ -16,6 +16,7 @@ final class TopicIndexService
public function __construct( public function __construct(
private readonly ArticleRepository $articleRepository, private readonly ArticleRepository $articleRepository,
private readonly MagazineContentService $magazineContent, private readonly MagazineContentService $magazineContent,
private readonly TenantContext $tenant,
) { ) {
} }
@ -40,11 +41,13 @@ final class TopicIndexService
$rows = $conn->fetchAllAssociative( $rows = $conn->fetchAllAssociative(
'SELECT a.slug, a.topics FROM article a '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 WHERE a.topics IS NOT NULL
AND a.content IS NOT NULL AND a.content IS NOT NULL
AND CHAR_LENGTH(a.content) > 250 AND CHAR_LENGTH(a.content) > 250
AND a.event_status IN (:st)', AND a.event_status IN (:st)',
[ [
'mag' => $this->tenant->getMagazineSlug(),
'st' => [EventStatusEnum::PUBLISHED->value, EventStatusEnum::ARCHIVED->value], 'st' => [EventStatusEnum::PUBLISHED->value, EventStatusEnum::ARCHIVED->value],
], ],
[ [

Loading…
Cancel
Save