9 changed files with 652 additions and 19 deletions
@ -0,0 +1,82 @@ |
|||||||
|
import { Controller } from '@hotwired/stimulus'; |
||||||
|
|
||||||
|
export default class extends Controller { |
||||||
|
static targets = ['status', 'publishButton']; |
||||||
|
static values = { |
||||||
|
categoryEvents: String, |
||||||
|
magazineEvent: String, |
||||||
|
publishUrl: String, |
||||||
|
nzineSlug: String, |
||||||
|
csrfToken: String |
||||||
|
}; |
||||||
|
|
||||||
|
async publish(event) { |
||||||
|
event.preventDefault(); |
||||||
|
|
||||||
|
if (!this.publishUrlValue || !this.csrfTokenValue || !this.nzineSlugValue) { |
||||||
|
this.showError('Missing configuration'); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
this.publishButtonTarget.disabled = true; |
||||||
|
|
||||||
|
try { |
||||||
|
const categoryEvents = JSON.parse(this.categoryEventsValue || '[]'); |
||||||
|
const magazineEvent = JSON.parse(this.magazineEventValue || '{}'); |
||||||
|
|
||||||
|
this.showStatus('Publishing magazine and categories...'); |
||||||
|
|
||||||
|
const response = await fetch(this.publishUrlValue, { |
||||||
|
method: 'POST', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json', |
||||||
|
'X-CSRF-TOKEN': this.csrfTokenValue, |
||||||
|
'X-Requested-With': 'XMLHttpRequest' |
||||||
|
}, |
||||||
|
body: JSON.stringify({ |
||||||
|
nzineSlug: this.nzineSlugValue, |
||||||
|
categoryEvents: categoryEvents, |
||||||
|
magazineEvent: magazineEvent |
||||||
|
}) |
||||||
|
}); |
||||||
|
|
||||||
|
if (!response.ok) { |
||||||
|
const data = await response.json().catch(() => ({})); |
||||||
|
throw new Error(data.error || `HTTP ${response.status}`); |
||||||
|
} |
||||||
|
|
||||||
|
const result = await response.json(); |
||||||
|
this.showSuccess(result.message || 'Magazine published successfully!'); |
||||||
|
|
||||||
|
// Redirect to home or magazine page after a short delay
|
||||||
|
setTimeout(() => { |
||||||
|
window.location.href = '/'; |
||||||
|
}, 2000); |
||||||
|
|
||||||
|
} catch (e) { |
||||||
|
console.error(e); |
||||||
|
this.showError(e.message || 'Publish failed'); |
||||||
|
} finally { |
||||||
|
this.publishButtonTarget.disabled = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
showStatus(message) { |
||||||
|
if (this.hasStatusTarget) { |
||||||
|
this.statusTarget.innerHTML = `<div class="alert alert-info">${message}</div>`; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
showSuccess(message) { |
||||||
|
if (this.hasStatusTarget) { |
||||||
|
this.statusTarget.innerHTML = `<div class="alert alert-success">${message}</div>`; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
showError(message) { |
||||||
|
if (this.hasStatusTarget) { |
||||||
|
this.statusTarget.innerHTML = `<div class="alert alert-danger">${message}</div>`; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,310 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Service; |
||||||
|
|
||||||
|
use App\Entity\Event as EventEntity; |
||||||
|
use App\Entity\Nzine; |
||||||
|
use App\Enum\KindsEnum; |
||||||
|
use Doctrine\ORM\EntityManagerInterface; |
||||||
|
use Psr\Log\LoggerInterface; |
||||||
|
use swentel\nostr\Event\Event; |
||||||
|
use swentel\nostr\Sign\Sign; |
||||||
|
use Symfony\Component\Serializer\Encoder\JsonEncoder; |
||||||
|
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; |
||||||
|
use Symfony\Component\Serializer\Serializer; |
||||||
|
use Symfony\Component\String\Slugger\AsciiSlugger; |
||||||
|
|
||||||
|
/** |
||||||
|
* Service for managing category index events for nzines |
||||||
|
*/ |
||||||
|
class NzineCategoryIndexService |
||||||
|
{ |
||||||
|
public function __construct( |
||||||
|
private readonly EntityManagerInterface $entityManager, |
||||||
|
private readonly EncryptionService $encryptionService, |
||||||
|
private readonly LoggerInterface $logger |
||||||
|
) { |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Ensure category index events exist for all categories in a nzine |
||||||
|
* Creates missing category index events and returns them |
||||||
|
* |
||||||
|
* @param Nzine $nzine The nzine entity |
||||||
|
* @return array Map of category slug => EventEntity |
||||||
|
* @throws \JsonException |
||||||
|
*/ |
||||||
|
public function ensureCategoryIndices(Nzine $nzine): array |
||||||
|
{ |
||||||
|
$categories = $nzine->getMainCategories(); |
||||||
|
if (empty($categories)) { |
||||||
|
return []; |
||||||
|
} |
||||||
|
|
||||||
|
$bot = $nzine->getNzineBot(); |
||||||
|
if (!$bot) { |
||||||
|
$this->logger->warning('Cannot create category indices: nzine bot not found', [ |
||||||
|
'nzine_id' => $nzine->getId(), |
||||||
|
]); |
||||||
|
return []; |
||||||
|
} |
||||||
|
|
||||||
|
$bot->setEncryptionService($this->encryptionService); |
||||||
|
$privateKey = $bot->getNsec(); |
||||||
|
|
||||||
|
if (!$privateKey) { |
||||||
|
$this->logger->warning('Cannot create category indices: bot private key not found', [ |
||||||
|
'nzine_id' => $nzine->getId(), |
||||||
|
]); |
||||||
|
return []; |
||||||
|
} |
||||||
|
|
||||||
|
$slugger = new AsciiSlugger(); |
||||||
|
$categoryIndices = []; |
||||||
|
|
||||||
|
// Load all existing category indices for this nzine at once |
||||||
|
$existingIndices = $this->entityManager->getRepository(EventEntity::class) |
||||||
|
->findBy([ |
||||||
|
'pubkey' => $nzine->getNpub(), |
||||||
|
'kind' => KindsEnum::PUBLICATION_INDEX->value, |
||||||
|
]); |
||||||
|
|
||||||
|
// Index existing events by their d-tag (slug) |
||||||
|
$existingBySlug = []; |
||||||
|
foreach ($existingIndices as $existingIndex) { |
||||||
|
$slug = $this->extractSlugFromTags($existingIndex->getTags()); |
||||||
|
if ($slug) { |
||||||
|
$existingBySlug[$slug] = $existingIndex; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
foreach ($categories as $category) { |
||||||
|
if (empty($category['title'])) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
$title = $category['title']; |
||||||
|
$slug = !empty($category['slug']) |
||||||
|
? $category['slug'] |
||||||
|
: $slugger->slug($title)->lower()->toString(); |
||||||
|
|
||||||
|
// Check if category index already exists |
||||||
|
if (isset($existingBySlug[$slug])) { |
||||||
|
$this->logger->debug('Using existing category index', [ |
||||||
|
'category_slug' => $slug, |
||||||
|
'title' => $title, |
||||||
|
]); |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
// Create new category index event |
||||||
|
$event = new Event(); |
||||||
|
$event->setKind(KindsEnum::PUBLICATION_INDEX->value); |
||||||
|
$event->addTag(['d', $slug]); |
||||||
|
$event->addTag(['title', $title]); |
||||||
|
$event->addTag(['auto-update', 'yes']); |
||||||
|
$event->addTag(['type', 'magazine']); |
||||||
|
|
||||||
|
// Add tags for RSS matching |
||||||
|
if (isset($category['tags']) && is_array($category['tags'])) { |
||||||
|
foreach ($category['tags'] as $tag) { |
||||||
|
$event->addTag(['t', $tag]); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
$event->setPublicKey($nzine->getNpub()); |
||||||
|
|
||||||
|
// Sign the event |
||||||
|
$signer = new Sign(); |
||||||
|
$signer->signEvent($event, $privateKey); |
||||||
|
|
||||||
|
// Convert to EventEntity and save |
||||||
|
$serializer = new Serializer([new ObjectNormalizer()], [new JsonEncoder()]); |
||||||
|
$eventEntity = $serializer->deserialize($event->toJson(), EventEntity::class, 'json'); |
||||||
|
|
||||||
|
$this->entityManager->persist($eventEntity); |
||||||
|
$categoryIndices[$slug] = $eventEntity; |
||||||
|
|
||||||
|
$this->logger->info('Created category index event', [ |
||||||
|
'nzine_id' => $nzine->getId(), |
||||||
|
'category_title' => $title, |
||||||
|
'category_slug' => $slug, |
||||||
|
]); |
||||||
|
} |
||||||
|
|
||||||
|
$this->entityManager->flush(); |
||||||
|
|
||||||
|
$this->logger->info('Category indices ready', [ |
||||||
|
'nzine_id' => $nzine->getId(), |
||||||
|
'total_categories' => count($categories), |
||||||
|
'indexed_by_slug' => array_keys($categoryIndices), |
||||||
|
]); |
||||||
|
|
||||||
|
return $categoryIndices; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Extract the slug (d-tag value) from event tags |
||||||
|
*/ |
||||||
|
private function extractSlugFromTags(array $tags): ?string |
||||||
|
{ |
||||||
|
foreach ($tags as $tag) { |
||||||
|
if (is_array($tag) && $tag[0] === 'd' && isset($tag[1])) { |
||||||
|
return $tag[1]; |
||||||
|
} |
||||||
|
} |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Add an article to a category index |
||||||
|
* |
||||||
|
* @param EventEntity $categoryIndex The category index event |
||||||
|
* @param string $articleCoordinate The article coordinate (kind:pubkey:slug) |
||||||
|
* @param Nzine $nzine The nzine entity (needed for signing) |
||||||
|
*/ |
||||||
|
public function addArticleToCategoryIndex(EventEntity $categoryIndex, string $articleCoordinate, Nzine $nzine): void |
||||||
|
{ |
||||||
|
// Check if article already exists in the index |
||||||
|
$tags = $categoryIndex->getTags(); |
||||||
|
foreach ($tags as $tag) { |
||||||
|
if ($tag[0] === 'a' && isset($tag[1]) && $tag[1] === $articleCoordinate) { |
||||||
|
// Article already in index |
||||||
|
return; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Get the bot and private key for signing |
||||||
|
$bot = $nzine->getNzineBot(); |
||||||
|
if (!$bot) { |
||||||
|
throw new \RuntimeException('Cannot sign category index: nzine bot not found'); |
||||||
|
} |
||||||
|
|
||||||
|
$bot->setEncryptionService($this->encryptionService); |
||||||
|
$privateKey = $bot->getNsec(); |
||||||
|
|
||||||
|
if (!$privateKey) { |
||||||
|
throw new \RuntimeException('Cannot sign category index: bot private key not found'); |
||||||
|
} |
||||||
|
|
||||||
|
// Add article coordinate to tags |
||||||
|
$tags[] = ['a', $articleCoordinate]; |
||||||
|
|
||||||
|
// Create a new Event object with updated tags |
||||||
|
$event = new Event(); |
||||||
|
$event->setKind($categoryIndex->getKind()); |
||||||
|
$event->setContent($categoryIndex->getContent() ?? ''); |
||||||
|
$event->setPublicKey($categoryIndex->getPubkey()); |
||||||
|
|
||||||
|
// Add all tags including the new article coordinate |
||||||
|
foreach ($tags as $tag) { |
||||||
|
$event->addTag($tag); |
||||||
|
} |
||||||
|
|
||||||
|
// Sign the event with current timestamp |
||||||
|
$signer = new Sign(); |
||||||
|
$signer->signEvent($event, $privateKey); |
||||||
|
|
||||||
|
// Convert to JSON and back to get all properties including sig |
||||||
|
$eventJson = $event->toJson(); |
||||||
|
$eventData = json_decode($eventJson, true); |
||||||
|
|
||||||
|
// Update the EventEntity with new tags, signature, ID, and timestamp |
||||||
|
$categoryIndex->setTags($tags); |
||||||
|
$categoryIndex->setSig($eventData['sig']); |
||||||
|
$categoryIndex->setId($eventData['id']); |
||||||
|
$categoryIndex->setEventId($eventData['id']); |
||||||
|
$categoryIndex->setCreatedAt($eventData['created_at']); |
||||||
|
|
||||||
|
$this->entityManager->persist($categoryIndex); |
||||||
|
|
||||||
|
$this->logger->debug('Added article to category index and re-signed', [ |
||||||
|
'category_slug' => $this->extractSlugFromTags($tags), |
||||||
|
'article_coordinate' => $articleCoordinate, |
||||||
|
'event_id' => $eventData['id'], |
||||||
|
]); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Re-sign and save category index events |
||||||
|
* Should be called after all articles have been added to ensure valid signatures |
||||||
|
* |
||||||
|
* @param array $categoryIndices Map of category slug => EventEntity |
||||||
|
* @param Nzine $nzine The nzine entity |
||||||
|
*/ |
||||||
|
public function resignCategoryIndices(array $categoryIndices, Nzine $nzine): void |
||||||
|
{ |
||||||
|
if (empty($categoryIndices)) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
$bot = $nzine->getNzineBot(); |
||||||
|
if (!$bot) { |
||||||
|
$this->logger->warning('Cannot re-sign category indices: nzine bot not found', [ |
||||||
|
'nzine_id' => $nzine->getId(), |
||||||
|
]); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
$bot->setEncryptionService($this->encryptionService); |
||||||
|
$privateKey = $bot->getNsec(); |
||||||
|
|
||||||
|
if (!$privateKey) { |
||||||
|
$this->logger->warning('Cannot re-sign category indices: bot private key not found', [ |
||||||
|
'nzine_id' => $nzine->getId(), |
||||||
|
]); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
$signer = new Sign(); |
||||||
|
$serializer = new Serializer([new ObjectNormalizer()], [new JsonEncoder()]); |
||||||
|
|
||||||
|
foreach ($categoryIndices as $slug => $categoryIndex) { |
||||||
|
try { |
||||||
|
// Create a new Event from the existing EventEntity |
||||||
|
$event = new Event(); |
||||||
|
$event->setKind($categoryIndex->getKind()); |
||||||
|
$event->setContent($categoryIndex->getContent() ?? ''); |
||||||
|
$event->setPublicKey($categoryIndex->getPubkey()); |
||||||
|
|
||||||
|
// Add all tags from the category index |
||||||
|
foreach ($categoryIndex->getTags() as $tag) { |
||||||
|
$event->addTag($tag); |
||||||
|
} |
||||||
|
|
||||||
|
// Sign the event with current timestamp |
||||||
|
$signer->signEvent($event, $privateKey); |
||||||
|
|
||||||
|
// Convert to JSON and back to get all properties including sig |
||||||
|
$eventJson = $event->toJson(); |
||||||
|
$eventData = json_decode($eventJson, true); |
||||||
|
|
||||||
|
// Update the EventEntity with new signature and timestamp |
||||||
|
$categoryIndex->setSig($eventData['sig']); |
||||||
|
$categoryIndex->setId($eventData['id']); |
||||||
|
$categoryIndex->setEventId($eventData['id']); |
||||||
|
$categoryIndex->setCreatedAt($eventData['created_at']); |
||||||
|
|
||||||
|
$this->entityManager->persist($categoryIndex); |
||||||
|
|
||||||
|
$this->logger->info('Re-signed category index', [ |
||||||
|
'category_slug' => $slug, |
||||||
|
'event_id' => $eventData['id'], |
||||||
|
'article_count' => count(array_filter($categoryIndex->getTags(), fn($tag) => $tag[0] === 'a')), |
||||||
|
]); |
||||||
|
} catch (\Exception $e) { |
||||||
|
$this->logger->error('Failed to re-sign category index', [ |
||||||
|
'category_slug' => $slug, |
||||||
|
'error' => $e->getMessage(), |
||||||
|
]); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
$this->entityManager->flush(); |
||||||
|
|
||||||
|
$this->logger->info('Category indices re-signed', [ |
||||||
|
'nzine_id' => $nzine->getId(), |
||||||
|
'count' => count($categoryIndices), |
||||||
|
]); |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue