41 changed files with 7570 additions and 2651 deletions
@ -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 @@ |
|||||||
|
import { startStimulusApp } from '@symfony/stimulus-bundle'; |
||||||
|
|
||||||
|
const app = startStimulusApp(); |
||||||
// register any custom, 3rd party controllers here
|
// register any custom, 3rd party controllers here
|
||||||
// app.register('some_controller_name', SomeImportedController);
|
// app.register('some_controller_name', SomeImportedController);
|
||||||
|
|||||||
@ -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 @@ |
|||||||
|
#!/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 @@ |
|||||||
|
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 @@ |
|||||||
|
when@dev: |
||||||
|
web_profiler: |
||||||
|
toolbar: true |
||||||
|
intercept_redirects: false |
||||||
|
|
||||||
|
framework: |
||||||
|
profiler: |
||||||
|
only_exceptions: false |
||||||
|
collect_serializer_data: true |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
_security_logout: |
||||||
|
resource: security.route_loader.logout |
||||||
|
type: service |
||||||
@ -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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Twig\Components; |
||||||
|
|
||||||
|
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; |
||||||
|
use Symfony\UX\LiveComponent\DefaultActionTrait; |
||||||
|
|
||||||
|
#[AsLiveComponent] |
||||||
|
class UserMenu |
||||||
|
{ |
||||||
|
use DefaultActionTrait; |
||||||
|
} |
||||||
@ -0,0 +1 @@ |
|||||||
|
<p>{{ "now"|date("Y") }} Newsroom</p> |
||||||
@ -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> |
||||||
Loading…
Reference in new issue