You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
539 lines
20 KiB
539 lines
20 KiB
<?php |
|
|
|
declare(strict_types=1); |
|
|
|
namespace App\Controller; |
|
|
|
use App\Dto\CategoryDraft; |
|
use App\Dto\MagazineDraft; |
|
use App\Enum\KindsEnum; |
|
use App\Form\CategoryArticlesType; |
|
use App\Form\MagazineSetupType; |
|
use App\Service\EncryptionService; |
|
use Doctrine\ORM\EntityManagerInterface; |
|
use Psr\Cache\CacheItemPoolInterface; |
|
use swentel\nostr\Event\Event; |
|
use swentel\nostr\Sign\Sign; |
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; |
|
use Symfony\Component\HttpFoundation\JsonResponse; |
|
use Symfony\Component\HttpFoundation\Request; |
|
use Symfony\Component\HttpFoundation\Response; |
|
use Symfony\Component\Routing\Attribute\Route; |
|
use Symfony\Component\Security\Csrf\CsrfToken; |
|
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; |
|
use swentel\nostr\Key\Key; |
|
use Redis as RedisClient; |
|
|
|
class MagazineWizardController extends AbstractController |
|
{ |
|
private const SESSION_KEY = 'mag_wizard'; |
|
|
|
#[Route('/magazine/wizard/setup', name: 'mag_wizard_setup')] |
|
public function setup(Request $request): Response |
|
{ |
|
$draft = $this->getDraft($request); |
|
if (!$draft) { |
|
$draft = new MagazineDraft(); |
|
$draft->categories = [new CategoryDraft()]; |
|
} |
|
|
|
$form = $this->createForm(MagazineSetupType::class, $draft); |
|
$form->handleRequest($request); |
|
if ($form->isSubmitted() && $form->isValid()) { |
|
$draft = $form->getData(); |
|
// Slug generation with a short random suffix |
|
if (!$draft->slug) { |
|
$draft->slug = $this->slugifyWithRandom($draft->title); |
|
} |
|
foreach ($draft->categories as $cat) { |
|
if (!$cat->slug) { |
|
$cat->slug = $this->slugifyWithRandom($cat->title); |
|
} |
|
} |
|
$this->saveDraft($request, $draft); |
|
return $this->redirectToRoute('mag_wizard_articles'); |
|
} |
|
|
|
return $this->render('magazine/magazine_setup.html.twig', [ |
|
'form' => $form->createView(), |
|
]); |
|
} |
|
|
|
#[Route('/magazine/wizard/articles', name: 'mag_wizard_articles')] |
|
public function articles(Request $request): Response |
|
{ |
|
$draft = $this->getDraft($request); |
|
if (!$draft) { |
|
return $this->redirectToRoute('mag_wizard_setup'); |
|
} |
|
|
|
// Build a form as a collection of CategoryArticlesType |
|
$formBuilder = $this->createFormBuilder($draft); |
|
$formBuilder->add('categories', \Symfony\Component\Form\Extension\Core\Type\CollectionType::class, [ |
|
'entry_type' => CategoryArticlesType::class, |
|
'allow_add' => false, |
|
'allow_delete' => false, |
|
'by_reference' => false, |
|
'label' => false, |
|
]); |
|
$form = $formBuilder->getForm(); |
|
$form->handleRequest($request); |
|
if ($form->isSubmitted() && $form->isValid()) { |
|
$this->saveDraft($request, $form->getData()); |
|
return $this->redirectToRoute('mag_wizard_review'); |
|
} |
|
|
|
return $this->render('magazine/magazine_articles.html.twig', [ |
|
'form' => $form->createView(), |
|
]); |
|
} |
|
|
|
#[Route('/magazine/wizard/review', name: 'mag_wizard_review')] |
|
public function review(Request $request, NzineRepository $nzineRepository): Response |
|
{ |
|
$draft = $this->getDraft($request); |
|
if (!$draft) { |
|
return $this->redirectToRoute('mag_wizard_setup'); |
|
} |
|
|
|
// Check if this slug belongs to an NZine (which has a bot) |
|
$nzine = null; |
|
$isNzineEdit = false; |
|
if ($draft->slug) { |
|
$nzine = $nzineRepository->findOneBy(['slug' => $draft->slug]); |
|
if ($nzine && $nzine->getNzineBot()) { |
|
$isNzineEdit = true; |
|
} |
|
} |
|
|
|
// Build event skeletons (without pubkey/sig/id); created_at client can adjust |
|
$categoryEvents = []; |
|
foreach ($draft->categories as $cat) { |
|
$tags = []; |
|
$tags[] = ['d', $cat->slug]; |
|
$tags[] = ['type', 'magazine']; |
|
if ($cat->title) { $tags[] = ['title', $cat->title]; } |
|
if ($cat->summary) { $tags[] = ['summary', $cat->summary]; } |
|
foreach ($cat->tags as $t) { $tags[] = ['t', $t]; } |
|
foreach ($cat->articles as $a) { |
|
if (is_string($a) && $a !== '') { $tags[] = ['a', $a]; } |
|
} |
|
$categoryEvents[] = [ |
|
'kind' => 30040, |
|
'created_at' => time(), |
|
'tags' => $tags, |
|
'content' => '', |
|
]; |
|
} |
|
|
|
// Determine current user's pubkey (hex) from their npub (user identifier) |
|
// For NZine edits, use the NZine's npub instead |
|
$pubkeyHex = null; |
|
if ($isNzineEdit && $nzine) { |
|
try { |
|
$key = new Key(); |
|
$pubkeyHex = $key->convertToHex($nzine->getNpub()); |
|
} catch (\Throwable $e) { |
|
$pubkeyHex = null; |
|
} |
|
} else { |
|
$user = $this->getUser(); |
|
if ($user && method_exists($user, 'getUserIdentifier')) { |
|
try { |
|
$key = new Key(); |
|
$pubkeyHex = $key->convertToHex($user->getUserIdentifier()); |
|
} catch (\Throwable $e) { |
|
$pubkeyHex = null; |
|
} |
|
} |
|
} |
|
|
|
$magTags = []; |
|
$magTags[] = ['d', $draft->slug]; |
|
$magTags[] = ['type', 'magazine']; |
|
if ($draft->title) { $magTags[] = ['title', $draft->title]; } |
|
if ($draft->summary) { $magTags[] = ['summary', $draft->summary]; } |
|
if ($draft->imageUrl) { $magTags[] = ['image', $draft->imageUrl]; } |
|
if ($draft->language) { $magTags[] = ['l', $draft->language]; } |
|
foreach ($draft->tags as $t) { $magTags[] = ['t', $t]; } |
|
|
|
// If we know the user's pubkey, include all category coordinates as 'a' tags now |
|
if ($pubkeyHex) { |
|
foreach ($draft->categories as $cat) { |
|
if ($cat->slug) { |
|
$magTags[] = ['a', sprintf('30040:%s:%s', $pubkeyHex, $cat->slug)]; |
|
} |
|
} |
|
} |
|
|
|
$magazineEvent = [ |
|
'kind' => 30040, |
|
'created_at' => time(), |
|
'tags' => $magTags, |
|
'content' => '', |
|
]; |
|
|
|
return $this->render('magazine/magazine_review.html.twig', [ |
|
'draft' => $draft, |
|
'categoryEventsJson' => json_encode($categoryEvents, JSON_UNESCAPED_SLASHES), |
|
'magazineEventJson' => json_encode($magazineEvent, JSON_UNESCAPED_SLASHES), |
|
'csrfToken' => $this->container->get('security.csrf.token_manager')->getToken('nostr_publish')->getValue(), |
|
'isNzineEdit' => $isNzineEdit, |
|
'nzineSlug' => $isNzineEdit ? $draft->slug : null, |
|
]); |
|
} |
|
|
|
#[Route('/api/index/publish', name: 'api-index-publish', methods: ['POST'])] |
|
public function publishIndexEvent( |
|
Request $request, |
|
CacheItemPoolInterface $appCache, |
|
CsrfTokenManagerInterface $csrfTokenManager, |
|
RedisClient $redis, |
|
EntityManagerInterface $entityManager |
|
): JsonResponse { |
|
// Verify CSRF token |
|
$csrfToken = $request->headers->get('X-CSRF-TOKEN'); |
|
if (!$csrfTokenManager->isTokenValid(new CsrfToken('nostr_publish', $csrfToken))) { |
|
return new JsonResponse(['error' => 'Invalid CSRF token'], 403); |
|
} |
|
|
|
$data = json_decode($request->getContent(), true); |
|
if (!$data || !isset($data['event'])) { |
|
return new JsonResponse(['error' => 'Invalid request'], 400); |
|
} |
|
|
|
$signedEvent = $data['event']; |
|
|
|
// Convert array to swentel Event and verify |
|
$eventObj = new Event(); |
|
$eventObj->setId($signedEvent['id'] ?? ''); |
|
$eventObj->setPublicKey($signedEvent['pubkey'] ?? ''); |
|
$eventObj->setCreatedAt($signedEvent['created_at'] ?? time()); |
|
$eventObj->setKind($signedEvent['kind'] ?? KindsEnum::PUBLICATION_INDEX->value); |
|
$eventObj->setTags($signedEvent['tags'] ?? []); |
|
$eventObj->setContent($signedEvent['content'] ?? ''); |
|
$eventObj->setSignature($signedEvent['sig'] ?? ''); |
|
|
|
if (!$eventObj->verify()) { |
|
return new JsonResponse(['error' => 'Verification failed'], 400); |
|
} |
|
|
|
// Extract slug from 'd' tag |
|
$slug = null; |
|
foreach ($signedEvent['tags'] as $tag) { |
|
if (isset($tag[0]) && $tag[0] === 'd' && isset($tag[1])) { |
|
$slug = $tag[1]; |
|
break; |
|
} |
|
} |
|
if (!$slug) { |
|
return new JsonResponse(['error' => 'Missing d tag/slug'], 400); |
|
} |
|
|
|
// Save to Redis under magazine-<slug> |
|
try { |
|
$key = 'magazine-' . $slug; |
|
$item = $appCache->getItem($key); |
|
$item->set($eventObj); |
|
$appCache->save($item); |
|
} catch (\Throwable $e) { |
|
return new JsonResponse(['error' => 'Redis error'], 500); |
|
} |
|
|
|
// If the event is a top-level magazine index (references 30040 categories), record slug in a set for admin listing |
|
try { |
|
$isTopLevelMagazine = false; |
|
foreach ($signedEvent['tags'] as $tag) { |
|
if (($tag[0] ?? null) === 'a' && isset($tag[1]) && str_starts_with((string)$tag[1], '30040:')) { |
|
$isTopLevelMagazine = true; break; |
|
} |
|
} |
|
if ($isTopLevelMagazine) { |
|
$redis->sAdd('magazine_slugs', $slug); |
|
} |
|
} catch (\Throwable $e) { |
|
// non-fatal |
|
} |
|
|
|
// Save to persistence as Event entity |
|
// Map swentel Event to Event entity, it's always a new event |
|
$event = new \App\Entity\Event(); |
|
$event->setId($eventObj->getId()); |
|
$event->setPubkey($eventObj->getPublicKey()); |
|
$event->setCreatedAt($eventObj->getCreatedAt()); |
|
$event->setKind($eventObj->getKind()); |
|
$event->setTags($eventObj->getTags()); |
|
$event->setContent($eventObj->getContent()); |
|
$event->setSig($eventObj->getSignature()); |
|
// Persist |
|
$entityManager->persist($event); |
|
$entityManager->flush(); |
|
|
|
return new JsonResponse(['ok' => true]); |
|
} |
|
|
|
#[Route('/api/nzine-index/publish', name: 'api-nzine-index-publish', methods: ['POST'])] |
|
public function publishNzineIndexEvent( |
|
Request $request, |
|
CsrfTokenManagerInterface $csrfTokenManager, |
|
NzineRepository $nzineRepository, |
|
EncryptionService $encryptionService, |
|
CacheItemPoolInterface $appCache, |
|
RedisClient $redis, |
|
EntityManagerInterface $entityManager |
|
): JsonResponse { |
|
// Verify CSRF token |
|
$csrfToken = $request->headers->get('X-CSRF-TOKEN'); |
|
if (!$csrfTokenManager->isTokenValid(new CsrfToken('nostr_publish', $csrfToken))) { |
|
return new JsonResponse(['error' => 'Invalid CSRF token'], 403); |
|
} |
|
|
|
$data = json_decode($request->getContent(), true); |
|
if (!$data || !isset($data['nzineSlug']) || !isset($data['categoryEvents']) || !isset($data['magazineEvent'])) { |
|
return new JsonResponse(['error' => 'Invalid request'], 400); |
|
} |
|
|
|
$nzineSlug = $data['nzineSlug']; |
|
$categorySkeletons = $data['categoryEvents']; |
|
$magazineSkeleton = $data['magazineEvent']; |
|
|
|
// Load the NZine entity |
|
$nzine = $nzineRepository->findOneBy(['slug' => $nzineSlug]); |
|
if (!$nzine || !$nzine->getNzineBot()) { |
|
return new JsonResponse(['error' => 'NZine not found or no bot configured'], 404); |
|
} |
|
|
|
// Get the bot's nsec for signing |
|
$bot = $nzine->getNzineBot(); |
|
$bot->setEncryptionService($encryptionService); |
|
$nsec = $bot->getNsec(); |
|
if (!$nsec) { |
|
return new JsonResponse(['error' => 'Bot credentials not available'], 500); |
|
} |
|
|
|
$key = new Key(); |
|
$pubkeyHex = $key->getPublicKey($nsec); |
|
$signer = new Sign(); |
|
|
|
$categoryCoordinates = []; |
|
|
|
try { |
|
// 1) Sign and publish each category event |
|
foreach ($categorySkeletons as $catSkeleton) { |
|
$catEvent = new Event(); |
|
$catEvent->setKind($catSkeleton['kind'] ?? 30040); |
|
$catEvent->setCreatedAt($catSkeleton['created_at'] ?? time()); |
|
$catEvent->setTags($catSkeleton['tags'] ?? []); |
|
$catEvent->setContent($catSkeleton['content'] ?? ''); |
|
|
|
// Sign with bot's nsec |
|
$signer->signEvent($catEvent, $nsec); |
|
|
|
// Extract slug from d tag |
|
$slug = null; |
|
foreach ($catEvent->getTags() as $tag) { |
|
if (($tag[0] ?? null) === 'd' && isset($tag[1])) { |
|
$slug = $tag[1]; |
|
break; |
|
} |
|
} |
|
if (!$slug) { |
|
return new JsonResponse(['error' => 'Category missing d tag'], 400); |
|
} |
|
|
|
// Save to Redis |
|
$cacheKey = 'magazine-' . $slug; |
|
$item = $appCache->getItem($cacheKey); |
|
$item->set($catEvent); |
|
$appCache->save($item); |
|
|
|
// Save to database |
|
$eventEntity = new \App\Entity\Event(); |
|
$eventEntity->setId($catEvent->getId()); |
|
$eventEntity->setPubkey($catEvent->getPublicKey()); |
|
$eventEntity->setCreatedAt($catEvent->getCreatedAt()); |
|
$eventEntity->setKind($catEvent->getKind()); |
|
$eventEntity->setTags($catEvent->getTags()); |
|
$eventEntity->setContent($catEvent->getContent()); |
|
$eventEntity->setSig($catEvent->getSignature()); |
|
$entityManager->persist($eventEntity); |
|
|
|
// Build coordinate |
|
$categoryCoordinates[] = sprintf('30040:%s:%s', $pubkeyHex, $slug); |
|
} |
|
|
|
// 2) Build and sign the magazine event with category references |
|
$magEvent = new Event(); |
|
$magEvent->setKind($magazineSkeleton['kind'] ?? 30040); |
|
$magEvent->setCreatedAt($magazineSkeleton['created_at'] ?? time()); |
|
|
|
// Remove any existing 'a' tags and add the new category coordinates |
|
$magTags = array_filter($magazineSkeleton['tags'] ?? [], fn($t) => ($t[0] ?? null) !== 'a'); |
|
foreach ($categoryCoordinates as $coord) { |
|
$magTags[] = ['a', $coord]; |
|
} |
|
$magEvent->setTags($magTags); |
|
$magEvent->setContent($magazineSkeleton['content'] ?? ''); |
|
|
|
// Sign with bot's nsec |
|
$signer->signEvent($magEvent, $nsec); |
|
|
|
// Extract magazine slug |
|
$magSlug = null; |
|
foreach ($magEvent->getTags() as $tag) { |
|
if (($tag[0] ?? null) === 'd' && isset($tag[1])) { |
|
$magSlug = $tag[1]; |
|
break; |
|
} |
|
} |
|
if (!$magSlug) { |
|
return new JsonResponse(['error' => 'Magazine missing d tag'], 400); |
|
} |
|
|
|
// Save magazine to Redis |
|
$cacheKey = 'magazine-' . $magSlug; |
|
$item = $appCache->getItem($cacheKey); |
|
$item->set($magEvent); |
|
$appCache->save($item); |
|
|
|
// Save magazine to database |
|
$magEventEntity = new \App\Entity\Event(); |
|
$magEventEntity->setId($magEvent->getId()); |
|
$magEventEntity->setPubkey($magEvent->getPublicKey()); |
|
$magEventEntity->setCreatedAt($magEvent->getCreatedAt()); |
|
$magEventEntity->setKind($magEvent->getKind()); |
|
$magEventEntity->setTags($magEvent->getTags()); |
|
$magEventEntity->setContent($magEvent->getContent()); |
|
$magEventEntity->setSig($magEvent->getSignature()); |
|
$entityManager->persist($magEventEntity); |
|
|
|
// Record slug in Redis set for admin listing |
|
$redis->sAdd('magazine_slugs', $magSlug); |
|
|
|
$entityManager->flush(); |
|
|
|
return new JsonResponse(['ok' => true, 'message' => 'NZine magazine updated successfully']); |
|
|
|
} catch (\Throwable $e) { |
|
return new JsonResponse(['error' => $e->getMessage()], 500); |
|
} |
|
} |
|
|
|
#[Route('/magazine/wizard/cancel', name: 'mag_wizard_cancel', methods: ['GET'])] |
|
public function cancel(Request $request): Response |
|
{ |
|
$this->clearDraft($request); |
|
$this->addFlash('info', 'Magazine setup canceled.'); |
|
return $this->redirectToRoute('home'); |
|
} |
|
|
|
#[Route('/magazine/wizard/edit/{slug}', name: 'mag_wizard_edit')] |
|
public function editStart(string $slug, EntityManagerInterface $entityManager, Request $request): Response |
|
{ |
|
// Load magazine event from database |
|
$sql = "SELECT e.* FROM event e |
|
WHERE e.tags::jsonb @> ?::jsonb |
|
LIMIT 1"; |
|
|
|
$conn = $entityManager->getConnection(); |
|
$result = $conn->executeQuery($sql, [ |
|
json_encode([['d', $slug]]) |
|
]); |
|
|
|
$magEventData = $result->fetchAssociative(); |
|
|
|
if ($magEventData === false) { |
|
throw $this->createNotFoundException('Magazine not found'); |
|
} |
|
|
|
$tags = json_decode($magEventData['tags'], true); |
|
|
|
$draft = new \App\Dto\MagazineDraft(); |
|
$draft->slug = $slug; |
|
$draft->title = $this->getTagValue($tags, 'title') ?? ''; |
|
$draft->summary = $this->getTagValue($tags, 'summary') ?? ''; |
|
$draft->imageUrl = $this->getTagValue($tags, 'image') ?? ''; |
|
$draft->language = $this->getTagValue($tags, 'l'); |
|
$draft->tags = $this->getAllTagValues($tags, 't'); |
|
$draft->categories = []; |
|
|
|
// For each category coordinate (30040:pubkey:slug), load its index and map |
|
foreach ($tags as $t) { |
|
if (is_array($t) && ($t[0] ?? null) === 'a' && isset($t[1]) && str_starts_with((string)$t[1], '30040:')) { |
|
$parts = explode(':', (string)$t[1], 3); |
|
if (count($parts) !== 3) { continue; } |
|
$catSlug = $parts[2]; |
|
|
|
// Query database for category event |
|
$catResult = $conn->executeQuery($sql, [ |
|
json_encode([['d', $catSlug]]) |
|
]); |
|
$catEventData = $catResult->fetchAssociative(); |
|
|
|
if ($catEventData === false) { continue; } |
|
|
|
$ctags = json_decode($catEventData['tags'], true); |
|
$cat = new \App\Dto\CategoryDraft(); |
|
$cat->slug = $catSlug; |
|
$cat->title = $this->getTagValue($ctags, 'title') ?? ''; |
|
$cat->summary = $this->getTagValue($ctags, 'summary') ?? ''; |
|
$cat->tags = $this->getAllTagValues($ctags, 't'); |
|
$cat->articles = []; |
|
foreach ($ctags as $ct) { |
|
if (is_array($ct) && ($ct[0] ?? null) === 'a' && isset($ct[1])) { |
|
$cat->articles[] = (string)$ct[1]; |
|
} |
|
} |
|
$draft->categories[] = $cat; |
|
} |
|
} |
|
|
|
$this->saveDraft($request, $draft); |
|
return $this->redirectToRoute('mag_wizard_setup'); |
|
} |
|
|
|
private function getTagValue(array $tags, string $name): ?string |
|
{ |
|
foreach ($tags as $t) { |
|
if (is_array($t) && ($t[0] ?? null) === $name && isset($t[1])) { |
|
return (string)$t[1]; |
|
} |
|
} |
|
return null; |
|
} |
|
|
|
private function getAllTagValues(array $tags, string $name): array |
|
{ |
|
$out = []; |
|
foreach ($tags as $t) { |
|
if (is_array($t) && ($t[0] ?? null) === $name && isset($t[1])) { |
|
$out[] = (string)$t[1]; |
|
} |
|
} |
|
return $out; |
|
} |
|
|
|
private function getDraft(Request $request): ?MagazineDraft |
|
{ |
|
$data = $request->getSession()->get(self::SESSION_KEY); |
|
return $data instanceof MagazineDraft ? $data : null; |
|
} |
|
|
|
private function saveDraft(Request $request, MagazineDraft $draft): void |
|
{ |
|
$request->getSession()->set(self::SESSION_KEY, $draft); |
|
} |
|
|
|
private function clearDraft(Request $request): void |
|
{ |
|
$request->getSession()->remove(self::SESSION_KEY); |
|
} |
|
|
|
private function slugifyWithRandom(string $title): string |
|
{ |
|
$slug = strtolower(trim(preg_replace('/[^A-Za-z0-9-]+/', '-', $title)) ?? ''); |
|
$slug = trim(preg_replace('/-+/', '-', $slug) ?? '', '-'); |
|
$rand = substr(bin2hex(random_bytes(4)), 0, 6); |
|
return $slug !== '' ? ($slug . '-' . $rand) : $rand; |
|
} |
|
}
|
|
|