Browse Source

Change config, update security,

imwald
Nuša Pukšič 11 months ago
parent
commit
3917e24ca7
  1. 13
      assets/styles/layout.css
  2. 1
      config/packages/framework.yaml
  3. 24
      config/packages/security.yaml
  4. 5
      config/packages/web_profiler.yaml
  5. 5
      config/routes/web_profiler.yaml
  6. 31
      migrations/Version20250206181740.php
  7. 31
      migrations/Version20250210173157.php
  8. 31
      migrations/Version20250210174710.php
  9. 39
      migrations/Version20250302163932.php
  10. 10
      src/Controller/ArticleController.php
  11. 29
      src/Controller/AuthorController.php
  12. 12
      src/Controller/DefaultController.php
  13. 57
      src/Controller/NzineController.php
  14. 31
      src/Entity/Event.php
  15. 4
      src/Entity/NzineBot.php
  16. 78
      src/Entity/User.php
  17. 5
      src/Enum/KindsEnum.php
  18. 2
      src/Form/MainCategoryType.php
  19. 18
      src/Security/NostrAuthenticator.php
  20. 63
      src/Security/UserDTO.php
  21. 73
      src/Security/UserDTOProvider.php
  22. 187
      src/Service/NostrClient.php
  23. 13
      src/Service/NzineWorkflowService.php
  24. 22
      src/Twig/Components/IndexTabs.php
  25. 11
      src/Twig/Components/Molecules/ArticleFromATag.php
  26. 4
      src/Twig/Components/Molecules/Card.php
  27. 15
      src/Twig/Components/Molecules/UserFromNpub.php
  28. 15
      src/Twig/Components/Organisms/ZineList.php
  29. 9
      templates/admin/roles.html.twig
  30. 6
      templates/components/Atoms/NameOrNpub.html.twig
  31. 2
      templates/components/IndexTabs.html.twig
  32. 7
      templates/components/Molecules/ArticleFromATag.html.twig
  33. 2
      templates/components/Organisms/CardList.html.twig
  34. 13
      templates/components/Organisms/ZineList.html.twig
  35. 2
      templates/home.html.twig
  36. 2
      templates/pages/article.html.twig
  37. 12
      templates/pages/author.html.twig
  38. 10
      templates/pages/nzine-editor.html.twig
  39. 19
      templates/pages/nzine.html.twig

13
assets/styles/layout.css

@ -60,6 +60,19 @@ aside {
padding: 1em; padding: 1em;
} }
table {
width: 100%;
margin: 20px 0;
}
code {
text-wrap: wrap;
}
hr {
margin: 20px 0;
}
/* Responsive adjustments */ /* Responsive adjustments */
@media (max-width: 768px) { @media (max-width: 768px) {
nav, aside { nav, aside {

1
config/packages/framework.yaml

@ -8,6 +8,7 @@ framework:
handler_id: Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler handler_id: Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler
cookie_secure: auto cookie_secure: auto
cookie_samesite: lax cookie_samesite: lax
cookie_lifetime: 0 # integer, lifetime in seconds, 0 means 'valid for the length of the browser session'
#esi: true #esi: true
#fragments: true #fragments: true

24
config/packages/security.yaml

@ -1,19 +1,16 @@
security: 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 # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers: providers:
app_user_provider: user_dto_provider:
entity: id: App\Security\UserDTOProvider
class: App\Entity\User
property: npub
firewalls: firewalls:
dev: dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/ pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false security: false
main: main:
lazy: true lazy: true
provider: user_dto_provider
custom_authenticators: custom_authenticators:
- App\Security\NostrAuthenticator - App\Security\NostrAuthenticator
logout: logout:
@ -31,16 +28,3 @@ security:
- { path: ^/admin, roles: ROLE_USER } - { path: ^/admin, roles: ROLE_USER }
- { path: ^/nzine, roles: ROLE_USER } - { path: ^/nzine, roles: ROLE_USER }
# - { path: ^/profile, 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

5
config/packages/web_profiler.yaml

@ -1,9 +1,8 @@
when@dev: web_profiler:
web_profiler:
toolbar: true toolbar: true
intercept_redirects: false intercept_redirects: false
framework: framework:
profiler: profiler:
only_exceptions: false only_exceptions: false
collect_serializer_data: true collect_serializer_data: true

5
config/routes/web_profiler.yaml

@ -1,8 +1,7 @@
when@dev: web_profiler_wdt:
web_profiler_wdt:
resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml' resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml'
prefix: /_wdt prefix: /_wdt
web_profiler_profiler: web_profiler_profiler:
resource: '@WebProfilerBundle/Resources/config/routing/profiler.xml' resource: '@WebProfilerBundle/Resources/config/routing/profiler.xml'
prefix: /_profiler prefix: /_profiler

31
migrations/Version20250206181740.php

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250206181740 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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');
}
}

31
migrations/Version20250210173157.php

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250210173157 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
// $this->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');
}
}

