diff --git a/assets/styles/layout.css b/assets/styles/layout.css index 7bedb1f..24af931 100644 --- a/assets/styles/layout.css +++ b/assets/styles/layout.css @@ -60,6 +60,19 @@ aside { padding: 1em; } +table { + width: 100%; + margin: 20px 0; +} + +code { + text-wrap: wrap; +} + +hr { + margin: 20px 0; +} + /* Responsive adjustments */ @media (max-width: 768px) { nav, aside { diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index c63e2f2..9c865ce 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -8,6 +8,7 @@ framework: handler_id: Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler cookie_secure: auto cookie_samesite: lax + cookie_lifetime: 0 # integer, lifetime in seconds, 0 means 'valid for the length of the browser session' #esi: true #fragments: true diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 58c2449..e65f640 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -1,19 +1,16 @@ security: - # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords - password_hashers: - Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider providers: - app_user_provider: - entity: - class: App\Entity\User - property: npub + user_dto_provider: + id: App\Security\UserDTOProvider + firewalls: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ security: false main: lazy: true + provider: user_dto_provider custom_authenticators: - App\Security\NostrAuthenticator logout: @@ -31,16 +28,3 @@ security: - { path: ^/admin, roles: ROLE_USER } - { path: ^/nzine, roles: ROLE_USER } # - { path: ^/profile, roles: ROLE_USER } - -when@test: - security: - password_hashers: - # By default, password hashers are resource intensive and take time. This is - # important to generate secure password hashes. In tests however, secure hashes - # are not important, waste resources and increase test times. The following - # reduces the work factor to the lowest possible values. - Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: - algorithm: auto - cost: 4 # Lowest possible value for bcrypt - time_cost: 3 # Lowest possible value for argon - memory_cost: 10 # Lowest possible value for argon diff --git a/config/packages/web_profiler.yaml b/config/packages/web_profiler.yaml index 7b4c1bd..ffb9ff7 100644 --- a/config/packages/web_profiler.yaml +++ b/config/packages/web_profiler.yaml @@ -1,9 +1,8 @@ -when@dev: - web_profiler: - toolbar: true - intercept_redirects: false +web_profiler: + toolbar: true + intercept_redirects: false - framework: - profiler: - only_exceptions: false - collect_serializer_data: true +framework: + profiler: + only_exceptions: false + collect_serializer_data: true diff --git a/config/routes/web_profiler.yaml b/config/routes/web_profiler.yaml index 8d85319..c82beff 100644 --- a/config/routes/web_profiler.yaml +++ b/config/routes/web_profiler.yaml @@ -1,8 +1,7 @@ -when@dev: - web_profiler_wdt: - resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml' - prefix: /_wdt +web_profiler_wdt: + resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml' + prefix: /_wdt - web_profiler_profiler: - resource: '@WebProfilerBundle/Resources/config/routing/profiler.xml' - prefix: /_profiler +web_profiler_profiler: + resource: '@WebProfilerBundle/Resources/config/routing/profiler.xml' + prefix: /_profiler diff --git a/migrations/Version20250206181740.php b/migrations/Version20250206181740.php new file mode 100644 index 0000000..202db87 --- /dev/null +++ b/migrations/Version20250206181740.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE event ADD event_id VARCHAR(225) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE event DROP event_id'); + } +} diff --git a/migrations/Version20250210173157.php b/migrations/Version20250210173157.php new file mode 100644 index 0000000..870236b --- /dev/null +++ b/migrations/Version20250210173157.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE event ALTER event_id SET NOT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + // $this->addSql('ALTER TABLE event ALTER event_id DROP NOT NULL'); + } +} diff --git a/migrations/Version20250210174710.php b/migrations/Version20250210174710.php new file mode 100644 index 0000000..5354916 --- /dev/null +++ b/migrations/Version20250210174710.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE event DROP event_id'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + // $this->addSql('ALTER TABLE event ADD event_id VARCHAR(225) DEFAULT NULL'); + } +} diff --git a/migrations/Version20250302163932.php b/migrations/Version20250302163932.php new file mode 100644 index 0000000..22d7a5c --- /dev/null +++ b/migrations/Version20250302163932.php @@ -0,0 +1,39 @@ +addSql('ALTER TABLE app_user DROP name'); + $this->addSql('ALTER TABLE app_user DROP display_name'); + $this->addSql('ALTER TABLE app_user DROP about'); + $this->addSql('ALTER TABLE app_user DROP website'); + $this->addSql('ALTER TABLE app_user DROP picture'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE app_user ADD name VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE app_user ADD display_name VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE app_user ADD about TEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE app_user ADD website TEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE app_user ADD picture TEXT DEFAULT NULL'); + } +} diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index 08ec19a..f57dbcc 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -3,7 +3,6 @@ namespace App\Controller; use App\Entity\Article; -use App\Entity\User; use App\Enum\KindsEnum; use App\Form\EditorType; use App\Service\NostrClient; @@ -55,13 +54,8 @@ class ArticleController extends AbstractController $articlesCache->save($cacheItem); } - // find user by npub - try { - $nostrClient->getMetadata([$article->getPubkey()]); - } catch (\Exception) { - // eh - } - $author = $entityManager->getRepository(User::class)->findOneBy(['npub' => $article->getPubkey()]); + $meta = $nostrClient->getNpubMetadata($article->getPubkey()); + $author = (array) json_decode($meta->content); return $this->render('Pages/article.html.twig', [ 'article' => $article, diff --git a/src/Controller/AuthorController.php b/src/Controller/AuthorController.php index be5b500..7f08678 100644 --- a/src/Controller/AuthorController.php +++ b/src/Controller/AuthorController.php @@ -5,8 +5,13 @@ declare(strict_types=1); namespace App\Controller; use App\Entity\Article; +use App\Entity\Event; +use App\Entity\Nzine; use App\Entity\User; +use App\Enum\KindsEnum; +use App\Service\NostrClient; use Doctrine\ORM\EntityManagerInterface; +use Psr\Cache\InvalidArgumentException; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; @@ -15,20 +20,30 @@ class AuthorController extends AbstractController { /** * @throws \Exception + * @throws InvalidArgumentException */ #[Route('/p/{npub}', name: 'author-profile')] - public function index($npub, EntityManagerInterface $entityManager): Response + public function index($npub, EntityManagerInterface $entityManager, NostrClient $client): Response { - $author = $entityManager->getRepository(User::class)->findOneBy(['npub' => $npub]); - if (!$author) { - throw new \Exception('No author found'); - } + $meta = $client->getNpubMetadata($npub); + $author = (array) json_decode($meta->content); - $articles = $entityManager->getRepository(Article::class)->findBy(['pubkey' => $npub], ['createdAt' => 'DESC']); + $client->getNpubLongForm($npub); + + $articles = $entityManager->getRepository(Article::class)->findBy(['pubkey' => $npub, 'kind' => KindsEnum::LONGFORM], ['createdAt' => 'DESC']); + + $indices = $entityManager->getRepository(Event::class)->findBy(['pubkey' => $npub, 'kind' => KindsEnum::PUBLICATION_INDEX]); + + $nzines = $entityManager->getRepository(Nzine::class)->findBy(['editor' => $npub]); + + $nzine = $entityManager->getRepository(Nzine::class)->findBy(['npub' => $npub]); return $this->render('Pages/author.html.twig', [ 'author' => $author, - 'articles' => $articles + 'articles' => $articles, + 'nzine' => $nzine, + 'nzines' => $nzines, + 'idx' => $indices ]); } } diff --git a/src/Controller/DefaultController.php b/src/Controller/DefaultController.php index 6862799..eec6521 100644 --- a/src/Controller/DefaultController.php +++ b/src/Controller/DefaultController.php @@ -24,12 +24,20 @@ class DefaultController extends AbstractController #[Route('/', name: 'default')] public function index(): Response { - $original = $this->entityManager->getRepository(Article::class)->findBy([], ['createdAt' => 'DESC'], 10); + $original = $this->entityManager->getRepository(Article::class)->findBy([], ['createdAt' => 'DESC'], 20); $list = array_filter($original, function ($obj) { return !empty($obj->getSlug()); }); + // deduplicate by slugs + $deduplicated = []; + foreach ($list as $item) { + if (!key_exists((string) $item->getSlug(), $deduplicated)) { + $deduplicated[(string) $item->getSlug()] = $item; + } + } + $npubs = array_map(function($obj) { return $obj->getPubkey(); }, $list); @@ -37,7 +45,7 @@ class DefaultController extends AbstractController $this->nostrClient->getMetadata(array_unique($npubs)); return $this->render('home.html.twig', [ - 'list' => $list + 'list' => array_values($deduplicated) ]); } } diff --git a/src/Controller/NzineController.php b/src/Controller/NzineController.php index 4129228..3aeb27f 100644 --- a/src/Controller/NzineController.php +++ b/src/Controller/NzineController.php @@ -34,12 +34,15 @@ class NzineController extends AbstractController * @throws \JsonException */ #[Route('/nzine', name: 'nzine_index')] - public function index(Request $request, NzineWorkflowService $nzineWorkflowService): Response + public function index(Request $request, NzineWorkflowService $nzineWorkflowService, EntityManagerInterface $entityManager): Response { $form = $this->createForm(NzineBotType::class); $form->handleRequest($request); $user = $this->getUser(); + $nzine = $entityManager->getRepository(Nzine::class)->findAll(); + + if ($form->isSubmitted() && $form->isValid()) { $data = $form->getData(); // init object @@ -75,6 +78,7 @@ class NzineController extends AbstractController // existing index $indices = $entityManager->getRepository(EventEntity::class)->findBy(['pubkey' => $npub, 'kind' => KindsEnum::PUBLICATION_INDEX]); + $mainIndexCandidates = array_filter($indices, function ($index) use ($nzine) { return $index->getSlug() == $nzine->getSlug(); }); @@ -86,6 +90,7 @@ class NzineController extends AbstractController $catForm = $this->createForm(NzineType::class, ['categories' => $nzine->getMainCategories()]); $catForm->handleRequest($request); + if ($catForm->isSubmitted() && $catForm->isValid()) { // Process and normalize the 'tags' field $data = $catForm->get('categories')->getData(); @@ -102,9 +107,22 @@ class NzineController extends AbstractController $managerRegistry->resetManager(); } - // TODO create and update indices + $catIndices = []; + + $bot = $nzine->getNzineBot(); + $bot->setEncryptionService($encryptionService); + $private_key = $bot->getNsec(); // decrypted en route + foreach ($data as $cat) { - // find or create new index + // check if such an index exists, only create new cats + $id = array_filter($indices, function ($k) use ($cat) { + return $cat['title'] === $k->getTitle(); + }); + if (!empty($id)) { continue; } + + // create new index + // currently not possible to edit existing, because there is no way to tell what has changed + // and which is the corresponding event $slugger = new AsciiSlugger(); $title = $cat['title']; $slug = $mainIndex->getSlug().'-'.$slugger->slug($title)->lower(); @@ -116,24 +134,34 @@ class NzineController extends AbstractController $index->addTag(['title' => $title]); $index->addTag(['auto-update' => 'yes']); $index->addTag(['type' => 'magazine']); - // TODO add indexed items that fall into the category + foreach ($cat['tags'] as $tag) { + $index->addTag(['t' => $tag]); + } + $index->setPublicKey($nzine->getNpub()); $signer = new Sign(); - // TODO get key - - $private_key = $encryptionService->decrypt($nzine->getNsec()); $signer->signEvent($index, $private_key); // save to persistence, first map to EventEntity $serializer = new Serializer([new ObjectNormalizer()],[new JsonEncoder()]); $i = $serializer->deserialize($index->toJson(), EventEntity::class, 'json'); // don't save any more for now $entityManager->persist($i); - // $entityManager->flush(); + $entityManager->flush(); // TODO publish index to relays + + $catIndices[] = $index; } - // TODO add the new and updated indices to the main index + // add the new and updated indices to the main index + foreach ($catIndices as $idx) { + $mainIndex->addTag(['e' => $idx->getId() ]); + } + // re-sign main index and save to relays + // $signer = new Sign(); + // $signer->signEvent($mainIndex, $private_key); + // for now, just save new index + $entityManager->flush(); // redirect to route nzine_view return $this->redirectToRoute('nzine_view', [ @@ -144,7 +172,7 @@ class NzineController extends AbstractController return $this->render('pages/nzine-editor.html.twig', [ 'nzine' => $nzine, 'indices' => $indices, - 'bot' => $bot, + 'bot' => $bot ?? null, // if null, the profile for the bot doesn't exist yet 'catForm' => $catForm ]); } @@ -172,11 +200,16 @@ class NzineController extends AbstractController } // Find all index events for this nzine $indices = $entityManager->getRepository(EventEntity::class)->findBy(['pubkey' => $npub, 'kind' => KindsEnum::PUBLICATION_INDEX]); - $main = $indices[0]; + $mainIndexCandidates = array_filter($indices, function ($index) use ($nzine) { + return $index->getSlug() == $nzine->getSlug(); + }); + + $mainIndex = array_pop($mainIndexCandidates); return $this->render('pages/nzine.html.twig', [ 'nzine' => $nzine, - 'index' => $main + 'index' => $mainIndex, + 'events' => $indices, // TODO traverse all and collect all leaves ]); } diff --git a/src/Entity/Event.php b/src/Entity/Event.php index aee6777..52efbda 100644 --- a/src/Entity/Event.php +++ b/src/Entity/Event.php @@ -14,6 +14,8 @@ class Event #[ORM\Id] #[ORM\Column(length: 225)] private string $id; + #[ORM\Column(length: 225, nullable: true)] + private ?string $eventId = null; #[ORM\Column(type: Types::INTEGER)] private int $kind = 0; #[ORM\Column(length: 255)] @@ -37,6 +39,18 @@ class Event $this->id = $id; } + public function getEventId(): ?string + { + return $this->eventId; + } + + public function setEventId(string $eventId): static + { + $this->eventId = $eventId; + + return $this; + } + public function getKind(): int { return $this->kind; @@ -108,6 +122,16 @@ class Event return null; } + public function getSummary(): ?string + { + foreach ($this->getTags() as $tag) { + if (array_key_first($tag) === 'summary') { + return $tag['summary']; + } + } + return null; + } + public function getSlug(): ?string { foreach ($this->getTags() as $tag) { @@ -118,4 +142,11 @@ class Event return null; } + + public function addTag(array $tag): static + { + $this->tags[] = $tag; + + return $this; + } } diff --git a/src/Entity/NzineBot.php b/src/Entity/NzineBot.php index 79c4b9c..81d7149 100644 --- a/src/Entity/NzineBot.php +++ b/src/Entity/NzineBot.php @@ -14,13 +14,15 @@ class NzineBot #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')] private ?int $id = null; + private ?EncryptionService $encryptionService = null; #[ORM\Column(type: 'string', length: 255)] private ?string $encryptedNsec = null; #[Ignore] private ?string $nsec = null; - public function __construct(private readonly EncryptionService $encryptionService) + public function setEncryptionService(EncryptionService $encryptionService): void { + $this->encryptionService = $encryptionService; } public function getId(): ?int diff --git a/src/Entity/User.php b/src/Entity/User.php index ed589c8..3629376 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -5,14 +5,13 @@ namespace App\Entity; use App\Repository\UserEntityRepository; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; -use Symfony\Component\Security\Core\User\UserInterface; /** * Entity storing local user representations */ #[ORM\Entity(repositoryClass: UserEntityRepository::class)] #[ORM\Table(name: "app_user")] -class User implements UserInterface +class User { #[ORM\Id] #[ORM\GeneratedValue] @@ -22,21 +21,6 @@ class User implements UserInterface #[ORM\Column(unique: true)] private ?string $npub = null; - #[ORM\Column(nullable: true)] - private ?string $name = null; - - #[ORM\Column(nullable: true)] - private ?string $displayName = null; - - #[ORM\Column(type: Types::TEXT, nullable: true)] - private ?string $about = null; - - #[ORM\Column(type: Types::TEXT, nullable: true)] - private ?string $website = null; - - #[ORM\Column(type: Types::TEXT, nullable: true)] - private ?string $picture = null; - #[ORM\Column(type: Types::JSON, nullable: true)] private array $roles = []; @@ -64,16 +48,6 @@ class User implements UserInterface return $this; } - public function eraseCredentials(): void - { - // TODO: Implement eraseCredentials() method. - } - - public function getUserIdentifier(): string - { - return $this->npub; - } - public function getId(): ?int { return $this->id; @@ -93,54 +67,4 @@ class User implements UserInterface { $this->npub = $npub; } - - public function getName(): ?string - { - return $this->name; - } - - public function setName(?string $name): void - { - $this->name = $name; - } - - public function getDisplayName(): ?string - { - return $this->displayName; - } - - public function setDisplayName(?string $displayName): void - { - $this->displayName = $displayName; - } - - public function getAbout(): ?string - { - return $this->about; - } - - public function setAbout(?string $about): void - { - $this->about = $about; - } - - public function getWebsite(): ?string - { - return $this->website; - } - - public function setWebsite(?string $website): void - { - $this->website = $website; - } - - public function getPicture(): ?string - { - return $this->picture; - } - - public function setPicture(?string $picture): void - { - $this->picture = $picture; - } } diff --git a/src/Enum/KindsEnum.php b/src/Enum/KindsEnum.php index 3495141..db1eab2 100644 --- a/src/Enum/KindsEnum.php +++ b/src/Enum/KindsEnum.php @@ -5,8 +5,9 @@ namespace App\Enum; enum KindsEnum: int { case METADATA = 0; // metadata, NIP-01 - case TEXT_NOTE = 1; // text note, NIP-01 - case REPOST = 6; // Only wraps kind 1, NIP-18 + case TEXT_NOTE = 1; // text note, NIP-01, will not implement + case FOLLOWS = 3; + case REPOST = 6; // Only wraps kind 1, NIP-18, will not implement case GENERIC_REPOST = 16; // Generic repost, original kind signalled in a "k" tag, NIP-18 case FILE_METADATA = 1063; // NIP-94 case PINNED_LONGFORM = 10023; // Special purpose curation set, NIP-51, seems deprecated? diff --git a/src/Form/MainCategoryType.php b/src/Form/MainCategoryType.php index 6965f5e..ab8b461 100644 --- a/src/Form/MainCategoryType.php +++ b/src/Form/MainCategoryType.php @@ -23,7 +23,7 @@ class MainCategoryType extends AbstractType 'label' => 'Title', ]) ->add('tags', TextType::class, [ - 'label' => 'Tags (comma-separated)', + 'label' => 'Tags', ]); $builder->get('tags')->addModelTransformer($this->transformer); diff --git a/src/Security/NostrAuthenticator.php b/src/Security/NostrAuthenticator.php index 49c6179..f4c28a3 100644 --- a/src/Security/NostrAuthenticator.php +++ b/src/Security/NostrAuthenticator.php @@ -3,8 +3,6 @@ namespace App\Security; use App\Entity\Event; -use App\Service\NostrClient; -use Doctrine\ORM\EntityManagerInterface; use Mdanter\Ecc\Crypto\Signature\SchnorrSignature; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -21,9 +19,6 @@ use Symfony\Component\Serializer\Serializer; class NostrAuthenticator extends AbstractAuthenticator implements InteractiveAuthenticatorInterface { - public function __construct(private readonly NostrClient $nostrClient) - { - } public function supports(Request $request): ?bool { @@ -54,19 +49,8 @@ class NostrAuthenticator extends AbstractAuthenticator implements InteractiveAut throw new AuthenticationException('Invalid Authorization header'); } - // default, in case this is a plain key with no metadata event - $user = new \App\Entity\User(); - $user->setNpub($event->getPubkey()); - - try { - $this->nostrClient->getMetadata([$event->getPubkey()]); - } catch (\Exception) { - // even if the user metadata not found, if sig is valid, login the pubkey - // TODO log? - } - return new SelfValidatingPassport( - new UserBadge($user->getUserIdentifier()) + new UserBadge($event->getPubkey()) ); } diff --git a/src/Security/UserDTO.php b/src/Security/UserDTO.php new file mode 100644 index 0000000..88ab301 --- /dev/null +++ b/src/Security/UserDTO.php @@ -0,0 +1,63 @@ +user = $user; + $this->metadata = $metadata; + $this->relays = $relays; + } + + public function getUser(): User + { + return $this->user; + } + + public function getMetadata() + { + return $this->metadata; + } + + public function getDisplayName() { + return $this->metadata->name; + } + + /** + * @return null|array + */ + public function getRelays(): ?array + { + return $this->relays; + } + + // Delegate UserInterface methods to the wrapped User entity + public function getRoles(): array + { + return $this->user->getRoles(); + } + + public function eraseCredentials(): void + { + $this->metadata = null; + $this->relays = null; + } + + public function getUserIdentifier(): string + { + return $this->user->getNpub(); + } + + public function getNpub(): string { + return $this->user->getNpub(); + } +} diff --git a/src/Security/UserDTOProvider.php b/src/Security/UserDTOProvider.php new file mode 100644 index 0000000..2fff607 --- /dev/null +++ b/src/Security/UserDTOProvider.php @@ -0,0 +1,73 @@ +loadUserByIdentifier($user->getUserIdentifier()); + } + + /** + * @inheritDoc + */ + public function supportsClass(string $class): bool + { + return $class === UserDTO::class; + } + + /** + * @inheritDoc + */ + public function loadUserByIdentifier(string $identifier): UserInterface + { + $user = $this->entityManager->getRepository(User::class)->findOneBy(['npub' => $identifier]); + + if (!$user) { + // user + $user = new User(); + $user->setNpub($identifier); + $this->entityManager->persist($user); + $this->entityManager->flush(); + } + + try { + $data = $this->nostrClient->getLoginData($identifier); + foreach ($data as $d) { + $ev = $d->event; + if ($ev->kind === 0) { + $metadata = json_decode($ev->content); + } + if ($ev->kind === 10002) { + $relays = $ev->tags; + } + } + } catch (\Exception) { + // even if the user metadata not found, if sig is valid, login the pubkey + $metadata = new \stdClass(); + $metadata->name = substr($identifier, 0, 5) . ':' . substr($identifier, -5); + } + + return new UserDTO($user, $metadata ?? null, $relays ?? null); + + } +} diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index 3631422..a8925bc 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -7,25 +7,162 @@ use App\Entity\User; use App\Enum\KindsEnum; use App\Factory\ArticleFactory; use App\Repository\UserEntityRepository; +use App\Security\UserDTO; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\Persistence\ManagerRegistry; +use Psr\Cache\InvalidArgumentException; use Psr\Log\LoggerInterface; +use swentel\nostr\Event\Event; use swentel\nostr\Filter\Filter; +use swentel\nostr\Message\EventMessage; use swentel\nostr\Message\RequestMessage; use swentel\nostr\Relay\Relay; use swentel\nostr\Relay\RelaySet; use swentel\nostr\Request\Request; use swentel\nostr\Subscription\Subscription; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Cache\ItemInterface; class NostrClient { + private $defaultRelaySet; public function __construct(private readonly EntityManagerInterface $entityManager, + private readonly ManagerRegistry $managerRegistry, private readonly UserEntityRepository $userEntityRepository, private readonly ArticleFactory $articleFactory, private readonly SerializerInterface $serializer, + private readonly TokenStorageInterface $tokenStorage, + private readonly CacheInterface $cacheApp, private readonly LoggerInterface $logger) { + // TODO configure read and write relays for logged in users from their 10002 events + $this->defaultRelaySet = new RelaySet(); + $this->defaultRelaySet->addRelay(new Relay('wss://relay.damus.io')); // public relay + $this->defaultRelaySet->addRelay(new Relay('wss://nos.lol')); // public relay + } + + public function getLoginData($npub) + { + $subscription = new Subscription(); + $subscriptionId = $subscription->setId(); + $filter = new Filter(); + $filter->setKinds([KindsEnum::METADATA, KindsEnum::RELAY_LIST]); + $filter->setAuthors([$npub]); + $requestMessage = new RequestMessage($subscriptionId, [$filter]); + // use default aggregator relay + $relay = new Relay('wss://purplepag.es'); + + $request = new Request($relay, $requestMessage); + + $response = $request->send(); + // response is an n-dimensional array, where n is the number of relays in the set + // check that response has events in the results + foreach ($response as $relayRes) { + $filtered = array_filter($relayRes, function ($item) { + return $item->type === 'EVENT'; + }); + if (count($filtered) > 0) { + return $filtered; + } + } + + return null; + } + + /** + * @throws \Exception + * @throws InvalidArgumentException + */ + public function getNpubMetadata($npub) + { + // cache metadata, only fetch new, if no cache hit + return $this->cacheApp->get($npub.'-0', function (ItemInterface $item) use ($npub) { + $item->expiresAfter(7000); + + $subscription = new Subscription(); + $subscriptionId = $subscription->setId(); + $filter = new Filter(); + $filter->setKinds([KindsEnum::METADATA]); + $filter->setAuthors([$npub]); + $requestMessage = new RequestMessage($subscriptionId, [$filter]); + $relays = new RelaySet(); + $relays->addRelay(new Relay('wss://purplepag.es')); // default metadata aggregator + + $request = new Request($relays, $requestMessage); + + $response = $request->send(); + // response is an array of arrays + foreach ($response as $value) { + foreach ($value as $item) { + switch ($item->type) { + case 'EVENT': + return $item->event; + case 'AUTH': + throw new UnauthorizedHttpException('', 'Relay requires authentication'); + case 'ERROR': + case 'NOTICE': + throw new \Exception('An error occurred'); + default: + return null; + } + } + } + return null; + }); + } + + public function getNpubLongForm($npub): void + { + $subscription = new Subscription(); + $subscriptionId = $subscription->setId(); + $filter = new Filter(); + $filter->setKinds([KindsEnum::LONGFORM]); + $filter->setAuthors([$npub]); + $filter->setSince(strtotime('-6 months')); // too much? + $requestMessage = new RequestMessage($subscriptionId, [$filter]); + + // if user is logged in, use their settings + /* @var UserDTO $user */ + $user = $this->tokenStorage->getToken()?->getUser(); + $relays = $this->defaultRelaySet; + if ($user && $user->getRelays()) { + $relays = new RelaySet(); + foreach ($user->getRelays() as $relayArr) { + $relays->addRelay(new Relay($relayArr[1])); + } + } + + $request = new Request($relays, $requestMessage); + + $response = $request->send(); + // response is an n-dimensional array, where n is the number of relays in the set + // check that response has events in the results + foreach ($response as $relayRes) { + $filtered = array_filter($relayRes, function ($item) { + return $item->type === 'EVENT'; + }); + if (count($filtered) > 0) { + $this->saveLongFormContent($filtered); + } + } + // TODO handle relays that require auth + } + + + public function publishEvent(Event $event, array $relays): array + { + $eventMessage = new EventMessage($event); + $relaySet = new RelaySet(); + foreach ($relays as $relayWss) { + $relay = new Relay($relayWss); + $relaySet->addRelay($relay); + } + $relaySet->setMessage($eventMessage); + // TODO handle responses appropriately + return $relaySet->send(); } /** @@ -39,11 +176,16 @@ class NostrClient $filter = new Filter(); $filter->setKinds([KindsEnum::LONGFORM]); // TODO make filters configurable - $filter->setSince(strtotime('-1 week')); // + $filter->setSince(strtotime('-8 weeks')); // $requestMessage = new RequestMessage($subscriptionId, [$filter]); - // TODO make relays configurable - $relays = new RelaySet(); - $relays->addRelay(new Relay('wss://nos.lol')); // default relay + + // if user is logged in, use their settings + $user = $this->tokenStorage->getToken()?->getUser(); + $relays = $this->defaultRelaySet; + if ($user) { + $relays = new RelaySet(); + + } $request = new Request($relays, $requestMessage); @@ -61,6 +203,8 @@ class NostrClient // TODO handle relays that require auth } + + /** * User metadata * NIP-01 @@ -77,7 +221,7 @@ class NostrClient // TODO make relays configurable $relays = new RelaySet(); $relays->addRelay(new Relay('wss://purplepag.es')); // default metadata aggregator - $relays->addRelay(new Relay('wss://nos.lol')); // default metadata aggregator + // $relays->addRelay(new Relay('wss://nos.lol')); // default metadata aggregator $request = new Request($relays, $requestMessage); @@ -102,6 +246,41 @@ class NostrClient } + public function getProfileEvents($npub): void + { + $subscription = new Subscription(); + $subscriptionId = $subscription->setId(); + $filter = new Filter(); + $filter->setKinds([KindsEnum::METADATA, KindsEnum::FOLLOWS, KindsEnum::RELAY_LIST]); + $filter->setAuthors([$npub]); + $requestMessage = new RequestMessage($subscriptionId, [$filter]); + // TODO make relays configurable + $relays = new RelaySet(); + $relays->addRelay(new Relay('wss://purplepag.es')); // default metadata aggregator + $relays->addRelay(new Relay('wss://nos.lol')); // default public + + $request = new Request($relays, $requestMessage); + + $response = $request->send(); + // response is an array of arrays + foreach ($response as $value) { + foreach ($value as $item) { + switch ($item->type) { + case 'EVENT': + dump($item); + break; + case 'AUTH': + throw new UnauthorizedHttpException('', 'Relay requires authentication'); + case 'ERROR': + case 'NOTICE': + throw new \Exception('An error occurred'); + default: + // nothing to do here + } + } + } + } + /** * Save user metadata */ diff --git a/src/Service/NzineWorkflowService.php b/src/Service/NzineWorkflowService.php index 2622ac2..84b6bb4 100644 --- a/src/Service/NzineWorkflowService.php +++ b/src/Service/NzineWorkflowService.php @@ -51,9 +51,12 @@ class NzineWorkflowService // create NZine bot $key = new Key(); $private_key = $key->generatePrivateKey(); - $bot = new NzineBot($this->encryptionService); + $bot = new NzineBot(); + $bot->setEncryptionService($this->encryptionService); $bot->setNsec($private_key); $this->entityManager->persist($bot); + $this->entityManager->flush(); + // publish bot profile $profileContent = [ 'name' => $name, @@ -65,7 +68,7 @@ class NzineWorkflowService $profileEvent->setContent(json_encode($profileContent)); $signer = new Sign(); $signer->signEvent($profileEvent, $private_key); - $this->nostrClient->publishEvent($profileEvent, ['wss://purplepag.es']); + // $this->nostrClient->publishEvent($profileEvent, ['wss://purplepag.es']); // add EDITOR role to the user $role = RolesEnum::EDITOR->value; @@ -91,7 +94,7 @@ class NzineWorkflowService public function createMainIndex(Nzine $nzine, string $title, string $summary): void { if (!$this->nzineWorkflow->can($nzine, 'create_main_index')) { - throw new \LogicException('Cannot create main index in the current state.'); + // throw new \LogicException('Cannot create main index in the current state.'); } $bot = $nzine->getNzineBot(); @@ -99,6 +102,9 @@ class NzineWorkflowService $slugger = new AsciiSlugger(); $slug = 'nzine-'.$slugger->slug($title)->lower().'-'.rand(10000,99999); + // save slug to nzine + $nzine->setSlug($slug); + // create NZine main index $index = new Event(); $index->setKind(KindsEnum::PUBLICATION_INDEX->value); @@ -116,7 +122,6 @@ class NzineWorkflowService $this->entityManager->persist($i); $this->nzineWorkflow->apply($nzine, 'create_main_index'); - $this->entityManager->persist($nzine); $this->entityManager->flush(); } diff --git a/src/Twig/Components/IndexTabs.php b/src/Twig/Components/IndexTabs.php index 2d53fc2..3b88cbd 100644 --- a/src/Twig/Components/IndexTabs.php +++ b/src/Twig/Components/IndexTabs.php @@ -20,11 +20,8 @@ class IndexTabs public int $activeTab = 1; // Default active tab #[LiveProp] - public array $tabs = [ - ['id' => 1, 'label' => 'Tab 1'], - ['id' => 2, 'label' => 'Tab 2'], - ['id' => 3, 'label' => 'Tab 3'], - ]; + /** ['id' => 1, 'label' => 'Tab 1'] */ + public array $tabs = []; #[LiveAction] public function changeTab(#[LiveArg] int $id): void @@ -44,18 +41,23 @@ class IndexTabs if (array_key_first($tag) === 'a') { $ref = $tag['a']; list($kind,$npub,$slug) = explode(':',$ref); - // find all connected indices - $this->entityManager->getRepository(EventEntity::class)->findOneBy(['slug' => $slug]); + $cat = $this->entityManager->getRepository(EventEntity::class)->findOneBy(['slug' => $slug]); + } elseif (array_key_first($tag) === 'e') { + $cat = $this->entityManager->getRepository(EventEntity::class)->findOneBy(['id' => $tag['e']]); + $next = count($this->tabs) + 1; + $this->tabs[] = ['id' => $next, 'label' => $cat->getTitle()]; } } } - public function getTabContent(): string + public function getTabContent(): string|array { return match ($this->activeTab) { - 1 => 'This is content for Tab 1. Loaded directly in Live Component!', + 1 => [ + '30023:c1e6505c02da8d1b0a5b3d6db6e19b2eb22dcd54f0e86306ec8a213902b3157e:809797', + '30032:c1e6505c02da8d1b0a5b3d6db6e19b2eb22dcd54f0e86306ec8a213902b3157e:012025-7zsaxt' + ], 2 => 'This is content for Tab 2. No AJAX needed!', - 3 => 'This is content for Tab 3. Server-side rendering!', default => 'No content available.', }; } diff --git a/src/Twig/Components/Molecules/ArticleFromATag.php b/src/Twig/Components/Molecules/ArticleFromATag.php new file mode 100644 index 0000000..949ce3d --- /dev/null +++ b/src/Twig/Components/Molecules/ArticleFromATag.php @@ -0,0 +1,11 @@ +nostrClient->getMetadata(); $this->user = $this->entityManager->getRepository(User::class)->findOneBy(['npub' => $npub]); } } diff --git a/src/Twig/Components/Molecules/UserFromNpub.php b/src/Twig/Components/Molecules/UserFromNpub.php index b8981b4..97862c3 100644 --- a/src/Twig/Components/Molecules/UserFromNpub.php +++ b/src/Twig/Components/Molecules/UserFromNpub.php @@ -2,23 +2,28 @@ namespace App\Twig\Components\Molecules; -use App\Entity\User; -use Doctrine\ORM\EntityManagerInterface; +use App\Service\NostrClient; +use Psr\Cache\InvalidArgumentException; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; #[AsTwigComponent] final class UserFromNpub { public string $npub; - public ?User $user = null; + public ?array $user = null; - public function __construct(private EntityManagerInterface $entityManager) + public function __construct(private readonly NostrClient $nostrClient) { } public function mount(string $npub): void { $this->npub = $npub; - $this->user = $this->entityManager->getRepository(User::class)->findOneBy(['npub' => $npub]); + try { + $meta = $this->nostrClient->getNpubMetadata($npub); + $this->user = (array) json_decode($meta->content); + } catch (InvalidArgumentException|\Exception) { + $this->user = null; + } } } diff --git a/src/Twig/Components/Organisms/ZineList.php b/src/Twig/Components/Organisms/ZineList.php index 405e859..a7104fa 100644 --- a/src/Twig/Components/Organisms/ZineList.php +++ b/src/Twig/Components/Organisms/ZineList.php @@ -2,7 +2,9 @@ namespace App\Twig\Components\Organisms; +use App\Entity\Event; use App\Entity\Nzine; +use App\Enum\KindsEnum; use Doctrine\ORM\EntityManagerInterface; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; @@ -10,6 +12,7 @@ use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; final class ZineList { public array $nzines = []; + public array $indices = []; public function __construct(private readonly EntityManagerInterface $entityManager) { @@ -18,5 +21,17 @@ final class ZineList public function mount(?array $nzines = null): void { $this->nzines = $nzines ?? $this->entityManager->getRepository(Nzine::class)->findAll(); + if (count($this->nzines) > 0) { + // find indices for each nzine + foreach ($this->nzines as $zine) { + $ids = $this->entityManager->getRepository(Event::class)->findBy(['pubkey' => $zine->getNpub(), 'kind' => KindsEnum::PUBLICATION_INDEX]); + $id = array_filter($ids, function($k) use ($zine) { + return $k->getSlug() == $zine->getSlug(); + }); + if ($id) { + $this->indices[$zine->getNpub()] = array_pop($id); + } + } + } } } diff --git a/templates/admin/roles.html.twig b/templates/admin/roles.html.twig index a3cd83d..897ab14 100644 --- a/templates/admin/roles.html.twig +++ b/templates/admin/roles.html.twig @@ -10,6 +10,15 @@ {% endfor %} + {% if app.user %} +
+ {{ app.user.userIdentifier }}
+ {% for role in app.user.roles %} + {{ role }}
+ {% endfor %} +
+ {% endif %} + {# Form for adding a new role #} {{ form_start(form) }} {{ form_widget(form) }} diff --git a/templates/components/Atoms/NameOrNpub.html.twig b/templates/components/Atoms/NameOrNpub.html.twig index 3d943f1..58f3944 100644 --- a/templates/components/Atoms/NameOrNpub.html.twig +++ b/templates/components/Atoms/NameOrNpub.html.twig @@ -1,9 +1,7 @@ - {% if author.displayName is not empty %} - {{ author.displayName }} + {% if author.display_name is defined and author.display_name is not empty %} + {{ author.display_name }} {% elseif author.name is not empty %} {{ author.name }} - {% else %} - {{ author.npub }} {% endif %} diff --git a/templates/components/IndexTabs.html.twig b/templates/components/IndexTabs.html.twig index 8181b3b..ab61ca4 100644 --- a/templates/components/IndexTabs.html.twig +++ b/templates/components/IndexTabs.html.twig @@ -17,7 +17,7 @@
- {{ this.tabContent()|raw }} +
diff --git a/templates/components/Molecules/ArticleFromATag.html.twig b/templates/components/Molecules/ArticleFromATag.html.twig new file mode 100644 index 0000000..54b157f --- /dev/null +++ b/templates/components/Molecules/ArticleFromATag.html.twig @@ -0,0 +1,7 @@ +{% if list is iterable %} + {% for item in list %} +

{{ item }}

+ {% endfor %} +{% else %} +

{{ list }}

+{% endif %} diff --git a/templates/components/Organisms/CardList.html.twig b/templates/components/Organisms/CardList.html.twig index e543fad..2be9256 100644 --- a/templates/components/Organisms/CardList.html.twig +++ b/templates/components/Organisms/CardList.html.twig @@ -1,5 +1,7 @@
{% for item in list %} - + {% if item.slug is not empty %} + + {% endif %} {% endfor %}
diff --git a/templates/components/Organisms/ZineList.html.twig b/templates/components/Organisms/ZineList.html.twig index e1d5e8c..8c867cd 100644 --- a/templates/components/Organisms/ZineList.html.twig +++ b/templates/components/Organisms/ZineList.html.twig @@ -1,5 +1,16 @@
{% for item in nzines %} - + {% if item.npub in indices|keys %} + {% set idx = indices[item.npub] %} + {% if idx|length > 0 %} + +
+

{{ idx.title }}

+ +
+
+
+ {% endif %} + {% endif %} {% endfor %}
diff --git a/templates/home.html.twig b/templates/home.html.twig index a0ea23f..e1e483f 100644 --- a/templates/home.html.twig +++ b/templates/home.html.twig @@ -9,6 +9,6 @@ {% endblock %} {% block aside %} - {# sidebar #} +
Magazines
{% endblock %} diff --git a/templates/pages/article.html.twig b/templates/pages/article.html.twig index 4a87326..b06309f 100644 --- a/templates/pages/article.html.twig +++ b/templates/pages/article.html.twig @@ -8,7 +8,7 @@ {% if author %}
- {{ 'text.byline'|trans }} + {{ 'text.byline'|trans }} diff --git a/templates/pages/author.html.twig b/templates/pages/author.html.twig index d5a7764..3898ade 100644 --- a/templates/pages/author.html.twig +++ b/templates/pages/author.html.twig @@ -1,13 +1,25 @@ {% extends 'base.html.twig' %} {% block body %} + + {% if author.image is defined %} + {{ author.name }} + {% endif %} +

-

- {{ author.about }} -

+ {% if author.about is defined %} +

+ {{ author.about }} +

+ {% endif %} {% if nzine %} View as N-Zine + +

List of indices

+ {% for i in idx %} + {{ i.title }} + {% endfor %} {% endif %} diff --git a/templates/pages/nzine-editor.html.twig b/templates/pages/nzine-editor.html.twig index 961b2a2..a9a1fc6 100644 --- a/templates/pages/nzine-editor.html.twig +++ b/templates/pages/nzine-editor.html.twig @@ -28,8 +28,14 @@ {% else %}

{{ 'heading.editNzine'|trans }}

-

{{ bot.name }}

-

{{ bot.about }}

+ +

Indices

+ +

Categories

diff --git a/templates/pages/nzine.html.twig b/templates/pages/nzine.html.twig index 1525cad..a24b976 100644 --- a/templates/pages/nzine.html.twig +++ b/templates/pages/nzine.html.twig @@ -2,23 +2,16 @@ {% block body %}

- {# TODO replace with main index data #}

{{ index.title }}

-

{{ index.slug }}

-
- -
+

{{ index.summary }}


- {% if list is defined %} - - {% endif %} +
{% endblock %} + +{% block aside %} +

TODO search

+{% endblock %}