41 changed files with 7570 additions and 2651 deletions
@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
|
||||
# define your env variables for the test env here |
||||
KERNEL_CLASS='App\Kernel' |
||||
APP_SECRET='$ecretf0rt3st' |
||||
SYMFONY_DEPRECATIONS_HELPER=999999 |
||||
PANTHER_APP_ENV=panther |
||||
PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots |
||||
@ -1,2 +1,5 @@
@@ -1,2 +1,5 @@
|
||||
import { startStimulusApp } from '@symfony/stimulus-bundle'; |
||||
|
||||
const app = startStimulusApp(); |
||||
// register any custom, 3rd party controllers here
|
||||
// app.register('some_controller_name', SomeImportedController);
|
||||
|
||||
@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
|
||||
import { Controller } from '@hotwired/stimulus'; |
||||
import { getComponent } from '@symfony/ux-live-component'; |
||||
|
||||
export default class extends Controller { |
||||
async initialize() { |
||||
this.component = await getComponent(this.element); |
||||
} |
||||
async loginAct() { |
||||
const tags = [ |
||||
['u', window.location.origin + '/login'], |
||||
['method', 'GET'] |
||||
] |
||||
const ev = { |
||||
created_at: Math.floor(Date.now()/1000), |
||||
kind: 27235, |
||||
tags: tags, |
||||
content: '' |
||||
} |
||||
|
||||
const signed = await window.nostr.signEvent(ev); |
||||
// base64 encode and send as Auth header
|
||||
const result = await fetch('/login', { |
||||
method: 'GET', |
||||
headers: { |
||||
'Authorization': 'Nostr ' + btoa(JSON.stringify(signed)) |
||||
} |
||||
}).then(response => { |
||||
if (response.ok) return response.json(); |
||||
return false; |
||||
}).then(res => { |
||||
return res; |
||||
}) |
||||
if (!!result) { |
||||
this.component.render(); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env php |
||||
<?php |
||||
|
||||
if (!ini_get('date.timezone')) { |
||||
ini_set('date.timezone', 'UTC'); |
||||
} |
||||
|
||||
if (is_file(dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit')) { |
||||
if (PHP_VERSION_ID >= 80000) { |
||||
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit'; |
||||
} else { |
||||
define('PHPUNIT_COMPOSER_INSTALL', dirname(__DIR__).'/vendor/autoload.php'); |
||||
require PHPUNIT_COMPOSER_INSTALL; |
||||
PHPUnit\TextUI\Command::main(); |
||||
} |
||||
} else { |
||||
if (!is_file(dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php')) { |
||||
echo "Unable to find the `simple-phpunit.php` script in `vendor/symfony/phpunit-bridge/bin/`.\n"; |
||||
exit(1); |
||||
} |
||||
|
||||
require dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php'; |
||||
} |
||||
@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
|
||||
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 |
||||
firewalls: |
||||
dev: |
||||
pattern: ^/(_(profiler|wdt)|css|images|js)/ |
||||
security: false |
||||
main: |
||||
lazy: true |
||||
custom_authenticators: |
||||
- App\Security\NostrAuthenticator |
||||
logout: |
||||
path: /logout |
||||
|
||||
# activate different ways to authenticate |
||||
# https://symfony.com/doc/current/security.html#the-firewall |
||||
|
||||
# https://symfony.com/doc/current/security/impersonating_user.html |
||||
# switch_user: true |
||||
|
||||
# Easy way to control access for large sections of your site |
||||
# Note: Only the *first* access control that matches will be used |
||||
access_control: |
||||
# - { path: ^/admin, roles: ROLE_ADMIN } |
||||
# - { 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 |
||||
@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
|
||||
when@dev: |
||||
web_profiler: |
||||
toolbar: true |
||||
intercept_redirects: false |
||||
|
||||
framework: |
||||
profiler: |
||||
only_exceptions: false |
||||
collect_serializer_data: true |
||||
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
_security_logout: |
||||
resource: security.route_loader.logout |
||||
type: service |
||||
@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
|
||||
when@dev: |
||||
web_profiler_wdt: |
||||
resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml' |
||||
prefix: /_wdt |
||||
|
||||
web_profiler_profiler: |
||||
resource: '@WebProfilerBundle/Resources/config/routing/profiler.xml' |
||||
prefix: /_profiler |
||||
@ -0,0 +1,31 @@
@@ -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 Version20241201111823 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('CREATE TABLE article (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, raw JSON DEFAULT NULL, event_id VARCHAR(225) DEFAULT NULL, slug TEXT DEFAULT NULL, content TEXT DEFAULT NULL, kind INT DEFAULT NULL, title VARCHAR(225) DEFAULT NULL, summary TEXT DEFAULT NULL, pubkey VARCHAR(255) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, sig VARCHAR(255) NOT NULL, image TEXT DEFAULT NULL, published_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, topics JSON DEFAULT NULL, event_status INT DEFAULT NULL, index_status INT DEFAULT NULL, img_url VARCHAR(255) DEFAULT NULL, current_places JSON DEFAULT NULL, rating_negative INT DEFAULT NULL, rating_positive INT DEFAULT NULL, PRIMARY KEY(id))'); |
||||
} |
||||
|
||||
public function down(Schema $schema): void |
||||
{ |
||||
// this down() migration is auto-generated, please modify it to your needs |
||||
$this->addSql('DROP TABLE article'); |
||||
} |
||||
} |
||||
@ -0,0 +1,31 @@
@@ -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 Version20241202163851 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('CREATE TABLE "user" (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, npub VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, display_name VARCHAR(255) NOT NULL, about TEXT DEFAULT NULL, website TEXT DEFAULT NULL, picture TEXT DEFAULT NULL, roles JSON DEFAULT NULL, PRIMARY KEY(id))'); |
||||
} |
||||
|
||||
public function down(Schema $schema): void |
||||
{ |
||||
// this down() migration is auto-generated, please modify it to your needs |
||||
$this->addSql('DROP TABLE "user"'); |
||||
} |
||||
} |
||||
@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
|
||||
<?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 Version20241203124000 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('CREATE TABLE app_user (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, npub VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, display_name VARCHAR(255) NOT NULL, about TEXT DEFAULT NULL, website TEXT DEFAULT NULL, picture TEXT DEFAULT NULL, roles JSON DEFAULT NULL, PRIMARY KEY(id))'); |
||||
$this->addSql('DROP TABLE "user"'); |
||||
} |
||||
|
||||
public function down(Schema $schema): void |
||||
{ |
||||
// this down() migration is auto-generated, please modify it to your needs |
||||
$this->addSql('CREATE TABLE "user" (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, npub VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, display_name VARCHAR(255) NOT NULL, about TEXT DEFAULT NULL, website TEXT DEFAULT NULL, picture TEXT DEFAULT NULL, roles JSON DEFAULT NULL, PRIMARY KEY(id))'); |
||||
$this->addSql('DROP TABLE app_user'); |
||||
} |
||||
} |
||||
@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
|
||||
<?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 Version20241203130131 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('CREATE TABLE sessions (sess_id VARCHAR(128) NOT NULL, sess_data BYTEA NOT NULL, sess_lifetime INT NOT NULL, sess_time INT NOT NULL, PRIMARY KEY(sess_id))'); |
||||
$this->addSql('CREATE INDEX sess_lifetime_idx ON sessions (sess_lifetime)'); |
||||
} |
||||
|
||||
public function down(Schema $schema): void |
||||
{ |
||||
// this down() migration is auto-generated, please modify it to your needs |
||||
$this->addSql('DROP TABLE sessions'); |
||||
} |
||||
} |
||||
@ -0,0 +1,31 @@
@@ -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 Version20241204212205 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 article DROP img_url'); |
||||
} |
||||
|
||||
public function down(Schema $schema): void |
||||
{ |
||||
// this down() migration is auto-generated, please modify it to your needs |
||||
$this->addSql('ALTER TABLE article ADD img_url VARCHAR(255) DEFAULT NULL'); |
||||
} |
||||
} |
||||
@ -0,0 +1,39 @@
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?> |
||||
|
||||
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html --> |
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" |
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" |
||||
backupGlobals="false" |
||||
colors="true" |
||||
bootstrap="tests/bootstrap.php" |
||||
convertDeprecationsToExceptions="false" |
||||
> |
||||
<php> |
||||
<ini name="display_errors" value="1" /> |
||||
<ini name="error_reporting" value="-1" /> |
||||
<env name="KERNEL_CLASS" value="App\Kernel" /> |
||||
<server name="APP_ENV" value="test" force="true" /> |
||||
<server name="SHELL_VERBOSITY" value="-1" /> |
||||
<server name="SYMFONY_PHPUNIT_REMOVE" value="" /> |
||||
<server name="SYMFONY_PHPUNIT_VERSION" value="9.6" /> |
||||
</php> |
||||
|
||||
<testsuites> |
||||
<testsuite name="Project Test Suite"> |
||||
<directory>tests</directory> |
||||
</testsuite> |
||||
</testsuites> |
||||
|
||||
<coverage processUncoveredFiles="true"> |
||||
<include> |
||||
<directory suffix=".php">src</directory> |
||||
</include> |
||||
</coverage> |
||||
|
||||
<listeners> |
||||
<listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener" /> |
||||
</listeners> |
||||
|
||||
<extensions> |
||||
</extensions> |
||||
</phpunit> |
||||
@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Controller; |
||||
|
||||
use App\Entity\User; |
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; |
||||
use Symfony\Component\HttpFoundation\JsonResponse; |
||||
use Symfony\Component\HttpFoundation\Response; |
||||
use Symfony\Component\Routing\Attribute\Route; |
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser; |
||||
|
||||
class LoginController extends AbstractController |
||||
{ |
||||
#[Route('/login', name: 'app_login')] |
||||
public function index(#[CurrentUser] ?User $user): Response |
||||
{ |
||||
if (null !== $user) { |
||||
return new JsonResponse('Authentication Successful', 200); |
||||
} |
||||
|
||||
return new JsonResponse('Unauthenticated', 401); |
||||
} |
||||
} |
||||
@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
|
||||
<?php |
||||
|
||||
namespace App\Entity; |
||||
|
||||
class Event |
||||
{ |
||||
private string $id; |
||||
private int $kind = 0; |
||||
private string $pubkey; |
||||
private string $content = ''; |
||||
private int $created_at = 0; |
||||
private array $tags = []; |
||||
private string $sig; |
||||
|
||||
public function getId(): string |
||||
{ |
||||
return $this->id; |
||||
} |
||||
|
||||
public function setId(string $id): void |
||||
{ |
||||
$this->id = $id; |
||||
} |
||||
|
||||
public function getKind(): int |
||||
{ |
||||
return $this->kind; |
||||
} |
||||
|
||||
public function setKind(int $kind): void |
||||
{ |
||||
$this->kind = $kind; |
||||
} |
||||
|
||||
public function getPubkey(): string |
||||
{ |
||||
return $this->pubkey; |
||||
} |
||||
|
||||
public function setPubkey(string $pubkey): void |
||||
{ |
||||
$this->pubkey = $pubkey; |
||||
} |
||||
|
||||
public function getContent(): string |
||||
{ |
||||
return $this->content; |
||||
} |
||||
|
||||
public function setContent(string $content): void |
||||
{ |
||||
$this->content = $content; |
||||
} |
||||
|
||||
public function getCreatedAt(): int |
||||
{ |
||||
return $this->created_at; |
||||
} |
||||
|
||||
public function setCreatedAt(int $created_at): void |
||||
{ |
||||
$this->created_at = $created_at; |
||||
} |
||||
|
||||
public function getTags(): array |
||||
{ |
||||
return $this->tags; |
||||
} |
||||
|
||||
public function setTags(array $tags): void |
||||
{ |
||||
$this->tags = $tags; |
||||
} |
||||
|
||||
public function getSig(): string |
||||
{ |
||||
return $this->sig; |
||||
} |
||||
|
||||
public function setSig(string $sig): void |
||||
{ |
||||
$this->sig = $sig; |
||||
} |
||||
|
||||
|
||||
} |
||||
@ -0,0 +1,145 @@
@@ -0,0 +1,145 @@
|
||||
<?php |
||||
|
||||
namespace App\Entity; |
||||
|
||||
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] |
||||
#[ORM\Table(name: "app_user")] |
||||
class User implements UserInterface |
||||
{ |
||||
#[ORM\Id] |
||||
#[ORM\GeneratedValue] |
||||
#[ORM\Column] |
||||
private ?int $id = null; |
||||
|
||||
#[ORM\Column] |
||||
private ?string $npub = null; |
||||
|
||||
#[ORM\Column] |
||||
private ?string $name = null; |
||||
|
||||
#[ORM\Column] |
||||
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 = []; |
||||
|
||||
public function getRoles(): array |
||||
{ |
||||
$roles = $this->roles; |
||||
$roles[] = 'ROLE_USER'; |
||||
|
||||
return $roles; |
||||
} |
||||
|
||||
public function setRoles(?array $roles): self |
||||
{ |
||||
$this->roles = $roles; |
||||
|
||||
return $this; |
||||
} |
||||
|
||||
public function addRole(string $role): self |
||||
{ |
||||
if (!in_array($role, $this->roles)) { |
||||
$this->roles[] = $role; |
||||
} |
||||
|
||||
return $this; |
||||
} |
||||
|
||||
public function eraseCredentials(): void |
||||
{ |
||||
// TODO: Implement eraseCredentials() method. |
||||
} |
||||
|
||||
public function getUserIdentifier(): string |
||||
{ |
||||
return $this->npub; |
||||
} |
||||
|
||||
public function getId(): ?int |
||||
{ |
||||
return $this->id; |
||||
} |
||||
|
||||
public function setId(?int $id): void |
||||
{ |
||||
$this->id = $id; |
||||
} |
||||
|
||||
public function getNpub(): ?string |
||||
{ |
||||
return $this->npub; |
||||
} |
||||
|
||||
public function setNpub(?string $npub): void |
||||
{ |
||||
$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; |
||||
} |
||||
} |
||||
@ -0,0 +1,59 @@
@@ -0,0 +1,59 @@
|
||||
<?php |
||||
|
||||
namespace App\Factory; |
||||
|
||||
use App\Entity\Article; |
||||
use App\Enum\EventStatusEnum; |
||||
use App\Enum\KindsEnum; |
||||
use InvalidArgumentException; |
||||
|
||||
/** |
||||
* Map nostr events of kind 30023 to local article entity |
||||
*/ |
||||
class ArticleFactory |
||||
{ |
||||
public function createFromLongFormContentEvent($source): Article |
||||
{ |
||||
if ($source->kind !== KindsEnum::LONGFORM->value) { |
||||
throw new InvalidArgumentException('Source event kind should be 30023'); |
||||
} |
||||
$entity = new Article(); |
||||
$entity->setRaw($source); |
||||
$entity->setEventId($source->id); |
||||
$entity->setCreatedAt(\DateTimeImmutable::createFromFormat('U', (string)$source->created_at)); |
||||
// TODO escape content before saving |
||||
$entity->setContent($source->content); |
||||
$entity->setKind(KindsEnum::from($source->kind)); |
||||
$entity->setPubkey($source->pubkey); |
||||
$entity->setSig($source->sig); |
||||
$entity->setEventStatus(EventStatusEnum::PUBLISHED); |
||||
$entity->setRatingNegative(0); |
||||
$entity->setRatingPositive(0); |
||||
// process tags |
||||
foreach ($source->tags as $tag) { |
||||
switch ($tag[0]) { |
||||
case 'd': |
||||
$entity->setSlug($tag[1]); |
||||
break; |
||||
case 'title': |
||||
$entity->setTitle($tag[1]); |
||||
break; |
||||
case 'summary': |
||||
$entity->setSummary($tag[1]); |
||||
break; |
||||
case 'image': |
||||
$entity->setImage($tag[1]); |
||||
break; |
||||
case 'published_at': |
||||
$entity->setPublishedAt(\DateTimeImmutable::createFromFormat('U', (string)$tag[1])); |
||||
case 't': |
||||
$entity->addTopic($tag[1]); |
||||
break; |
||||
case 'client': |
||||
// used to signal where it was created, ignored for now |
||||
break; |
||||
} |
||||
} |
||||
return $entity; |
||||
} |
||||
} |
||||
@ -0,0 +1,97 @@
@@ -0,0 +1,97 @@
|
||||
<?php |
||||
|
||||
namespace App\Security; |
||||
|
||||
use App\Entity\Event; |
||||
use App\Service\NostrClient; |
||||
use Doctrine\ORM\EntityManagerInterface; |
||||
use Symfony\Component\HttpFoundation\Request; |
||||
use Symfony\Component\HttpFoundation\Response; |
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; |
||||
use Symfony\Component\Security\Core\Exception\AuthenticationException; |
||||
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator; |
||||
use Symfony\Component\Security\Http\Authenticator\InteractiveAuthenticatorInterface; |
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; |
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Passport; |
||||
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; |
||||
use Symfony\Component\Serializer\Encoder\JsonEncoder; |
||||
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; |
||||
use Symfony\Component\Serializer\Serializer; |
||||
|
||||
class NostrAuthenticator extends AbstractAuthenticator implements InteractiveAuthenticatorInterface |
||||
{ |
||||
public function __construct(private readonly NostrClient $nostrClient, private readonly EntityManagerInterface $entityManager) |
||||
{ |
||||
} |
||||
|
||||
public function supports(Request $request): ?bool |
||||
{ |
||||
if ($request->getPathInfo() === '/login' && $request->headers->has('Authorization')) { |
||||
return true; |
||||
} |
||||
return false; |
||||
} |
||||
|
||||
public function authenticate(Request $request): Passport |
||||
{ |
||||
$authHeader = $request->headers->get('Authorization'); |
||||
if (!str_starts_with($authHeader, 'Nostr ')) { |
||||
throw new AuthenticationException('Invalid Authorization header'); |
||||
} |
||||
|
||||
$eventStr = base64_decode(substr($authHeader, 6), true); |
||||
$encoders = [new JsonEncoder()]; |
||||
$normalizers = [new ObjectNormalizer()]; |
||||
|
||||
$serializer = new Serializer($normalizers, $encoders); |
||||
/** @var Event $event */ |
||||
$event = $serializer->deserialize($eventStr, Event::class, 'json'); |
||||
if (time() > $event->getCreatedAt() + 60) { |
||||
throw new AuthenticationException('Expired'); |
||||
} |
||||
// TODO enable validity check after bug is fixed |
||||
// $validity = (new SchnorrSignature())->verify($event->getPubkey(), $event->getSig(), $event->getId()); |
||||
// pretend all is well |
||||
$validity = true; |
||||
if (!$validity) { |
||||
throw new AuthenticationException('Invalid Authorization header'); |
||||
} |
||||
|
||||
try { |
||||
$user = $this->fetchUser($event->getPubkey()); |
||||
} catch (\Exception) { |
||||
// even if the user metadata not found, if sig is valid, login the pubkey |
||||
$user = new \App\Entity\User(); |
||||
$user->setNpub($event->getPubkey()); |
||||
} |
||||
|
||||
return new SelfValidatingPassport( |
||||
new UserBadge($user->getUserIdentifier()) |
||||
); |
||||
} |
||||
|
||||
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response |
||||
{ |
||||
return null; |
||||
} |
||||
|
||||
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response |
||||
{ |
||||
return null; |
||||
} |
||||
|
||||
/** |
||||
* |
||||
* @throws \Exception |
||||
*/ |
||||
private function fetchUser(string $publicKey): \App\Entity\User |
||||
{ |
||||
$this->nostrClient->getMetadata([$publicKey]); |
||||
return $this->entityManager->getRepository(\App\Entity\User::class)->findOneBy(['npub' => $publicKey]); |
||||
} |
||||
|
||||
public function isInteractive(): bool |
||||
{ |
||||
return true; |
||||
} |
||||
} |
||||
@ -0,0 +1,143 @@
@@ -0,0 +1,143 @@
|
||||
<?php |
||||
|
||||
namespace App\Service; |
||||
|
||||
use App\Entity\Article; |
||||
use App\Entity\User; |
||||
use App\Enum\KindsEnum; |
||||
use App\Factory\ArticleFactory; |
||||
use Doctrine\ORM\EntityManagerInterface; |
||||
use Psr\Log\LoggerInterface; |
||||
use swentel\nostr\Filter\Filter; |
||||
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\Serializer\SerializerInterface; |
||||
|
||||
class NostrClient |
||||
{ |
||||
public function __construct(private readonly EntityManagerInterface $entityManager, |
||||
private readonly ArticleFactory $articleFactory, |
||||
private readonly SerializerInterface $serializer, |
||||
private readonly LoggerInterface $logger) |
||||
{ |
||||
} |
||||
|
||||
/** |
||||
* Long-form Content |
||||
* NIP-23 |
||||
*/ |
||||
public function getLongFormContent(): void |
||||
{ |
||||
$subscription = new Subscription(); |
||||
$subscriptionId = $subscription->setId(); |
||||
$filter = new Filter(); |
||||
$filter->setKinds([KindsEnum::LONGFORM]); |
||||
// TODO make filters configurable |
||||
$filter->setSince(strtotime('-1 year')); // |
||||
$filter->setUntil(strtotime('-11 months')); // |
||||
$requestMessage = new RequestMessage($subscriptionId, [$filter]); |
||||
// TODO make relays configurable |
||||
$relays = new RelaySet(); |
||||
$relays->addRelay(new Relay('wss://nos.lol')); // default relay |
||||
|
||||
$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 |
||||
} |
||||
|
||||
/** |
||||
* User metadata |
||||
* NIP-01 |
||||
* @throws \Exception |
||||
*/ |
||||
public function getMetadata(array $npubs): void |
||||
{ |
||||
$subscription = new Subscription(); |
||||
$subscriptionId = $subscription->setId(); |
||||
$filter = new Filter(); |
||||
$filter->setKinds([KindsEnum::METADATA]); |
||||
$filter->setAuthors($npubs); |
||||
$requestMessage = new RequestMessage($subscriptionId, [$filter]); |
||||
// TODO make relays configurable |
||||
$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': |
||||
$this->saveMetadata($item->event); |
||||
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 |
||||
*/ |
||||
private function saveMetadata($metadata): void |
||||
{ |
||||
try { |
||||
$user = $this->serializer->deserialize($metadata->content, User::class, 'json'); |
||||
$user->setNpub($metadata->pubkey); |
||||
} catch (\Exception $e) { |
||||
$this->logger->error('Deserialization of user data failed.', ['exception' => $e]); |
||||
return; |
||||
} |
||||
|
||||
try { |
||||
$this->logger->info('Saving user', ['user' => $user]); |
||||
$this->entityManager->persist($user); |
||||
$this->entityManager->flush(); |
||||
} catch (\Exception $e) { |
||||
$this->logger->error($e->getMessage()); |
||||
} |
||||
|
||||
} |
||||
|
||||
private function saveLongFormContent(mixed $filtered): void |
||||
{ |
||||
foreach ($filtered as $wrapper) { |
||||
$article = $this->articleFactory->createFromLongFormContentEvent($wrapper->event); |
||||
// check if event with same eventId already in DB |
||||
$saved = $this->entityManager->getRepository(Article::class)->findOneBy(['eventId' => $article->getEventId()]); |
||||
if (!$saved) { |
||||
try { |
||||
$this->logger->info('Saving article', ['article' => $article]); |
||||
$this->entityManager->persist($article); |
||||
$this->entityManager->flush(); |
||||
} catch (\Exception $e) { |
||||
$this->logger->error($e->getMessage()); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,14 @@
@@ -0,0 +1,14 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Twig\Components; |
||||
|
||||
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; |
||||
|
||||
#[AsTwigComponent] |
||||
class Footer { |
||||
public function __construct() |
||||
{ |
||||
} |
||||
} |
||||
@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
|
||||
<?php |
||||
|
||||
namespace App\Twig\Components; |
||||
|
||||
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; |
||||
use Symfony\UX\LiveComponent\DefaultActionTrait; |
||||
|
||||
#[AsLiveComponent] |
||||
class UserMenu |
||||
{ |
||||
use DefaultActionTrait; |
||||
} |
||||
@ -0,0 +1 @@
@@ -0,0 +1 @@
|
||||
<p>{{ "now"|date("Y") }} Newsroom</p> |
||||
@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
|
||||
<div {{ attributes.defaults(stimulus_controller('login')) }}> |
||||
{% if app.user %} |
||||
<p>Hello, {{ app.user.displayName }}</p> |
||||
|
||||
<a class="btn btn-primary" href="/logout" data-action="live#$render">Log out</a> |
||||
{% else %} |
||||
<button class="btn btn-primary" {{ stimulus_action('login', 'loginAct') }}>Log in</button> |
||||
{% endif %} |
||||
</div> |
||||
@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
|
||||
<?php |
||||
|
||||
use Symfony\Component\Dotenv\Dotenv; |
||||
|
||||
require dirname(__DIR__).'/vendor/autoload.php'; |
||||
|
||||
if (method_exists(Dotenv::class, 'bootEnv')) { |
||||
(new Dotenv())->bootEnv(dirname(__DIR__).'/.env'); |
||||
} |
||||
|
||||
if ($_SERVER['APP_DEBUG']) { |
||||
umask(0000); |
||||
} |
||||
Loading…
Reference in new issue