31
migrations/Version20250210174710.php

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250210174710 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
// $this->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');
}
}

39
migrations/Version20250302163932.php

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250302163932 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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');
}
}

10
src/Controller/ArticleController.php

@ -3,7 +3,6 @@
namespace App\Controller; namespace App\Controller;
use App\Entity\Article; use App\Entity\Article;
use App\Entity\User;
use App\Enum\KindsEnum; use App\Enum\KindsEnum;
use App\Form\EditorType; use App\Form\EditorType;
use App\Service\NostrClient; use App\Service\NostrClient;
@ -55,13 +54,8 @@ class ArticleController extends AbstractController
$articlesCache->save($cacheItem); $articlesCache->save($cacheItem);
} }
// find user by npub $meta = $nostrClient->getNpubMetadata($article->getPubkey());
try { $author = (array) json_decode($meta->content);
$nostrClient->getMetadata([$article->getPubkey()]);
} catch (\Exception) {
// eh
}
$author = $entityManager->getRepository(User::class)->findOneBy(['npub' => $article->getPubkey()]);
return $this->render('Pages/article.html.twig', [ return $this->render('Pages/article.html.twig', [
'article' => $article, 'article' => $article,

29
src/Controller/AuthorController.php

@ -5,8 +5,13 @@ declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use App\Entity\Article; use App\Entity\Article;
use App\Entity\Event;
use App\Entity\Nzine;
use App\Entity\User; use App\Entity\User;
use App\Enum\KindsEnum;
use App\Service\NostrClient;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@ -15,20 +20,30 @@ class AuthorController extends AbstractController
{ {
/** /**
* @throws \Exception * @throws \Exception
* @throws InvalidArgumentException
*/ */
#[Route('/p/{npub}', name: 'author-profile')] #[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]); $meta = $client->getNpubMetadata($npub);
if (!$author) { $author = (array) json_decode($meta->content);
throw new \Exception('No author found');
} $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]);
$articles = $entityManager->getRepository(Article::class)->findBy(['pubkey' => $npub], ['createdAt' => 'DESC']); $nzine = $entityManager->getRepository(Nzine::class)->findBy(['npub' => $npub]);
return $this->render('Pages/author.html.twig', [ return $this->render('Pages/author.html.twig', [
'author' => $author, 'author' => $author,
'articles' => $articles 'articles' => $articles,
'nzine' => $nzine,
'nzines' => $nzines,
'idx' => $indices
]); ]);
} }
} }

12
src/Controller/DefaultController.php

@ -24,12 +24,20 @@ class DefaultController extends AbstractController
#[Route('/', name: 'default')] #[Route('/', name: 'default')]
public function index(): Response 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) { $list = array_filter($original, function ($obj) {
return !empty($obj->getSlug()); 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) { $npubs = array_map(function($obj) {
return $obj->getPubkey(); return $obj->getPubkey();
}, $list); }, $list);
@ -37,7 +45,7 @@ class DefaultController extends AbstractController
$this->nostrClient->getMetadata(array_unique($npubs)); $this->nostrClient->getMetadata(array_unique($npubs));
return $this->render('home.html.twig', [ return $this->render('home.html.twig', [
'list' => $list 'list' => array_values($deduplicated)
]); ]);
} }
} }

57
src/Controller/NzineController.php

