Browse Source

Authenticate user with a NostrSigner. Prep for long form articles.

imwald
Nuša Pukšič 1 year ago
parent
commit
a6615a7107
  1. 6
      .env.test
  2. 5
      .gitignore
  3. 2
      Dockerfile
  4. 2
      assets/app.js
  5. 3
      assets/bootstrap.js
  6. 37
      assets/controllers/login_controller.js
  7. 23
      bin/phpunit
  8. 2
      compose.override.yaml
  9. 1
      compose.yaml
  10. 21
      composer.json
  11. 9124
      composer.lock
  12. 3
      config/bundles.php
  13. 2
      config/packages/doctrine.yaml
  14. 5
      config/packages/framework.yaml
  15. 45
      config/packages/security.yaml
  16. 9
      config/packages/web_profiler.yaml
  17. 3
      config/routes/security.yaml
  18. 8
      config/routes/web_profiler.yaml
  19. 3
      config/services.yaml
  20. 31
      migrations/Version20241201111823.php
  21. 31
      migrations/Version20241202163851.php
  22. 33
      migrations/Version20241203124000.php
  23. 32
      migrations/Version20241203130131.php
  24. 31
      migrations/Version20241204212205.php
  25. 39
      phpunit.xml.dist
  26. 25
      src/Controller/LoginController.php
  27. 35
      src/Entity/Article.php
  28. 86
      src/Entity/Event.php
  29. 145
      src/Entity/User.php
  30. 59
      src/Factory/ArticleFactory.php
  31. 97
      src/Security/NostrAuthenticator.php
  32. 143
      src/Service/NostrClient.php
  33. 14
      src/Twig/Components/Footer.php
  34. 1
      src/Twig/Components/Header.php
  35. 12
      src/Twig/Components/UserMenu.php
  36. 64
      symfony.lock
  37. 12
      templates/base.html.twig
  38. 1
      templates/components/Footer.html.twig
  39. 9
      templates/components/UserMenu.html.twig
  40. 4
      templates/home.html.twig
  41. 13
      tests/bootstrap.php

6
.env.test

@ -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

5
.gitignore vendored

@ -13,3 +13,8 @@ @@ -13,3 +13,8 @@
/public/assets/
/assets/vendor/
###< symfony/asset-mapper ###
###> symfony/phpunit-bridge ###
.phpunit.result.cache
/phpunit.xml
###< symfony/phpunit-bridge ###

2
Dockerfile

@ -31,6 +31,7 @@ RUN set -eux; \ @@ -31,6 +31,7 @@ RUN set -eux; \
intl \
opcache \
zip \
gmp \
;
# https://getcomposer.org/doc/03-cli.md#composer-allow-superuser
@ -40,6 +41,7 @@ ENV PHP_INI_SCAN_DIR=":$PHP_INI_DIR/app.conf.d" @@ -40,6 +41,7 @@ ENV PHP_INI_SCAN_DIR=":$PHP_INI_DIR/app.conf.d"
###> recipes ###
###> doctrine/doctrine-bundle ###
RUN install-php-extensions pdo
RUN install-php-extensions pdo_pgsql
###< doctrine/doctrine-bundle ###
###< recipes ###

2
assets/app.js

@ -1,3 +1,4 @@ @@ -1,3 +1,4 @@
import './bootstrap.js';
/*
* Welcome to your app's main JavaScript file!
*
@ -6,5 +7,6 @@ @@ -6,5 +7,6 @@
*/
import './styles/theme.css';
import './styles/app.css';
import './styles/layout.css';
console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉');

3
assets/bootstrap.js vendored

@ -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);

37
assets/controllers/login_controller.js

@ -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();
}
}
}

23
bin/phpunit

@ -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';
}

2
compose.override.yaml

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
# Development environment override
services:
php:
image: newsroom-php
image: docker.io/library/newsroom-php:latest
build:
context: .
target: frankenphp_dev

1
compose.yaml

@ -5,6 +5,7 @@ services: @@ -5,6 +5,7 @@ services:
context: .
dockerfile: Dockerfile
environment:
APP_ENV: ${APP_ENV:-dev}
SERVER_NAME: ${SERVER_NAME:-localhost}, php:80
MERCURE_PUBLISHER_JWT_KEY: ${MERCURE_JWT_SECRET:-!ChangeThisMercureHubJWTSecretKey!}
MERCURE_SUBSCRIBER_JWT_KEY: ${MERCURE_JWT_SECRET:-!ChangeThisMercureHubJWTSecretKey!}

21
composer.json