@ -34,12 +34,15 @@ class NzineController extends AbstractController
* @throws \JsonException * @throws \JsonException
*/ */
#[Route('/nzine', name: 'nzine_index')] #[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 = $this->createForm(NzineBotType::class);
$form->handleRequest($request); $form->handleRequest($request);
$user = $this->getUser(); $user = $this->getUser();
$nzine = $entityManager->getRepository(Nzine::class)->findAll();
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
$data = $form->getData(); $data = $form->getData();
// init object // init object
@ -75,6 +78,7 @@ class NzineController extends AbstractController
// existing index // existing index
$indices = $entityManager->getRepository(EventEntity::class)->findBy(['pubkey' => $npub, 'kind' => KindsEnum::PUBLICATION_INDEX]); $indices = $entityManager->getRepository(EventEntity::class)->findBy(['pubkey' => $npub, 'kind' => KindsEnum::PUBLICATION_INDEX]);
$mainIndexCandidates = array_filter($indices, function ($index) use ($nzine) { $mainIndexCandidates = array_filter($indices, function ($index) use ($nzine) {
return $index->getSlug() == $nzine->getSlug(); return $index->getSlug() == $nzine->getSlug();
}); });
@ -86,6 +90,7 @@ class NzineController extends AbstractController
$catForm = $this->createForm(NzineType::class, ['categories' => $nzine->getMainCategories()]); $catForm = $this->createForm(NzineType::class, ['categories' => $nzine->getMainCategories()]);
$catForm->handleRequest($request); $catForm->handleRequest($request);
if ($catForm->isSubmitted() && $catForm->isValid()) { if ($catForm->isSubmitted() && $catForm->isValid()) {
// Process and normalize the 'tags' field // Process and normalize the 'tags' field
$data = $catForm->get('categories')->getData(); $data = $catForm->get('categories')->getData();
@ -102,9 +107,22 @@ class NzineController extends AbstractController
$managerRegistry->resetManager(); $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) { 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(); $slugger = new AsciiSlugger();
$title = $cat['title']; $title = $cat['title'];
$slug = $mainIndex->getSlug().'-'.$slugger->slug($title)->lower(); $slug = $mainIndex->getSlug().'-'.$slugger->slug($title)->lower();
@ -116,24 +134,34 @@ class NzineController extends AbstractController
$index->addTag(['title' => $title]); $index->addTag(['title' => $title]);
$index->addTag(['auto-update' => 'yes']); $index->addTag(['auto-update' => 'yes']);
$index->addTag(['type' => 'magazine']); $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(); $signer = new Sign();
// TODO get key
$private_key = $encryptionService->decrypt($nzine->getNsec());
$signer->signEvent($index, $private_key); $signer->signEvent($index, $private_key);
// save to persistence, first map to EventEntity // save to persistence, first map to EventEntity
$serializer = new Serializer([new ObjectNormalizer()],[new JsonEncoder()]); $serializer = new Serializer([new ObjectNormalizer()],[new JsonEncoder()]);
$i = $serializer->deserialize($index->toJson(), EventEntity::class, 'json'); $i = $serializer->deserialize($index->toJson(), EventEntity::class, 'json');
// don't save any more for now // don't save any more for now
$entityManager->persist($i); $entityManager->persist($i);
// $entityManager->flush(); $entityManager->flush();
// TODO publish index to relays // 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 // redirect to route nzine_view
return $this->redirectToRoute('nzine_view', [ return $this->redirectToRoute('nzine_view', [
@ -144,7 +172,7 @@ class NzineController extends AbstractController
return $this->render('pages/nzine-editor.html.twig', [ return $this->render('pages/nzine-editor.html.twig', [
'nzine' => $nzine, 'nzine' => $nzine,
'indices' => $indices, 'indices' => $indices,
'bot' => $bot, 'bot' => $bot ?? null, // if null, the profile for the bot doesn't exist yet
'catForm' => $catForm 'catForm' => $catForm
]); ]);
} }
@ -172,11 +200,16 @@ class NzineController extends AbstractController
} }
// Find all index events for this nzine // Find all index events for this nzine
$indices = $entityManager->getRepository(EventEntity::class)->findBy(['pubkey' => $npub, 'kind' => KindsEnum::PUBLICATION_INDEX]); $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', [ return $this->render('pages/nzine.html.twig', [
'nzine' => $nzine, 'nzine' => $nzine,
'index' => $main 'index' => $mainIndex,
'events' => $indices, // TODO traverse all and collect all leaves
]); ]);
} }

31
src/Entity/Event.php

@ -14,6 +14,8 @@ class Event
#[ORM\Id] #[ORM\Id]
#[ORM\Column(length: 225)] #[ORM\Column(length: 225)]
private string $id; private string $id;
#[ORM\Column(length: 225, nullable: true)]
private ?string $eventId = null;
#[ORM\Column(type: Types::INTEGER)] #[ORM\Column(type: Types::INTEGER)]
private int $kind = 0; private int $kind = 0;
#[ORM\Column(length: 255)] #[ORM\Column(length: 255)]
@ -37,6 +39,18 @@ class Event
$this->id = $id; $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 public function getKind(): int
{ {
return $this->kind; return $this->kind;
@ -108,6 +122,16 @@ class Event
return null; 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 public function getSlug(): ?string
{ {
foreach ($this->getTags() as $tag) { foreach ($this->getTags() as $tag) {
@ -118,4 +142,11 @@ class Event
return null; return null;
} }
public function addTag(array $tag): static
{
$this->tags[] = $tag;
return $this;
}
} }

4
src/Entity/NzineBot.php

@ -14,13 +14,15 @@ class NzineBot
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')] #[ORM\Column(type: 'integer')]
private ?int $id = null; private ?int $id = null;
private ?EncryptionService $encryptionService = null;
#[ORM\Column(type: 'string', length: 255)] #[ORM\Column(type: 'string', length: 255)]
private ?string $encryptedNsec = null; private ?string $encryptedNsec = null;
#[Ignore] #[Ignore]
private ?string $nsec = null; private ?string $nsec = null;
public function __construct(private readonly EncryptionService $encryptionService) public function setEncryptionService(EncryptionService $encryptionService): void
{ {
$this->encryptionService = $encryptionService;
} }
public function getId(): ?int public function getId(): ?int

78
src/Entity/User.php

@ -5,14 +5,13 @@ namespace App\Entity;
use App\Repository\UserEntityRepository; use App\Repository\UserEntityRepository;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;
/** /**
* Entity storing local user representations * Entity storing local user representations
*/ */
#[ORM\Entity(repositoryClass: UserEntityRepository::class)] #[ORM\Entity(repositoryClass: UserEntityRepository::class)]
#[ORM\Table(name: "app_user")] #[ORM\Table(name: "app_user")]
class User implements UserInterface class User
{ {
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
@ -22,21 +21,6 @@ class User implements UserInterface
#[ORM\Column(unique: true)] #[ORM\Column(unique: true)]
private ?string $npub = null; 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)] #[ORM\Column(type: Types::JSON, nullable: true)]
private array $roles = []; private array $roles = [];
@ -64,16 +48,6 @@ class User implements UserInterface
return $this; return $this;
} }
public function eraseCredentials(): void
{
// TODO: Implement eraseCredentials() method.
}
public function getUserIdentifier(): string
{
return $this->npub;
}
public function getId(): ?int public function getId(): ?int
{ {
return $this->id; return $this->id;
@ -93,54 +67,4 @@ class User implements UserInterface
{ {
$this->npub = $npub; $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;
}
} }

5
src/Enum/KindsEnum.php

@ -5,8 +5,9 @@ namespace App\Enum;
enum KindsEnum: int enum KindsEnum: int
{ {
case METADATA = 0; // metadata, NIP-01 case METADATA = 0; // metadata, NIP-01
case TEXT_NOTE = 1; // text note, NIP-01 case TEXT_NOTE = 1; // text note, NIP-01, will not implement
case REPOST = 6; // Only wraps kind 1, NIP-18 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 GENERIC_REPOST = 16; // Generic repost, original kind signalled in a "k" tag, NIP-18
case FILE_METADATA = 1063; // NIP-94 case FILE_METADATA = 1063; // NIP-94
case PINNED_LONGFORM = 10023; // Special purpose curation set, NIP-51, seems deprecated? case PINNED_LONGFORM = 10023; // Special purpose curation set, NIP-51, seems deprecated?

2
src/Form/MainCategoryType.php

@ -23,7 +23,7 @@ class MainCategoryType extends AbstractType
'label' => 'Title', 'label' => 'Title',
]) ])
->add('tags', TextType::class, [ ->add('tags', TextType::class, [
'label' => 'Tags (comma-separated)', 'label' => 'Tags',
]); ]);
$builder->get('tags')->addModelTransformer($this->transformer); $builder->get('tags')->addModelTransformer($this->transformer);

18
src/Security/NostrAuthenticator.php