@ -9,11 +9,14 @@ @@ -9,11 +9,14 @@
"php": ">=8.3.13",
"ext-ctype": "*",
"ext-iconv": "*",
"doctrine/dbal": "^3",
"doctrine/dbal": "^4.2",
"doctrine/doctrine-bundle": "^2.13",
"doctrine/doctrine-migrations-bundle": "^3.3",
"doctrine/orm": "^3.3",
"phpdocumentor/reflection-docblock": "^5.6",
"phpstan/phpdoc-parser": "^2.0",
"runtime/frankenphp-symfony": "^0.2.0",
"swentel/nostr-php": "^1.5",
"symfony/asset": "7.1.*",
"symfony/asset-mapper": "7.1.*",
"symfony/console": "7.1.*",
@ -21,10 +24,15 @@ @@ -21,10 +24,15 @@
"symfony/flex": "^2",
"symfony/form": "7.1.*",
"symfony/framework-bundle": "7.1.*",
"symfony/http-foundation": "7.1.*",
"symfony/intl": "7.1.*",
"symfony/mercure-bundle": "^0.3.9",
"symfony/property-access": "7.1.*",
"symfony/property-info": "7.1.*",
"symfony/runtime": "7.1.*",
"symfony/stimulus-bundle": "^2.21",
"symfony/security-bundle": "7.1.*",
"symfony/serializer": "7.1.*",
"symfony/stimulus-bundle": "^2.22",
"symfony/twig-bundle": "7.1.*",
"symfony/ux-live-component": "^2.21",
"symfony/yaml": "7.1.*",
@ -84,5 +92,14 @@ @@ -84,5 +92,14 @@
"runtime": {
"dotenv_overload": true
}
},
"require-dev": {
"phpunit/phpunit": "^9.5",
"symfony/browser-kit": "7.1.*",
"symfony/css-selector": "7.1.*",
"symfony/maker-bundle": "^1.61",
"symfony/phpunit-bridge": "^7.2",
"symfony/stopwatch": "7.1.*",
"symfony/web-profiler-bundle": "7.1.*"
}
}

9124
composer.lock generated

File diff suppressed because it is too large Load Diff

3
config/bundles.php

@ -10,4 +10,7 @@ return [ @@ -10,4 +10,7 @@ return [
Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true],
Symfony\UX\LiveComponent\LiveComponentBundle::class => ['all' => true],
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['local' => true, 'prod' => false],
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
];

2
config/packages/doctrine.yaml

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
driver: pdo_pgsql
# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)
#server_version: '16'

5
config/packages/framework.yaml

@ -4,7 +4,10 @@ framework: @@ -4,7 +4,10 @@ framework:
#csrf_protection: true
# Note that the session will be started ONLY if you read or write from it.
session: true
session:
handler_id: Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler
cookie_secure: auto
cookie_samesite: lax
#esi: true
#fragments: true

45
config/packages/security.yaml

@ -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

9
config/packages/web_profiler.yaml

@ -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

3
config/routes/security.yaml

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
_security_logout:
resource: security.route_loader.logout
type: service

8
config/routes/web_profiler.yaml

@ -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

3
config/services.yaml

@ -22,3 +22,6 @@ services: @@ -22,3 +22,6 @@ services:
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones
Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler:
arguments:
- '%env(DATABASE_URL)%'

31
migrations/Version20241201111823.php

@ -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');
}
}

31
migrations/Version20241202163851.php

@ -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"');
}
}

33
migrations/Version20241203124000.php

@ -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');
}
}

32
migrations/Version20241203130131.php

@ -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');
}
}

31
migrations/Version20241204212205.php

@ -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');
}
}

39
phpunit.xml.dist

@ -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>

25
src/Controller/LoginController.php

@ -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);
}
}

35
src/Entity/Article.php

@ -5,11 +5,14 @@ namespace App\Entity; @@ -5,11 +5,14 @@ namespace App\Entity;
use App\Enum\EventStatusEnum;
use App\Enum\IndexStatusEnum;
use App\Enum\KindsEnum;
use App\Repository\NostrEventRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: NostrEventRepository::class)]
/**
* Entity storing long-form articles
* NIP-23, kinds 30023, 30024
*/
#[ORM\Entity]
class Article
{
#[ORM\Id]
@ -17,6 +20,9 @@ class Article @@ -17,6 +20,9 @@ class Article
#[ORM\Column(length: 225, nullable: true)]
private ?int $id = null;
#[ORM\Column(type: Types::JSON, nullable: true)]
private $raw = null;
#[ORM\Column(length: 225, nullable: true)]
private ?string $eventId = null;
@ -59,9 +65,6 @@ class Article @@ -59,9 +65,6 @@ class Article
#[ORM\Column(nullable: true, enumType: IndexStatusEnum::class)]
private ?IndexStatusEnum $indexStatus = IndexStatusEnum::NOT_INDEXED;
#[ORM\Column(length: 255, nullable: true)]
private ?string $imgUrl = null;
// Local properties
#[ORM\Column(type: Types::JSON, nullable: true)]
private ?array $currentPlaces;
@ -277,18 +280,6 @@ class Article @@ -277,18 +280,6 @@ class Article
return $this;
}
public function getImgUrl(): ?string
{
return $this->imgUrl;
}
public function setImgUrl(?string $imgUrl): static
{
$this->imgUrl = $imgUrl;
return $this;
}
public function getRatingNegative(): ?int
{
return $this->ratingNegative;
@ -317,4 +308,14 @@ class Article @@ -317,4 +308,14 @@ class Article
{
return $this->eventStatus === EventStatusEnum::PREVIEW;
}
public function getRaw(): null
{
return $this->raw;
}
public function setRaw(object $raw): void
{
$this->raw = $raw;
}
}