@ -3,8 +3,6 @@
namespace App\Security; namespace App\Security;
use App\Entity\Event; use App\Entity\Event;
use App\Service\NostrClient;
use Doctrine\ORM\EntityManagerInterface;
use Mdanter\Ecc\Crypto\Signature\SchnorrSignature; use Mdanter\Ecc\Crypto\Signature\SchnorrSignature;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@ -21,9 +19,6 @@ use Symfony\Component\Serializer\Serializer;
class NostrAuthenticator extends AbstractAuthenticator implements InteractiveAuthenticatorInterface class NostrAuthenticator extends AbstractAuthenticator implements InteractiveAuthenticatorInterface
{ {
public function __construct(private readonly NostrClient $nostrClient)
{
}
public function supports(Request $request): ?bool public function supports(Request $request): ?bool
{ {
@ -54,19 +49,8 @@ class NostrAuthenticator extends AbstractAuthenticator implements InteractiveAut
throw new AuthenticationException('Invalid Authorization header'); 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( return new SelfValidatingPassport(
new UserBadge($user->getUserIdentifier()) new UserBadge($event->getPubkey())
); );
} }

63
src/Security/UserDTO.php

@ -0,0 +1,63 @@
<?php
namespace App\Security;
use App\Entity\User;
use Symfony\Component\Security\Core\User\UserInterface;
class UserDTO implements UserInterface
{
private User $user;
private $metadata;
private $relays;
public function __construct(User $user, $metadata, $relays)
{
$this->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();
}
}

73
src/Security/UserDTOProvider.php

@ -0,0 +1,73 @@
<?php
namespace App\Security;
use App\Entity\User;
use App\Service\NostrClient;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
readonly class UserDTOProvider implements UserProviderInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private NostrClient $nostrClient
) {}
/**
* @inheritDoc
*/
public function refreshUser(UserInterface $user): UserInterface
{
if (!$user instanceof UserDTO) {
throw new \InvalidArgumentException('Invalid user type.');
}
return $this->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);
}
}

187
src/Service/NostrClient.php

@ -7,25 +7,162 @@ use App\Entity\User;
use App\Enum\KindsEnum; use App\Enum\KindsEnum;
use App\Factory\ArticleFactory; use App\Factory\ArticleFactory;
use App\Repository\UserEntityRepository; use App\Repository\UserEntityRepository;
use App\Security\UserDTO;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use swentel\nostr\Event\Event;
use swentel\nostr\Filter\Filter; use swentel\nostr\Filter\Filter;
use swentel\nostr\Message\EventMessage;
use swentel\nostr\Message\RequestMessage; use swentel\nostr\Message\RequestMessage;
use swentel\nostr\Relay\Relay; use swentel\nostr\Relay\Relay;
use swentel\nostr\Relay\RelaySet; use swentel\nostr\Relay\RelaySet;
use swentel\nostr\Request\Request; use swentel\nostr\Request\Request;
use swentel\nostr\Subscription\Subscription; use swentel\nostr\Subscription\Subscription;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Serializer\SerializerInterface; use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
class NostrClient class NostrClient
{ {
private $defaultRelaySet;
public function __construct(private readonly EntityManagerInterface $entityManager, public function __construct(private readonly EntityManagerInterface $entityManager,
private readonly ManagerRegistry $managerRegistry,
private readonly UserEntityRepository $userEntityRepository, private readonly UserEntityRepository $userEntityRepository,
private readonly ArticleFactory $articleFactory, private readonly ArticleFactory $articleFactory,
private readonly SerializerInterface $serializer, private readonly SerializerInterface $serializer,
private readonly TokenStorageInterface $tokenStorage,
private readonly CacheInterface $cacheApp,
private readonly LoggerInterface $logger) 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 = new Filter();
$filter->setKinds([KindsEnum::LONGFORM]); $filter->setKinds([KindsEnum::LONGFORM]);
// TODO make filters configurable // TODO make filters configurable
$filter->setSince(strtotime('-1 week')); // $filter->setSince(strtotime('-8 weeks')); //
$requestMessage = new RequestMessage($subscriptionId, [$filter]); $requestMessage = new RequestMessage($subscriptionId, [$filter]);
// TODO make relays configurable
// if user is logged in, use their settings
$user = $this->tokenStorage->getToken()?->getUser();
$relays = $this->defaultRelaySet;
if ($user) {
$relays = new RelaySet(); $relays = new RelaySet();
$relays->addRelay(new Relay('wss://nos.lol')); // default relay
}
$request = new Request($relays, $requestMessage); $request = new Request($relays, $requestMessage);
@ -61,6 +203,8 @@ class NostrClient
// TODO handle relays that require auth // TODO handle relays that require auth
} }
/** /**
* User metadata * User metadata
* NIP-01 * NIP-01
@ -77,7 +221,7 @@ class NostrClient
// TODO make relays configurable // TODO make relays configurable
$relays = new RelaySet(); $relays = new RelaySet();
$relays->addRelay(new Relay('wss://purplepag.es')); // default metadata aggregator $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); $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 * Save user metadata
*/ */

13
src/Service/NzineWorkflowService.php

@ -51,9 +51,12 @@ class NzineWorkflowService
// create NZine bot // create NZine bot
$key = new Key(); $key = new Key();
$private_key = $key->generatePrivateKey(); $private_key = $key->generatePrivateKey();
$bot = new NzineBot($this->encryptionService); $bot = new NzineBot();
$bot->setEncryptionService($this->encryptionService);
$bot->setNsec($private_key); $bot->setNsec($private_key);
$this->entityManager->persist($bot); $this->entityManager->persist($bot);
$this->entityManager->flush();
// publish bot profile // publish bot profile
$profileContent = [ $profileContent = [
'name' => $name, 'name' => $name,
@ -65,7 +68,7 @@ class NzineWorkflowService
$profileEvent->setContent(json_encode($profileContent)); $profileEvent->setContent(json_encode($profileContent));
$signer = new Sign(); $signer = new Sign();
$signer->signEvent($profileEvent, $private_key); $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 // add EDITOR role to the user
$role = RolesEnum::EDITOR->value; $role = RolesEnum::EDITOR->value;
@ -91,7 +94,7 @@ class NzineWorkflowService
public function createMainIndex(Nzine $nzine, string $title, string $summary): void public function createMainIndex(Nzine $nzine, string $title, string $summary): void
{ {
if (!$this->nzineWorkflow->can($nzine, 'create_main_index')) { 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(); $bot = $nzine->getNzineBot();
@ -99,6 +102,9 @@ class NzineWorkflowService
$slugger = new AsciiSlugger(); $slugger = new AsciiSlugger();
$slug = 'nzine-'.$slugger->slug($title)->lower().'-'.rand(10000,99999); $slug = 'nzine-'.$slugger->slug($title)->lower().'-'.rand(10000,99999);
// save slug to nzine
$nzine->setSlug($slug);
// create NZine main index // create NZine main index
$index = new Event(); $index = new Event();
$index->setKind(KindsEnum::PUBLICATION_INDEX->value); $index->setKind(KindsEnum::PUBLICATION_INDEX->value);
@ -116,7 +122,6 @@ class NzineWorkflowService
$this->entityManager->persist($i); $this->entityManager->persist($i);
$this->nzineWorkflow->apply($nzine, 'create_main_index'); $this->nzineWorkflow->apply($nzine, 'create_main_index');
$this->entityManager->persist($nzine);
$this->entityManager->flush(); $this->entityManager->flush();
} }

22
src/Twig/Components/IndexTabs.php

@ -20,11 +20,8 @@ class IndexTabs
public int $activeTab = 1; // Default active tab public int $activeTab = 1; // Default active tab
#[LiveProp] #[LiveProp]
public array $tabs = [ /** ['id' => 1, 'label' => 'Tab 1'] */
['id' => 1, 'label' => 'Tab 1'], public array $tabs = [];
['id' => 2, 'label' => 'Tab 2'],
['id' => 3, 'label' => 'Tab 3'],
];
#[LiveAction] #[LiveAction]
public function changeTab(#[LiveArg] int $id): void public function changeTab(#[LiveArg] int $id): void
@ -44,18 +41,23 @@ class IndexTabs
if (array_key_first($tag) === 'a') { if (array_key_first($tag) === 'a') {
$ref = $tag['a']; $ref = $tag['a'];
list($kind,$npub,$slug) = explode(':',$ref); list($kind,$npub,$slug) = explode(':',$ref);
// find all connected indices $cat = $this->entityManager->getRepository(EventEntity::class)->findOneBy(['slug' => $slug]);
$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) { 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!', 2 => 'This is content for Tab 2. No AJAX needed!',
3 => 'This is content for Tab 3. Server-side rendering!',
default => 'No content available.', default => 'No content available.',
}; };
} }

11
src/Twig/Components/Molecules/ArticleFromATag.php

@ -0,0 +1,11 @@
<?php
namespace App\Twig\Components\Molecules;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
final class ArticleFromATag
{
}

4
src/Twig/Components/Molecules/Card.php

@ -3,6 +3,7 @@
namespace App\Twig\Components\Molecules; namespace App\Twig\Components\Molecules;
use App\Entity\User; use App\Entity\User;
use App\Service\NostrClient;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
@ -14,13 +15,14 @@ final class Card
public object $article; public object $article;
public object $user; public object $user;
public function __construct(private readonly EntityManagerInterface $entityManager) public function __construct(private readonly EntityManagerInterface $entityManager, private readonly NostrClient $nostrClient)
{ {
} }
public function mount(?string $npub = null): void public function mount(?string $npub = null): void
{ {
if ($npub) { if ($npub) {
$this->nostrClient->getMetadata();
$this->user = $this->entityManager->getRepository(User::class)->findOneBy(['npub' => $npub]); $this->user = $this->entityManager->getRepository(User::class)->findOneBy(['npub' => $npub]);
} }
} }

15
src/Twig/Components/Molecules/UserFromNpub.php

@ -2,23 +2,28 @@
namespace App\Twig\Components\Molecules; namespace App\Twig\Components\Molecules;
use App\Entity\User; use App\Service\NostrClient;
use Doctrine\ORM\EntityManagerInterface; use Psr\Cache\InvalidArgumentException;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent] #[AsTwigComponent]
final class UserFromNpub final class UserFromNpub
{ {
public string $npub; 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 public function mount(string $npub): void
{ {
$this->npub = $npub; $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;
}
} }
} }

15
src/Twig/Components/Organisms/ZineList.php

@ -2,7 +2,9 @@
namespace App\Twig\Components\Organisms; namespace App\Twig\Components\Organisms;
use App\Entity\Event;
use App\Entity\Nzine; use App\Entity\Nzine;
use App\Enum\KindsEnum;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
@ -10,6 +12,7 @@ use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
final class ZineList final class ZineList
{ {
public array $nzines = []; public array $nzines = [];
public array $indices = [];
public function __construct(private readonly EntityManagerInterface $entityManager) public function __construct(private readonly EntityManagerInterface $entityManager)
{ {
@ -18,5 +21,17 @@ final class ZineList
public function mount(?array $nzines = null): void public function mount(?array $nzines = null): void
{ {
$this->nzines = $nzines ?? $this->entityManager->getRepository(Nzine::class)->findAll(); $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);
}
}
}
} }
} }