86
src/Entity/Event.php

@ -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;
}
}

145
src/Entity/User.php

@ -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;
}
}

59
src/Factory/ArticleFactory.php

@ -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;
}
}

97
src/Security/NostrAuthenticator.php

@ -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;
}
}

143
src/Service/NostrClient.php

@ -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());
}
}
}
}
}

14
src/Twig/Components/Footer.php

@ -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()
{
}
}

1
src/Twig/Components/Header.php

@ -7,7 +7,6 @@ namespace App\Twig\Components; @@ -7,7 +7,6 @@ namespace App\Twig\Components;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
class Header
{
public function __construct()

12
src/Twig/Components/UserMenu.php

@ -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;
}

64
symfony.lock

@ -26,6 +26,20 @@ @@ -26,6 +26,20 @@
"./migrations/.gitignore"
]
},
"phpunit/phpunit": {
"version": "9.6",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "9.6",
"ref": "7364a21d87e658eb363c5020c072ecfdc12e2326"
},
"files": [
".env.test",
"phpunit.xml.dist",
"tests/bootstrap.php"
]
},
"symfony/asset-mapper": {
"version": "7.1",
"recipe": {
@ -84,6 +98,15 @@ @@ -84,6 +98,15 @@
"src/Kernel.php"
]
},
"symfony/maker-bundle": {
"version": "1.61",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
}
},
"symfony/mercure-bundle": {
"version": "0.3",
"recipe": {
@ -96,6 +119,21 @@ @@ -96,6 +119,21 @@
"./config/packages/mercure.yaml"
]
},
"symfony/phpunit-bridge": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.3",
"ref": "a411a0480041243d97382cac7984f7dce7813c08"
},
"files": [
".env.test",
"bin/phpunit",
"phpunit.xml.dist",
"tests/bootstrap.php"
]
},
"symfony/routing": {
"version": "7.1",
"recipe": {
@ -109,6 +147,19 @@ @@ -109,6 +147,19 @@
"config/routes.yaml"
]
},
"symfony/security-bundle": {
"version": "7.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.4",
"ref": "2ae08430db28c8eb4476605894296c82a642028f"
},
"files": [
"config/packages/security.yaml",
"config/routes/security.yaml"
]
},
"symfony/stimulus-bundle": {
"version": "2.21",
"recipe": {
@ -160,6 +211,19 @@ @@ -160,6 +211,19 @@
"./config/packages/twig_component.yaml"
]
},
"symfony/web-profiler-bundle": {
"version": "7.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.1",
"ref": "e42b3f0177df239add25373083a564e5ead4e13a"
},
"files": [
"config/packages/web_profiler.yaml",
"config/routes/web_profiler.yaml"
]
},
"twig/extra-bundle": {
"version": "v3.15.0"
}

12
templates/base.html.twig

@ -4,11 +4,11 @@ @@ -4,11 +4,11 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Newsroom{% endblock %}</title>
<link rel="stylesheet" href="{{ asset('build/styles.css') }}">
{% block stylesheets %}
{% endblock %}
{% block javascripts %}
{% block importmap %}{{ importmap('app') }}{% endblock %}
{% endblock %}
</head>
<body>
<header>
@ -28,12 +28,8 @@ @@ -28,12 +28,8 @@
</div>
<footer>
<p>&copy; {{ "now"|date("Y") }} Newsroom</p>
<twig:Footer />
</footer>
<script src="{{ asset('build/scripts.js') }}"></script>
{% block javascripts %}
{% endblock %}
</body>
</html>

1
templates/components/Footer.html.twig

@ -0,0 +1 @@ @@ -0,0 +1 @@
<p>{{ "now"|date("Y") }} Newsroom</p>

9
templates/components/UserMenu.html.twig

@ -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>

4
templates/home.html.twig

@ -1,5 +1,9 @@ @@ -1,5 +1,9 @@
{% extends 'base.html.twig' %}
{% block nav %}
<twig:UserMenu />
{% endblock %}
{% block body %}
{# content #}
{% endblock %}

13
tests/bootstrap.php

@ -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…
Cancel
Save