9
templates/admin/roles.html.twig

@ -10,6 +10,15 @@
</div> </div>
{% endfor %} {% endfor %}
{% if app.user %}
<div>
{{ app.user.userIdentifier }}<br>
{% for role in app.user.roles %}
{{ role }}<br>
{% endfor %}
</div>
{% endif %}
{# Form for adding a new role #} {# Form for adding a new role #}
{{ form_start(form) }} {{ form_start(form) }}
{{ form_widget(form) }} {{ form_widget(form) }}

6
templates/components/Atoms/NameOrNpub.html.twig

@ -1,9 +1,7 @@
<span> <span>
{% if author.displayName is not empty %} {% if author.display_name is defined and author.display_name is not empty %}
{{ author.displayName }} {{ author.display_name }}
{% elseif author.name is not empty %} {% elseif author.name is not empty %}
{{ author.name }} {{ author.name }}
{% else %}
{{ author.npub }}
{% endif %} {% endif %}
</span> </span>

2
templates/components/IndexTabs.html.twig

@ -17,7 +17,7 @@
<!-- Tab Content --> <!-- Tab Content -->
<div class="tab-content mt-3"> <div class="tab-content mt-3">
<div class="tab-pane fade show active"> <div class="tab-pane fade show active">
{{ this.tabContent()|raw }} <twig:Molecules:ArticleFromATag :list="this.tabContent" />
</div> </div>
</div> </div>
</div> </div>

7
templates/components/Molecules/ArticleFromATag.html.twig

@ -0,0 +1,7 @@
{% if list is iterable %}
{% for item in list %}
<p>{{ item }}</p>
{% endfor %}
{% else %}
<p>{{ list }}</p>
{% endif %}

2
templates/components/Organisms/CardList.html.twig

@ -1,5 +1,7 @@
<div {{ attributes }}> <div {{ attributes }}>
{% for item in list %} {% for item in list %}
{% if item.slug is not empty %}
<twig:Molecules:Card class="card" :article="item" tag="a" href="{{ path('article-slug', {slug: item.slug}) }}" ></twig:Molecules:Card> <twig:Molecules:Card class="card" :article="item" tag="a" href="{{ path('article-slug', {slug: item.slug}) }}" ></twig:Molecules:Card>
{% endif %}
{% endfor %} {% endfor %}
</div> </div>

13
templates/components/Organisms/ZineList.html.twig

@ -1,5 +1,16 @@
<div {{ attributes }}> <div {{ attributes }}>
{% for item in nzines %} {% for item in nzines %}
<twig:Molecules:Card class="card user" :npub="item.npub" tag="a" href="{{ path('author-profile', {npub: item.npub}) }}" ></twig:Molecules:Card> {% if item.npub in indices|keys %}
{% set idx = indices[item.npub] %}
{% if idx|length > 0 %}
<a class="card" href="{{ path('nzine_view', { npub: item.npub })}}">
<div class="card-body">
<h3 class="card-title">{{ idx.title }}</h3>
<p class="hidden">{{ idx.summary }}</p>
</div>
</a>
<br>
{% endif %}
{% endif %}
{% endfor %} {% endfor %}
</div> </div>

2
templates/home.html.twig

@ -9,6 +9,6 @@
{% endblock %} {% endblock %}
{% block aside %} {% block aside %}
{# sidebar #} <h6>Magazines</h6>
<twig:Organisms:ZineList /> <twig:Organisms:ZineList />
{% endblock %} {% endblock %}

2
templates/pages/article.html.twig

@ -8,7 +8,7 @@
{% if author %} {% if author %}
<div class="byline"> <div class="byline">
<span> <span>
{{ 'text.byline'|trans }} <a href="{{ path('author-profile', {'npub': author.npub}) }}"> {{ 'text.byline'|trans }} <a href="{{ path('author-profile', {'npub': article.pubkey}) }}">
<twig:atoms:NameOrNpub :author="author" /> <twig:atoms:NameOrNpub :author="author" />
</a> </a>
</span> </span>

12
templates/pages/author.html.twig

@ -1,13 +1,25 @@
{% extends 'base.html.twig' %} {% extends 'base.html.twig' %}
{% block body %} {% block body %}
{% if author.image is defined %}
<img src="{{ author.image }}" alt="{{ author.name }}" />
{% endif %}
<h1><twig:atoms:NameOrNpub :author="author"></twig:atoms:NameOrNpub></h1> <h1><twig:atoms:NameOrNpub :author="author"></twig:atoms:NameOrNpub></h1>
{% if author.about is defined %}
<p class="lede"> <p class="lede">
{{ author.about }} {{ author.about }}
</p> </p>
{% endif %}
{% if nzine %} {% if nzine %}
<a href="{{ path('nzine_view', {npub: author.npub}) }}">View as N-Zine</a> <a href="{{ path('nzine_view', {npub: author.npub}) }}">View as N-Zine</a>
<h2>List of indices</h2>
{% for i in idx %}
{{ i.title }}
{% endfor %}
{% endif %} {% endif %}
<twig:Organisms:CardList :list="articles"></twig:Organisms:CardList> <twig:Organisms:CardList :list="articles"></twig:Organisms:CardList>

10
templates/pages/nzine-editor.html.twig

@ -28,8 +28,14 @@
{% else %} {% else %}
<h1>{{ 'heading.editNzine'|trans }}</h1> <h1>{{ 'heading.editNzine'|trans }}</h1>
<h2>{{ bot.name }}</h2>
<p class="lede">{{ bot.about }}</p> <h2>Indices</h2>
<ul>
{% for idx in indices %}
<li>{{ idx.title }}</li>
{% endfor %}
</ul>
<h2>Categories</h2> <h2>Categories</h2>
<p> <p>

19
templates/pages/nzine.html.twig

@ -2,23 +2,16 @@
{% block body %} {% block body %}
<div> <div>
{# TODO replace with main index data #}
<h1>{{ index.title }}</h1> <h1>{{ index.title }}</h1>
<p>{{ index.slug }}</p> <p>{{ index.summary }}</p>
<div>
<nav>
{# TODO replace this with a loop over the main index #}
{% for cat in nzine.mainCategories %}
<a href="{{ path('nzine_category', {npub: nzine.npub, cat: cat.title}) }}">{{ cat.title }}</a>
{% endfor %}
</nav>
</div>
<br> <br>
<twig:IndexTabs :index="index" /> <twig:IndexTabs :index="index" />
{% if list is defined %}
<twig:Organisms:CardList :list="list" />
{% endif %}
</div> </div>
{% endblock %} {% endblock %}
{% block aside %}
<p>TODO search</p>
{% endblock %}

Loading…
Cancel
Save