Browse Source

Update security, refactor queries for metadata and articles

imwald
Nuša Pukšič 8 months ago
parent
commit
f2d41beaf9
  1. 6
      assets/styles/article.css
  2. 2
      composer.json
  3. 464
      composer.lock
  4. 3
      config/packages/fos_elastica.yaml
  5. 2
      config/packages/security.yaml
  6. 41
      src/Controller/ArticleController.php
  7. 56
      src/Controller/AuthorController.php
  8. 46
      src/Entity/User.php
  9. 60
      src/Security/UserDTOProvider.php
  10. 431
      src/Service/NostrClient.php
  11. 71
      src/Service/RedisCacheService.php
  12. 25
      src/Twig/Components/Molecules/UserFromNpub.php
  13. 13
      src/Util/CommonMark/Converter.php
  14. 2
      templates/components/Atoms/NameOrNpub.html.twig
  15. 2
      templates/components/Molecules/UserFromNpub.html.twig
  16. 2
      templates/components/UserMenu.html.twig
  17. 2
      templates/pages/article.html.twig
  18. 32
      templates/pages/author.html.twig

6
assets/styles/article.css

@ -72,3 +72,9 @@ blockquote p {
text-decoration: none; text-decoration: none;
font-weight: bold; font-weight: bold;
} }
.embedded-content iframe {
width: 100%;
height: auto;
aspect-ratio: 16/9;
}

2
composer.json

@ -15,9 +15,11 @@
"doctrine/doctrine-bundle": "^2.13", "doctrine/doctrine-bundle": "^2.13",
"doctrine/doctrine-migrations-bundle": "^3.3", "doctrine/doctrine-migrations-bundle": "^3.3",
"doctrine/orm": "^3.3", "doctrine/orm": "^3.3",
"embed/embed": "^4.4",
"endroid/qr-code": "^6.0", "endroid/qr-code": "^6.0",
"endroid/qr-code-bundle": "^6.0", "endroid/qr-code-bundle": "^6.0",
"friendsofsymfony/elastica-bundle": "^6.5", "friendsofsymfony/elastica-bundle": "^6.5",
"laminas/laminas-diactoros": "^3.6",
"league/commonmark": "^2.7", "league/commonmark": "^2.7",
"league/html-to-markdown": "*", "league/html-to-markdown": "*",
"phpdocumentor/reflection-docblock": "^5.6", "phpdocumentor/reflection-docblock": "^5.6",

464
composer.lock generated

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "ad306101014c19a923d27979dcdeb2cd", "content-hash": "caedc29506c52d87b3e4e4217fe34eb8",
"packages": [ "packages": [
{ {
"name": "bacon/bacon-qr-code", "name": "bacon/bacon-qr-code",
@ -106,6 +106,82 @@
}, },
"time": "2018-02-05T22:23:47+00:00" "time": "2018-02-05T22:23:47+00:00"
}, },
{
"name": "composer/ca-bundle",
"version": "1.5.6",
"source": {
"type": "git",
"url": "https://github.com/composer/ca-bundle.git",
"reference": "f65c239c970e7f072f067ab78646e9f0b2935175"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/ca-bundle/zipball/f65c239c970e7f072f067ab78646e9f0b2935175",
"reference": "f65c239c970e7f072f067ab78646e9f0b2935175",
"shasum": ""
},
"require": {
"ext-openssl": "*",
"ext-pcre": "*",
"php": "^7.2 || ^8.0"
},
"require-dev": {
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^8 || ^9",
"psr/log": "^1.0 || ^2.0 || ^3.0",
"symfony/process": "^4.0 || ^5.0 || ^6.0 || ^7.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\CaBundle\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
}
],
"description": "Lets you find a path to the system CA bundle, and includes a fallback to the Mozilla CA bundle.",
"keywords": [
"cabundle",
"cacert",
"certificate",
"ssl",
"tls"
],
"support": {
"irc": "irc://irc.freenode.org/composer",
"issues": "https://github.com/composer/ca-bundle/issues",
"source": "https://github.com/composer/ca-bundle/tree/1.5.6"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
"type": "tidelift"
}
],
"time": "2025-03-06T14:30:56+00:00"
},
{ {
"name": "composer/semver", "name": "composer/semver",
"version": "3.4.3", "version": "3.4.3",
@ -1498,6 +1574,95 @@
}, },
"time": "2023-04-21T15:31:12+00:00" "time": "2023-04-21T15:31:12+00:00"
}, },
{
"name": "embed/embed",
"version": "v4.4.17",
"source": {
"type": "git",
"url": "https://github.com/php-embed/Embed.git",
"reference": "b2ea091a5586c14ea5f2c5bf52fb0ef38e5aef87"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-embed/Embed/zipball/b2ea091a5586c14ea5f2c5bf52fb0ef38e5aef87",
"reference": "b2ea091a5586c14ea5f2c5bf52fb0ef38e5aef87",
"shasum": ""
},
"require": {
"composer/ca-bundle": "^1.0",
"ext-curl": "*",
"ext-dom": "*",
"ext-json": "*",
"ext-mbstring": "*",
"ml/json-ld": "^1.1",
"oscarotero/html-parser": "^0.1.4",
"php": "^7.4|^8",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0",
"psr/http-message": "^1.0|^2.0"
},
"require-dev": {
"brick/varexporter": "^0.3.1",
"friendsofphp/php-cs-fixer": "^2.0",
"nyholm/psr7": "^1.2",
"oscarotero/php-cs-fixer-config": "^1.0",
"phpunit/phpunit": "^9.0",
"symfony/css-selector": "^5.0"
},
"suggest": {
"symfony/css-selector": "If you want to get elements using css selectors"
},
"type": "library",
"autoload": {
"files": [
"src/functions.php"
],
"psr-4": {
"Embed\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Oscar Otero",
"email": "oom@oscarotero.com",
"homepage": "http://oscarotero.com",
"role": "Developer"
}
],
"description": "PHP library to retrieve page info using oembed, opengraph, etc",
"homepage": "https://github.com/oscarotero/Embed",
"keywords": [
"embed",
"embedly",
"oembed",
"opengraph",
"twitter cards"
],
"support": {
"email": "oom@oscarotero.com",
"issues": "https://github.com/oscarotero/Embed/issues",
"source": "https://github.com/php-embed/Embed/tree/v4.4.17"
},
"funding": [
{
"url": "https://paypal.me/oscarotero",
"type": "custom"
},
{
"url": "https://github.com/oscarotero",
"type": "github"
},
{
"url": "https://www.patreon.com/misteroom",
"type": "patreon"
}
],
"time": "2025-05-13T12:42:29+00:00"
},
{ {
"name": "endroid/installer", "name": "endroid/installer",
"version": "1.5.0", "version": "1.5.0",
@ -1992,6 +2157,94 @@
}, },
"time": "2025-02-12T20:20:53+00:00" "time": "2025-02-12T20:20:53+00:00"
}, },
{
"name": "laminas/laminas-diactoros",
"version": "3.6.0",
"source": {
"type": "git",
"url": "https://github.com/laminas/laminas-diactoros.git",
"reference": "b068eac123f21c0e592de41deeb7403b88e0a89f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laminas/laminas-diactoros/zipball/b068eac123f21c0e592de41deeb7403b88e0a89f",
"reference": "b068eac123f21c0e592de41deeb7403b88e0a89f",
"shasum": ""
},
"require": {
"php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0",
"psr/http-factory": "^1.1",
"psr/http-message": "^1.1 || ^2.0"
},
"conflict": {
"amphp/amp": "<2.6.4"
},
"provide": {
"psr/http-factory-implementation": "^1.0",
"psr/http-message-implementation": "^1.1 || ^2.0"
},
"require-dev": {
"ext-curl": "*",
"ext-dom": "*",
"ext-gd": "*",
"ext-libxml": "*",
"http-interop/http-factory-tests": "^2.2.0",
"laminas/laminas-coding-standard": "~3.0.0",
"php-http/psr7-integration-tests": "^1.4.0",
"phpunit/phpunit": "^10.5.36",
"psalm/plugin-phpunit": "^0.19.0",
"vimeo/psalm": "^5.26.1"
},
"type": "library",
"extra": {
"laminas": {
"module": "Laminas\\Diactoros",
"config-provider": "Laminas\\Diactoros\\ConfigProvider"
}
},
"autoload": {
"files": [
"src/functions/create_uploaded_file.php",
"src/functions/marshal_headers_from_sapi.php",
"src/functions/marshal_method_from_sapi.php",
"src/functions/marshal_protocol_version_from_sapi.php",
"src/functions/normalize_server.php",
"src/functions/normalize_uploaded_files.php",
"src/functions/parse_cookie_header.php"
],
"psr-4": {
"Laminas\\Diactoros\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"description": "PSR HTTP Message implementations",
"homepage": "https://laminas.dev",
"keywords": [
"http",
"laminas",
"psr",
"psr-17",
"psr-7"
],
"support": {
"chat": "https://laminas.dev/chat",
"docs": "https://docs.laminas.dev/laminas-diactoros/",
"forum": "https://discourse.laminas.dev",
"issues": "https://github.com/laminas/laminas-diactoros/issues",
"rss": "https://github.com/laminas/laminas-diactoros/releases.atom",
"source": "https://github.com/laminas/laminas-diactoros"
},
"funding": [
{
"url": "https://funding.communitybridge.org/projects/laminas-project",
"type": "community_bridge"
}
],
"time": "2025-05-05T16:03:34+00:00"
},
{ {
"name": "lcobucci/jwt", "name": "lcobucci/jwt",
"version": "5.5.0", "version": "5.5.0",
@ -2584,6 +2837,110 @@
}, },
"time": "2024-03-31T07:05:07+00:00" "time": "2024-03-31T07:05:07+00:00"
}, },
{
"name": "ml/iri",
"version": "1.1.4",
"target-dir": "ML/IRI",
"source": {
"type": "git",
"url": "https://github.com/lanthaler/IRI.git",
"reference": "cbd44fa913e00ea624241b38cefaa99da8d71341"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/lanthaler/IRI/zipball/cbd44fa913e00ea624241b38cefaa99da8d71341",
"reference": "cbd44fa913e00ea624241b38cefaa99da8d71341",
"shasum": ""
},
"require": {
"lib-pcre": ">=4.0",
"php": ">=5.3.0"
},
"type": "library",
"autoload": {
"psr-0": {
"ML\\IRI": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Markus Lanthaler",
"email": "mail@markus-lanthaler.com",
"homepage": "http://www.markus-lanthaler.com",
"role": "Developer"
}
],
"description": "IRI handling for PHP",
"homepage": "http://www.markus-lanthaler.com",
"keywords": [
"URN",
"iri",
"uri",
"url"
],
"support": {
"issues": "https://github.com/lanthaler/IRI/issues",
"source": "https://github.com/lanthaler/IRI/tree/master"
},
"time": "2014-01-21T13:43:39+00:00"
},
{
"name": "ml/json-ld",
"version": "1.2.1",
"source": {
"type": "git",
"url": "https://github.com/lanthaler/JsonLD.git",
"reference": "537e68e87a6bce23e57c575cd5dcac1f67ce25d8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/lanthaler/JsonLD/zipball/537e68e87a6bce23e57c575cd5dcac1f67ce25d8",
"reference": "537e68e87a6bce23e57c575cd5dcac1f67ce25d8",
"shasum": ""
},
"require": {
"ext-json": "*",
"ml/iri": "^1.1.1",
"php": ">=5.3.0"
},
"require-dev": {
"json-ld/tests": "1.0",
"phpunit/phpunit": "^4"
},
"type": "library",
"autoload": {
"psr-4": {
"ML\\JsonLD\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Markus Lanthaler",
"email": "mail@markus-lanthaler.com",
"homepage": "http://www.markus-lanthaler.com",
"role": "Developer"
}
],
"description": "JSON-LD Processor for PHP",
"homepage": "http://www.markus-lanthaler.com",
"keywords": [
"JSON-LD",
"jsonld"
],
"support": {
"issues": "https://github.com/lanthaler/JsonLD/issues",
"source": "https://github.com/lanthaler/JsonLD/tree/1.2.1"
},
"time": "2022-09-29T08:45:17+00:00"
},
{ {
"name": "nette/schema", "name": "nette/schema",
"version": "v1.3.2", "version": "v1.3.2",
@ -2793,6 +3150,59 @@
], ],
"time": "2021-11-18T09:23:29+00:00" "time": "2021-11-18T09:23:29+00:00"
}, },
{
"name": "oscarotero/html-parser",
"version": "v0.1.8",
"source": {
"type": "git",
"url": "https://github.com/oscarotero/html-parser.git",
"reference": "10f3219267a365d9433f2f7d1694209c9d436c8d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/oscarotero/html-parser/zipball/10f3219267a365d9433f2f7d1694209c9d436c8d",
"reference": "10f3219267a365d9433f2f7d1694209c9d436c8d",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^2.11",
"phpunit/phpunit": "^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"HtmlParser\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Oscar Otero",
"email": "oom@oscarotero.com",
"homepage": "http://oscarotero.com",
"role": "Developer"
}
],
"description": "Parse html strings to DOMDocument",
"homepage": "https://github.com/oscarotero/html-parser",
"keywords": [
"dom",
"html",
"parser"
],
"support": {
"email": "oom@oscarotero.com",
"issues": "https://github.com/oscarotero/html-parser/issues",
"source": "https://github.com/oscarotero/html-parser/tree/v0.1.8"
},
"time": "2023-11-29T20:28:41+00:00"
},
{ {
"name": "pagerfanta/pagerfanta", "name": "pagerfanta/pagerfanta",
"version": "v4.7.1", "version": "v4.7.1",
@ -3729,6 +4139,58 @@
}, },
"time": "2019-01-08T18:20:26+00:00" "time": "2019-01-08T18:20:26+00:00"
}, },
{
"name": "psr/http-client",
"version": "1.0.3",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-client.git",
"reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90",
"reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90",
"shasum": ""
},
"require": {
"php": "^7.0 || ^8.0",
"psr/http-message": "^1.0 || ^2.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Client\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for HTTP clients",
"homepage": "https://github.com/php-fig/http-client",
"keywords": [
"http",
"http-client",
"psr",
"psr-18"
],
"support": {
"source": "https://github.com/php-fig/http-client"
},
"time": "2023-09-23T14:17:50+00:00"
},
{ {
"name": "psr/http-factory", "name": "psr/http-factory",
"version": "1.1.0", "version": "1.1.0",

3
config/packages/fos_elastica.yaml

@ -10,11 +10,14 @@ fos_elastica:
articles: articles:
indexable_callback: [ 'App\Util\IndexableArticleChecker', 'isIndexable' ] indexable_callback: [ 'App\Util\IndexableArticleChecker', 'isIndexable' ]
properties: properties:
createdAt: ~
title: ~ title: ~
summary: ~ summary: ~
content: ~ content: ~
slug: slug:
type: keyword type: keyword
pubkey:
type: keyword
topics: ~ topics: ~
persistence: persistence:
driver: orm driver: orm

2
config/packages/security.yaml

@ -9,7 +9,7 @@ security:
pattern: ^/(_(profiler|wdt)|css|images|js)/ pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false security: false
main: main:
lazy: true lazy: false
stateless: false stateless: false
provider: user_dto_provider provider: user_dto_provider
custom_authenticators: custom_authenticators:

41
src/Controller/ArticleController.php

@ -6,6 +6,7 @@ use App\Entity\Article;
use App\Enum\KindsEnum; use App\Enum\KindsEnum;
use App\Form\EditorType; use App\Form\EditorType;
use App\Service\NostrClient; use App\Service\NostrClient;
use App\Service\RedisCacheService;
use App\Util\Bech32\Bech32Decoder; use App\Util\Bech32\Bech32Decoder;
use App\Util\CommonMark\Converter; use App\Util\CommonMark\Converter;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@ -83,8 +84,13 @@ class ArticleController extends AbstractController
* @throws InvalidArgumentException|CommonMarkException * @throws InvalidArgumentException|CommonMarkException
*/ */
#[Route('/article/d/{slug}', name: 'article-slug')] #[Route('/article/d/{slug}', name: 'article-slug')]
public function article(EntityManagerInterface $entityManager, CacheItemPoolInterface $articlesCache, public function article(
NostrClient $nostrClient, Converter $converter, $slug): Response $slug,
EntityManagerInterface $entityManager,
RedisCacheService $redisCacheService,
CacheItemPoolInterface $articlesCache,
Converter $converter
): Response
{ {
$article = null; $article = null;
// check if an item with same eventId already exists in the db // check if an item with same eventId already exists in the db
@ -114,37 +120,16 @@ class ArticleController extends AbstractController
$articlesCache->save($cacheItem); $articlesCache->save($cacheItem);
} }
// // suggestions $key = new Key();
// $suggestions = $repository->findBy(['pubkey' => $article->getPubkey()], ['createdAt' => 'DESC'], 3); $npub = $key->convertPublicKeyToBech32($article->getPubkey());
// // skip current, if listed in suggestions $author = $redisCacheService->getMetadata($npub);
// $suggestions = array_filter($suggestions, function ($s) use ($article) {
// return $s->getId() !== $article->getId();
// });
// $suggestions = array_merge($suggestions, $repository->findBy([], ['createdAt' => 'DESC'], 6 - count($suggestions)));
// // sort by date
// usort($suggestions, function ($a, $b) {
// return $b->getCreatedAt() <=> $a->getCreatedAt();
// });
try {
$meta = $nostrClient->getNpubMetadata($article->getPubkey());
if ($meta?->content) {
$author = (array) json_decode($meta->content);
} else {
$author = [
'name' => '<anonymous>'
];
}
} catch (\Exception $e) {
// Whatever?
}
return $this->render('Pages/article.html.twig', [ return $this->render('Pages/article.html.twig', [
'article' => $article, 'article' => $article,
'author' => $author ?? null, 'author' => $author,
'npub' => $npub,
'content' => $cacheItem->get(), 'content' => $cacheItem->get(),
//'suggestions' => $suggestions
]); ]);
} }

56
src/Controller/AuthorController.php

@ -4,18 +4,14 @@ declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use App\Entity\Article; use App\Service\RedisCacheService;
use App\Entity\Event; use Elastica\Query\Terms;
use App\Enum\KindsEnum; use FOS\ElasticaBundle\Finder\FinderInterface;
use App\Service\NostrClient;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
use swentel\nostr\Key\Key; use swentel\nostr\Key\Key;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
class AuthorController extends AbstractController class AuthorController extends AbstractController
{ {
@ -24,39 +20,18 @@ class AuthorController extends AbstractController
* @throws InvalidArgumentException * @throws InvalidArgumentException
*/ */
#[Route('/p/{npub}', name: 'author-profile', requirements: ['npub' => '^npub1.*'])] #[Route('/p/{npub}', name: 'author-profile', requirements: ['npub' => '^npub1.*'])]
public function index($npub, CacheInterface $redisCache, EntityManagerInterface $entityManager, NostrClient $client): Response public function index($npub, RedisCacheService $redisCacheService, FinderInterface $finder): Response
{ {
$keys = new Key(); $keys = new Key();
$pubkey = $keys->convertToHex($npub); $pubkey = $keys->convertToHex($npub);
$relays = [];
try { $author = $redisCacheService->getMetadata($npub);
$cacheKey = '0_' . $pubkey; $relays = $redisCacheService->getRelays($npub);
$author = $redisCache->get($cacheKey, function (ItemInterface $item) use ($pubkey, $client) { // Look for articles in index, assume indexing is done regularly
$item->expiresAfter(3600); // 1 hour, adjust as needed // TODO give users an option to reindex
$query = new Terms('pubkey', [$pubkey]);
$meta = $client->getNpubMetadata($pubkey); $list = $finder->find($query, 25);
return (array) json_decode($meta->content ?? '{}');
});
} catch (InvalidArgumentException | \Exception $e) {
// nothing to do
}
try {
$cacheKey = '10002_' . $pubkey;
$relays = $redisCache->get($cacheKey, function (ItemInterface $item) use ($pubkey, $client) {
$item->expiresAfter(3600); // 1 hour, adjust as needed
return $client->getNpubRelays($pubkey);
});
} catch (InvalidArgumentException | \Exception $e) {
// nothing to do
}
$list = $client->getLongFormContentForPubkey($pubkey);
// deduplicate by slugs // deduplicate by slugs
$articles = []; $articles = [];
@ -66,19 +41,10 @@ class AuthorController extends AbstractController
} }
} }
// $indices = $entityManager->getRepository(Event::class)->findBy(['pubkey' => $pubkey, 'kind' => KindsEnum::PUBLICATION_INDEX]);
// $nzines = $entityManager->getRepository(Nzine::class)->findBy(['editor' => $pubkey]);
// $nzine = $entityManager->getRepository(Nzine::class)->findBy(['npub' => $npub]);
return $this->render('Pages/author.html.twig', [ return $this->render('Pages/author.html.twig', [
'author' => $author, 'author' => $author,
'npub' => $npub, 'npub' => $npub,
'articles' => $articles, 'articles' => $articles,
'nzine' => null,
'nzines' => null,
'idx' => null,
'relays' => $relays 'relays' => $relays
]); ]);
} }
@ -90,9 +56,7 @@ class AuthorController extends AbstractController
public function authorRedirect($pubkey): Response public function authorRedirect($pubkey): Response
{ {
$keys = new Key(); $keys = new Key();
$npub = $keys->convertPublicKeyToBech32($pubkey); $npub = $keys->convertPublicKeyToBech32($pubkey);
return $this->redirectToRoute('author-profile', ['npub' => $npub]); return $this->redirectToRoute('author-profile', ['npub' => $npub]);
} }
} }

46
src/Entity/User.php

@ -15,6 +15,8 @@ use Symfony\Component\Security\Core\User\UserInterface;
#[ORM\Table(name: "app_user")] #[ORM\Table(name: "app_user")]
class User implements UserInterface, EquatableInterface class User implements UserInterface, EquatableInterface
{ {
private static array $sessionData = [];
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column] #[ORM\Column]
@ -84,38 +86,48 @@ class User implements UserInterface, EquatableInterface
return $this->getNpub(); return $this->getNpub();
} }
public function setMetadata($metadata) public function setMetadata(?object $metadata): void
{ {
$this->metadata = $metadata; self::$sessionData[$this->getNpub()]['metadata'] = $metadata;
} }
public function getMetadata() public function getMetadata(): ?object
{ {
return $this->metadata; return self::$sessionData[$this->getNpub()]['metadata'] ?? null;
}
public function getDisplayName() {
return $this->metadata->name;
} }
/** public function setRelays(?array $relays): void
* @param mixed $relays
*/
public function setRelays($relays): void
{ {
$this->relays = $relays; self::$sessionData[$this->getNpub()]['relays'] = $relays;
} }
/**
* @return null|array
*/
public function getRelays(): ?array public function getRelays(): ?array
{ {
return $this->relays; return self::$sessionData[$this->getNpub()]['relays'] ?? null;
} }
public function isEqualTo(UserInterface $user): bool public function isEqualTo(UserInterface $user): bool
{ {
return $this->getUserIdentifier() === $user->getUserIdentifier(); return $this->getUserIdentifier() === $user->getUserIdentifier();
} }
public function __serialize(): array
{
return [
'id' => $this->id,
'npub' => $this->npub,
'roles' => $this->roles,
'metadata' => $this->metadata,
'relays' => $this->relays
];
}
public function __unserialize(array $data): void
{
$this->id = $data['id'];
$this->npub = $data['npub'];
$this->roles = $data['roles'];
$this->metadata = $data['metadata'];
$this->relays = $data['relays'];
}
} }

60
src/Security/UserDTOProvider.php

@ -3,11 +3,9 @@
namespace App\Security; namespace App\Security;
use App\Entity\User; use App\Entity\User;
use App\Enum\KindsEnum; use App\Service\RedisCacheService;
use App\Service\NostrClient;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use swentel\nostr\Key\Key;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface; use Symfony\Component\Security\Core\User\UserProviderInterface;
@ -15,9 +13,11 @@ readonly class UserDTOProvider implements UserProviderInterface
{ {
public function __construct( public function __construct(
private EntityManagerInterface $entityManager, private EntityManagerInterface $entityManager,
private NostrClient $nostrClient, private RedisCacheService $redisCacheService,
private LoggerInterface $logger private LoggerInterface $logger
) {} )
{
}
/** /**
* @inheritDoc * @inheritDoc
@ -27,8 +27,12 @@ readonly class UserDTOProvider implements UserProviderInterface
if (!$user instanceof User) { if (!$user instanceof User) {
throw new \InvalidArgumentException('Invalid user type.'); throw new \InvalidArgumentException('Invalid user type.');
} }
$this->logger->info('Refresh user.', ['user' => $user->getUserIdentifier()]);
return $this->loadUserByIdentifier($user->getUserIdentifier()); $freshUser = $this->entityManager->getRepository(User::class)
->findOneBy(['npub' => $user->getUserIdentifier()]);
$metadata = $this->redisCacheService->getMetadata($user->getUserIdentifier());
$freshUser->setMetadata($metadata);
return $freshUser;
} }
/** /**
@ -44,39 +48,7 @@ readonly class UserDTOProvider implements UserProviderInterface
*/ */
public function loadUserByIdentifier(string $identifier): UserInterface public function loadUserByIdentifier(string $identifier): UserInterface
{ {
try { $this->logger->info('Load user by identifier.');
$key = new Key();
$pubkey = $key->convertToHex($identifier);
$data = $this->nostrClient->getLoginData($pubkey);
$this->logger->info('Load user by identifier.', ['data' => $data]);
$metadata = null;
$relays = null;
foreach ($data as $d) {
$ev = $d->event;
$this->logger->info('Load user by identifier event.', ['event' => $ev]);
if ($ev->kind === KindsEnum::METADATA->value) {
$metadata = json_decode($ev->content);
$this->logger->info('Load user by identifier event.', ['metadata' => $metadata]);
}
if ($ev->kind === KindsEnum::RELAY_LIST->value) {
$relays = $ev->tags;
}
}
} catch (\Exception $e) {
$this->logger->error('Error getting user data.', ['exception' => $e]);
$metadata = null;
$relays = null;
}
// Fallback metadata if none fetched
if (is_null($metadata)) {
$metadata = new \stdClass();
$metadata->name = substr($identifier, 0, 8) . '…' . substr($identifier, -4);
}
// Get or create user // Get or create user
$user = $this->entityManager->getRepository(User::class)->findOneBy(['npub' => $identifier]); $user = $this->entityManager->getRepository(User::class)->findOneBy(['npub' => $identifier]);
@ -84,15 +56,13 @@ readonly class UserDTOProvider implements UserProviderInterface
$user = new User(); $user = new User();
$user->setNpub($identifier); $user->setNpub($identifier);
$this->entityManager->persist($user); $this->entityManager->persist($user);
$this->entityManager->flush();
} }
// Update with fresh metadata/relays $metadata = $this->redisCacheService->getMetadata($identifier);
$user->setMetadata($metadata); $user->setMetadata($metadata);
$user->setRelays($relays); $this->logger->debug('User metadata set.', ['metadata' => json_encode($user->getMetadata())]);
$this->entityManager->flush();
return $user; return $user;
} }
} }

431
src/Service/NostrClient.php

@ -12,6 +12,7 @@ use Doctrine\Persistence\ManagerRegistry;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use swentel\nostr\Event\Event; use swentel\nostr\Event\Event;
use swentel\nostr\Filter\Filter; use swentel\nostr\Filter\Filter;
use swentel\nostr\Key\Key;
use swentel\nostr\Message\EventMessage; use swentel\nostr\Message\EventMessage;
use swentel\nostr\Message\RequestMessage; use swentel\nostr\Message\RequestMessage;
use swentel\nostr\Relay\Relay; use swentel\nostr\Relay\Relay;
@ -24,23 +25,81 @@ use Symfony\Component\Serializer\SerializerInterface;
class NostrClient class NostrClient
{ {
private $defaultRelaySet; private RelaySet $defaultRelaySet;
/**
* List of reputable relays in descending order of reputation
*/
private const array REPUTABLE_RELAYS = [
'wss://theforest.nostr1.com',
'wss://relay.damus.io',
'wss://relay.primal.net',
'wss://nos.lol',
'wss://relay.snort.social',
'wss://nostr.land',
'wss://purplepag.es',
];
public function __construct(private readonly EntityManagerInterface $entityManager, public function __construct(private readonly EntityManagerInterface $entityManager,
private readonly ManagerRegistry $managerRegistry, private readonly ManagerRegistry $managerRegistry,
private readonly UserEntityRepository $userEntityRepository, private readonly UserEntityRepository $userEntityRepository,
private readonly ArticleFactory $articleFactory, private readonly ArticleFactory $articleFactory,
private readonly SerializerInterface $serializer, private readonly SerializerInterface $serializer,
private readonly TokenStorageInterface $tokenStorage, private readonly TokenStorageInterface $tokenStorage,
private readonly LoggerInterface $logger) private readonly LoggerInterface $logger)
{ {
// TODO configure read and write relays for logged in users from their 10002 events
$this->defaultRelaySet = new RelaySet(); $this->defaultRelaySet = new RelaySet();
$this->defaultRelaySet->addRelay(new Relay('wss://relay.damus.io')); // public relay $this->defaultRelaySet->addRelay(new Relay('wss://theforest.nostr1.com')); // public aggregator relay
// $this->defaultRelaySet->addRelay(new Relay('wss://relay.primal.net')); // public relay }
// $this->defaultRelaySet->addRelay(new Relay('wss://nos.lol')); // public relay
// $this->defaultRelaySet->addRelay(new Relay('wss://relay.snort.social')); // public relay /**
$this->defaultRelaySet->addRelay(new Relay('wss://theforest.nostr1.com')); // public relay * Creates a RelaySet from a list of relay URLs
// $this->defaultRelaySet->addRelay(new Relay('wss://purplepag.es')); // public relay */
private function createRelaySet(array $relayUrls): RelaySet
{
$relaySet = new RelaySet();
foreach ($relayUrls as $relayUrl) {
$relaySet->addRelay(new Relay($relayUrl));
}
return $relaySet;
}
/**
* Get top 3 reputable relays from an author's relay list
*/
private function getTopReputableRelaysForAuthor(string $pubkey, int $limit = 3): array
{
try {
$authorRelays = $this->getNpubRelays($pubkey);
} catch (\Exception $e) {
$this->logger->error('Error getting author relays', [
'pubkey' => $pubkey,
'error' => $e->getMessage()
]);
// fall through
$authorRelays = [];
}
if (empty($authorRelays)) {
return [self::REPUTABLE_RELAYS[0]]; // Default to theforest if no author relays
}
$reputableAuthorRelays = [];
foreach (self::REPUTABLE_RELAYS as $relay) {
if (in_array($relay, $authorRelays) && count($reputableAuthorRelays) < $limit) {
$reputableAuthorRelays[] = $relay;
}
}
// If no reputable relays found in author's list, take the top 3 from author's list
// But make sure they start with wss: and are not localhost
if (empty($reputableAuthorRelays)) {
$authorRelays = array_filter($authorRelays, function ($relay) {
return str_starts_with($relay, 'wss:') && !str_contains($relay, 'localhost');
});
return array_slice($authorRelays, 0, $limit);
}
return $reputableAuthorRelays;
} }
public function getLoginData($npub) public function getLoginData($npub)
@ -54,6 +113,8 @@ class NostrClient
$request = new Request($this->defaultRelaySet, $requestMessage); $request = new Request($this->defaultRelaySet, $requestMessage);
$response = $request->send(); $response = $request->send();
$this->logger->info('Login data.', ['response' => $response]);
// response is an n-dimensional array, where n is the number of relays in the set // response is an n-dimensional array, where n is the number of relays in the set
// check that response has events in the results // check that response has events in the results
foreach ($response as $relayRes) { foreach ($response as $relayRes) {
@ -68,56 +129,33 @@ class NostrClient
return null; return null;
} }
/** public function getNpubMetadata($npub): \stdClass
* @throws \Exception
*/
public function getNpubMetadata($npub)
{ {
$filter = new Filter(); $this->logger->info('Getting metadata for npub', ['npub' => $npub]);
$filter->setKinds([KindsEnum::METADATA]); // Convert npub to hex
$filter->setAuthors([$npub]); $keys = new Key();
$filters = [$filter]; $pubkey = $keys->convertToHex($npub);
$subscription = new Subscription(); $request = $this->createNostrRequest(
$requestMessage = new RequestMessage($subscription->getId(), $filters); kinds: [KindsEnum::METADATA],
$relays = [ filters: ['authors' => [$pubkey]],
new Relay('wss://purplepag.es'), relaySet: $this->defaultRelaySet
new Relay('wss://theforest.nostr1.com'), );
];
$relaySet = new RelaySet(); $events = $this->processResponse($request->send(), function($received) {
$relaySet->setRelays($relays); return $received;
});
$request = new Request($relaySet, $requestMessage);
$response = $request->send(); if (empty($events)) {
$meta = new \stdClass();
$meta = []; $content = new \stdClass();
// response is an array of arrays $content->name = substr($npub, 0, 8) . '…' . substr($npub, -4);
foreach ($response as $value) { $meta->content = json_encode($content);
foreach ($value as $item) { return $meta;
switch ($item->type) {
case 'EVENT':
$meta[] = $item->event;
break;
case 'AUTH':
throw new UnauthorizedHttpException('', 'Relay requires authentication');
case 'ERROR':
case 'NOTICE':
throw new \Exception('An error occurred');
default:
// skip
}
}
} }
if (count($meta) > 0) { // Sort by date and return newest
if (count($meta) > 1) { usort($events, fn($a, $b) => $b->created_at <=> $a->created_at);
// sort by date and pick newest return $events[0];
usort($meta, function($a, $b) {
return $b->created_at <=> $a->created_at;
});
}
return $meta[0];
}
return [];
} }
public function getNpubLongForm($npub): void public function getNpubLongForm($npub): void
@ -159,7 +197,6 @@ class NostrClient
// TODO handle relays that require auth // TODO handle relays that require auth
} }
public function publishEvent(Event $event, array $relays): array public function publishEvent(Event $event, array $relays): array
{ {
$eventMessage = new EventMessage($event); $eventMessage = new EventMessage($event);
@ -192,11 +229,7 @@ class NostrClient
} }
$requestMessage = new RequestMessage($subscriptionId, [$filter]); $requestMessage = new RequestMessage($subscriptionId, [$filter]);
// if user is logged in, use their settings $request = new Request($this->defaultRelaySet, $requestMessage);
$user = $this->tokenStorage->getToken()?->getUser();
$relays = $this->defaultRelaySet;
$request = new Request($relays, $requestMessage);
$response = $request->send(); $response = $request->send();
// response is an n-dimensional array, where n is the number of relays in the set // response is an n-dimensional array, where n is the number of relays in the set
@ -209,11 +242,8 @@ class NostrClient
$this->saveLongFormContent($filtered); $this->saveLongFormContent($filtered);
} }
} }
// TODO handle relays that require auth
} }
public function getLongFormFromNaddr($slug, $relayList, $author, $kind): void public function getLongFormFromNaddr($slug, $relayList, $author, $kind): void
{ {
$subscription = new Subscription(); $subscription = new Subscription();
@ -222,38 +252,74 @@ class NostrClient
$filter->setKinds([$kind]); $filter->setKinds([$kind]);
$filter->setAuthors([$author]); $filter->setAuthors([$author]);
$filter->setTag('#d', [$slug]); $filter->setTag('#d', [$slug]);
$requestMessage = new RequestMessage($subscriptionId, [$filter]); $requestMessage = new RequestMessage($subscriptionId, [$filter]);
$relays = $this->defaultRelaySet; // First try with theforest relay and any relays in $relayList
if (!empty($relayList)) { // Add theforest relay to the list, if not already present
// $relays->addRelay(new Relay($relayList[0])); if (!in_array('wss://theforest.nostr1.com', $relayList)) {
$relayList[] = 'wss://theforest.nostr1.com';
} }
$forestRelaySet = $this->createRelaySet($relayList);
$response = null;
$hasEvents = false;
try { try {
$request = new Request($this->defaultRelaySet, $requestMessage); $request = new Request($forestRelaySet, $requestMessage);
$response = $request->send(); $response = $request->send();
// Check if we got any events
foreach ($response as $relayRes) {
$filtered = array_filter($relayRes, function ($item) {
return $item->type === 'EVENT';
});
if (count($filtered) > 0) {
$this->saveLongFormContent($filtered);
$hasEvents = true;
break;
}
}
// If no events found in theforest, try author's reputable relays
if (!$hasEvents) {
$topAuthorRelays = $this->getTopReputableRelaysForAuthor($author);
$authorRelaySet = $this->createRelaySet($topAuthorRelays);
$this->logger->info('No results from theforest, trying author relays', [
'relays' => $topAuthorRelays
]);
$request = new Request($authorRelaySet, $requestMessage);
$response = $request->send();
foreach ($response as $relayRes) {
$filtered = array_filter($relayRes, function ($item) {
return $item->type === 'EVENT';
});
if (count($filtered) > 0) {
$this->saveLongFormContent($filtered);
break;
}
}
}
} catch (\Exception $e) { } catch (\Exception $e) {
// likely a problem with user's relays, go to defaults only // If any error occurs, fall back to default relay set
$this->logger->error('Error querying relays, falling back to defaults', [
'error' => $e->getMessage()
]);
$request = new Request($this->defaultRelaySet, $requestMessage); $request = new Request($this->defaultRelaySet, $requestMessage);
$response = $request->send(); $response = $request->send();
}
// response is an n-dimensional array, where n is the number of relays in the set foreach ($response as $relayRes) {
// check that response has events in the results $filtered = array_filter($relayRes, function ($item) {
foreach ($response as $relayRes) { return $item->type === 'EVENT';
$filtered = array_filter($relayRes, function ($item) { });
return $item->type === 'EVENT'; if (count($filtered) > 0) {
}); $this->saveLongFormContent($filtered);
if (count($filtered) > 0) { }
$this->saveLongFormContent($filtered);
} }
} }
// TODO handle relays that require auth
} }
/** /**
* User metadata * User metadata
* NIP-01 * NIP-01
@ -292,7 +358,6 @@ class NostrClient
} }
} }
} }
} }
/** /**
@ -316,7 +381,6 @@ class NostrClient
$this->logger->error($e->getMessage()); $this->logger->error($e->getMessage());
$this->managerRegistry->resetManager(); $this->managerRegistry->resetManager();
} }
} }
private function saveLongFormContent(mixed $filtered): void private function saveLongFormContent(mixed $filtered): void
@ -338,54 +402,39 @@ class NostrClient
} }
} }
/**
public function getNpubRelays($pubkey): array * @throws \Exception
*/
public function getNpubRelays($npub): array
{ {
$subscription = new Subscription(); // Convert npub to hex
$subscriptionId = $subscription->setId(); $keys = new Key();
$filter = new Filter(); $pubkey = $keys->convertToHex($npub);
$filter->setKinds([KindsEnum::RELAY_LIST]); // Get relays
$filter->setAuthors([$pubkey]); $request = $this->createNostrRequest(
$requestMessage = new RequestMessage($subscriptionId, [$filter]); kinds: [KindsEnum::RELAY_LIST],
$request = new Request($this->defaultRelaySet, $requestMessage); filters: ['authors' => [$pubkey]],
relaySet: $this->defaultRelaySet
$response = $request->send(); );
$response = $this->processResponse($request->send(), function($received) {
// response is an array of arrays return $received;
foreach ($response as $value) { });
foreach ($value as $item) { if (empty($response)) {
switch ($item->type) { return [];
case 'EVENT': }
$event = $item->event; // Sort by date and use newest
$relays = []; usort($response, fn($a, $b) => $b->created_at <=> $a->created_at);
foreach ($event->tags as $tag) { // Process tags of the $response[0] and extract relays
if ($tag[0] === 'r') { $relays = [];
$this->logger->info('Relay: ' . $tag[1]); foreach ($response[0]->tags as $tag) {
// if not already listed if ($tag[0] === 'r') {
// is wss: $relays[] = $tag[1];
// not localhost
if (!in_array($tag[1], $relays)
&& str_starts_with('wss:',$tag[1])
&& !str_contains('localhost',$tag[1])) {
$relays[] = $tag[1];
}
}
}
if (!empty($relays)) {
return $relays;
}
break;
case 'AUTH':
throw new UnauthorizedHttpException('', 'Relay requires authentication');
case 'ERROR':
case 'NOTICE':
throw new \Exception('An error occurred');
default:
// nothing to do here
}
} }
} }
return []; // Remove duplicates, localhost and any non-wss relays
return array_filter(array_unique($relays), function ($relay) {
return str_starts_with($relay, 'wss:') && !str_contains($relay, 'localhost');
});
} }
/** /**
@ -415,10 +464,10 @@ class NostrClient
$list[] = $item; $list[] = $item;
break; break;
case 'AUTH': case 'AUTH':
throw new UnauthorizedHttpException('', 'Relay requires authentication'); // throw new UnauthorizedHttpException('', 'Relay requires authentication');
case 'ERROR': case 'ERROR':
case 'NOTICE': case 'NOTICE':
throw new \Exception('An error occurred'); // throw new \Exception('An error occurred');
default: default:
// nothing to do here // nothing to do here
} }
@ -430,10 +479,9 @@ class NostrClient
/** /**
* @throws \Exception * @throws \Exception
*/ */
public function getLongFormContentForPubkey(string $pubkey) public function getLongFormContentForPubkey(string $pubkey): array
{ {
$articles = []; $articles = [];
$relaySet = $this->defaultRelaySet; $relaySet = $this->defaultRelaySet;
// look for last months long-form notes // look for last months long-form notes
@ -480,28 +528,111 @@ class NostrClient
$filter->setTag('#d', $slugs); $filter->setTag('#d', $slugs);
$requestMessage = new RequestMessage($subscriptionId, [$filter]); $requestMessage = new RequestMessage($subscriptionId, [$filter]);
$request = new Request($this->defaultRelaySet, $requestMessage); try {
$request = new Request($this->defaultRelaySet, $requestMessage);
$response = $request->send();
$hasEvents = false;
$response = $request->send(); // Check if we got any events
// response is an array of arrays foreach ($response as $value) {
foreach ($response as $value) { foreach ($value as $item) {
foreach ($value as $item) { if ($item->type === 'EVENT') {
switch ($item->type) {
case 'EVENT':
if (!isset($articles[$item->event->id])) { if (!isset($articles[$item->event->id])) {
$articles[$item->event->id] = $item->event; $articles[$item->event->id] = $item->event;
$hasEvents = true;
} }
break; }
case 'AUTH': }
throw new UnauthorizedHttpException('', 'Relay requires authentication'); }
case 'ERROR':
case 'NOTICE': // If no articles found, try the default relay set
$this->logger->error('An error while getting articles.', $item); if (!$hasEvents && !empty($slugs)) {
default: $this->logger->info('No results from theforest, trying default relays');
// nothing to do here
$request = new Request($this->defaultRelaySet, $requestMessage);
$response = $request->send();
foreach ($response as $value) {
foreach ($value as $item) {
if ($item->type === 'EVENT') {
if (!isset($articles[$item->event->id])) {
$articles[$item->event->id] = $item->event;
}
} elseif (in_array($item->type, ['AUTH', 'ERROR', 'NOTICE'])) {
$this->logger->error('An error while getting articles.', ['response' => $item]);
}
}
}
}
} catch (\Exception $e) {
$this->logger->error('Error querying relays', [
'error' => $e->getMessage()
]);
// Fall back to default relay set
$request = new Request($this->defaultRelaySet, $requestMessage);
$response = $request->send();
foreach ($response as $value) {
foreach ($value as $item) {
if ($item->type === 'EVENT') {
if (!isset($articles[$item->event->id])) {
$articles[$item->event->id] = $item->event;
}
}
} }
} }
} }
return $articles; return $articles;
} }
private function createNostrRequest(array $kinds, array $filters = [], ?RelaySet $relaySet = null): Request
{
$subscription = new Subscription();
$filter = new Filter();
$filter->setKinds($kinds);
foreach ($filters as $key => $value) {
$method = 'set' . ucfirst($key);
if (method_exists($filter, $method)) {
$filter->$method($value);
}
}
$requestMessage = new RequestMessage($subscription->getId(), [$filter]);
return new Request($relaySet ?? $this->defaultRelaySet, $requestMessage);
}
private function processResponse(array $response, callable $eventHandler): array
{
$results = [];
foreach ($response as $relayRes) {
foreach ($relayRes as $item) {
try {
switch ($item->type) {
case 'EVENT':
$result = $eventHandler($item->event);
if ($result !== null) {
$results[] = $result;
}
break;
case 'AUTH':
$this->logger->warning('Relay requires authentication', ['response' => $item]);
break;
case 'ERROR':
case 'NOTICE':
$this->logger->error('Relay error/notice', ['response' => $item]);
break;
}
} catch (\Exception $e) {
$this->logger->error('Error processing event', [
'exception' => $e->getMessage(),
'event' => $item
]);
}
}
}
return $results;
}
} }

71
src/Service/RedisCacheService.php

@ -0,0 +1,71 @@
<?php
namespace App\Service;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
readonly class RedisCacheService
{
public function __construct(
private NostrClient $nostrClient,
private CacheInterface $redisCache,
private LoggerInterface $logger
)
{
}
/**
* @param string $npub
* @return \stdClass
*/
public function getMetadata(string $npub): \stdClass
{
$cacheKey = '0_' . $npub;
try {
return $this->redisCache->get($cacheKey, function (ItemInterface $item) use ($npub) {
$item->expiresAfter(3600); // 1 hour, adjust as needed
try {
$meta = $this->nostrClient->getNpubMetadata($npub);
} catch (\Exception $e) {
$this->logger->error('Error getting user data.', ['exception' => $e]);
$meta = new \stdClass();
$content = new \stdClass();
$meta->name = substr($npub, 0, 8) . '…' . substr($npub, -4);
$meta->content = json_encode($content);
}
$this->logger->info('Metadata:', ['meta' => json_encode($meta)]);
return json_decode($meta->content);
});
} catch (InvalidArgumentException $e) {
$this->logger->error('Error getting user data.', ['exception' => $e]);
$content = new \stdClass();
$content->name = substr($npub, 0, 8) . '…' . substr($npub, -4);
return $content;
}
}
public function getRelays($npub)
{
$cacheKey = '10002_' . $npub;
try {
return $this->redisCache->get($cacheKey, function (ItemInterface $item) use ($npub) {
$item->expiresAfter(3600); // 1 hour, adjust as needed
try {
$relays = $this->nostrClient->getNpubRelays($npub);
} catch (\Exception $e) {
$this->logger->error('Error getting user relays.', ['exception' => $e]);
}
return $relays ?? [];
});
} catch (InvalidArgumentException $e) {
$this->logger->error('Error getting user relays.', ['exception' => $e]);
return [];
}
}
}

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

@ -2,11 +2,8 @@
namespace App\Twig\Components\Molecules; namespace App\Twig\Components\Molecules;
use App\Service\NostrClient; use App\Service\RedisCacheService;
use Psr\Cache\InvalidArgumentException;
use swentel\nostr\Key\Key; use swentel\nostr\Key\Key;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent] #[AsTwigComponent]
@ -16,31 +13,15 @@ final class UserFromNpub
public string $npub; public string $npub;
public $user = null; public $user = null;
public function __construct( public function __construct(private readonly RedisCacheService $redisCacheService)
private readonly CacheInterface $redisCache,
private readonly NostrClient $nostrClient)
{ {
} }
public function mount(string $pubkey): void public function mount(string $pubkey): void
{ {
$keys = new Key(); $keys = new Key();
$this->pubkey = $pubkey; $this->pubkey = $pubkey;
$this->npub = $keys->convertPublicKeyToBech32($pubkey); $this->npub = $keys->convertPublicKeyToBech32($pubkey);
$this->user = $this->redisCacheService->getMetadata($this->npub);
try {
$cacheKey = '0_' . $this->pubkey;
$this->user = $this->redisCache->get($cacheKey, function (ItemInterface $item) use ($pubkey) {
$item->expiresAfter(3600); // 1 hour, adjust as needed
$meta = $this->nostrClient->getNpubMetadata($pubkey);
return (array) json_decode($meta->content);
});
} catch (InvalidArgumentException | \Exception $e) {
// nothing to do
}
} }
} }

13
src/Util/CommonMark/Converter.php

@ -9,6 +9,10 @@ use League\CommonMark\Environment\Environment;
use League\CommonMark\Exception\CommonMarkException; use League\CommonMark\Exception\CommonMarkException;
use League\CommonMark\Extension\Autolink\AutolinkExtension; use League\CommonMark\Extension\Autolink\AutolinkExtension;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\Embed\Bridge\OscaroteroEmbedAdapter;
use League\CommonMark\Extension\Embed\Embed;
use League\CommonMark\Extension\Embed\EmbedExtension;
use League\CommonMark\Extension\Embed\EmbedRenderer;
use League\CommonMark\Extension\Footnote\FootnoteExtension; use League\CommonMark\Extension\Footnote\FootnoteExtension;
use League\CommonMark\Extension\HeadingPermalink\HeadingPermalinkExtension; use League\CommonMark\Extension\HeadingPermalink\HeadingPermalinkExtension;
use League\CommonMark\Extension\SmartPunct\SmartPunctExtension; use League\CommonMark\Extension\SmartPunct\SmartPunctExtension;
@ -16,6 +20,7 @@ use League\CommonMark\Extension\Strikethrough\StrikethroughExtension;
use League\CommonMark\Extension\Table\TableExtension; use League\CommonMark\Extension\Table\TableExtension;
use League\CommonMark\Extension\TableOfContents\TableOfContentsExtension; use League\CommonMark\Extension\TableOfContents\TableOfContentsExtension;
use League\CommonMark\MarkdownConverter; use League\CommonMark\MarkdownConverter;
use League\CommonMark\Renderer\HtmlDecorator;
class Converter class Converter
{ {
@ -33,7 +38,6 @@ class Converter
preg_match_all('/^#+\s.*$/m', $markdown, $matches); preg_match_all('/^#+\s.*$/m', $markdown, $matches);
$headingsCount = count($matches[0]); $headingsCount = count($matches[0]);
// Configure the Environment with all the CommonMark parsers/renderers // Configure the Environment with all the CommonMark parsers/renderers
$config = [ $config = [
'table_of_contents' => [ 'table_of_contents' => [
@ -47,6 +51,11 @@ class Converter
'allowed_protocols' => ['https'], // defaults to ['https', 'http', 'ftp'] 'allowed_protocols' => ['https'], // defaults to ['https', 'http', 'ftp']
'default_protocol' => 'https', // defaults to 'http' 'default_protocol' => 'https', // defaults to 'http'
], ],
'embed' => [
'adapter' => new OscaroteroEmbedAdapter(), // See the "Adapter" documentation below
'allowed_domains' => ['youtube.com', 'twitter.com', 'github.com'],
'fallback' => 'link'
],
]; ];
$environment = new Environment($config); $environment = new Environment($config);
// Add the extensions // Add the extensions
@ -57,6 +66,8 @@ class Converter
// create a custom extension, that handles nostr mentions // create a custom extension, that handles nostr mentions
$environment->addExtension(new NostrSchemeExtension($this->bech32Decoder)); $environment->addExtension(new NostrSchemeExtension($this->bech32Decoder));
$environment->addExtension(new SmartPunctExtension()); $environment->addExtension(new SmartPunctExtension());
$environment->addExtension(new EmbedExtension());
$environment->addRenderer(Embed::class, new HtmlDecorator(new EmbedRenderer(), 'div', ['class' => 'embedded-content']));
$environment->addExtension(new RawImageLinkExtension()); $environment->addExtension(new RawImageLinkExtension());
$environment->addExtension(new AutolinkExtension()); $environment->addExtension(new AutolinkExtension());
if ($headingsCount > 3) { if ($headingsCount > 3) {

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

@ -3,5 +3,7 @@
{{ author.display_name }} {{ author.display_name }}
{% elseif author.name is defined and author.name is not empty %} {% elseif author.name is defined and author.name is not empty %}
{{ author.name }} {{ author.name }}
{% else %}
{{ npub|shortenNpub }}
{% endif %} {% endif %}
</span> </span>

2
templates/components/Molecules/UserFromNpub.html.twig

@ -1,5 +1,5 @@
{% if user %} {% if user %}
<a href="{{ path('author-profile', { npub: npub })}}"><twig:Atoms:NameOrNpub :author="user" /></a> <a href="{{ path('author-profile', { npub: npub })}}"><twig:Atoms:NameOrNpub :author="user" :npub="npub"/></a>
{% else %} {% else %}
<a href="{{ path('author-profile', { npub: npub })}}"><span>{{ npub|shortenNpub }}</span></a> <a href="{{ path('author-profile', { npub: npub })}}"><span>{{ npub|shortenNpub }}</span></a>
{% endif %} {% endif %}

2
templates/components/UserMenu.html.twig

@ -1,6 +1,6 @@
<div class="user-menu" {{ attributes.defaults(stimulus_controller('login')) }}> <div class="user-menu" {{ attributes.defaults(stimulus_controller('login')) }}>
{% if app.user %} {% if app.user %}
<p>Hello, {{ app.user.displayName }}</p> <p>Hello, {{ app.user.name }}</p>
{% if is_granted('ROLE_ADMIN') %} {% if is_granted('ROLE_ADMIN') %}
{# <ul>#} {# <ul>#}
{# <li>#} {# <li>#}

2
templates/pages/article.html.twig

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

32
templates/pages/author.html.twig

@ -2,22 +2,22 @@
{% block body %} {% block body %}
{# {% if author.image is defined %}#} {% if author.image is defined %}
{# <img src="{{ author.image }}" class="avatar" alt="{{ author.name }}" />#} <img src="{{ author.image }}" class="avatar" alt="{{ author.name }}" onerror="this.style.display = 'none'" />
{# {% endif %}#} {% endif %}
<h1><twig:atoms:NameOrNpub :author="author"></twig:atoms:NameOrNpub></h1> <h1><twig:atoms:NameOrNpub :author="author" :npub="npub"></twig:atoms:NameOrNpub></h1>
{% if author.about is defined %} {% if author.about is defined %}
<p class="lede"> <p class="lede">
{{ author.about|linkify|mentionify }} {{ author.about|linkify|mentionify }}
</p> </p>
{% endif %} {% endif %}
{% if relays|length > 0 %} {# {% if relays|length > 0 %}#}
{% for rel in relays %} {# {% for rel in relays %}#}
<p>{{ rel }}</p> {# <p>{{ rel }}</p>#}
{% endfor %} {# {% endfor %}#}
{% endif %} {# {% endif %}#}
{# {% if app.user and app.user.userIdentifier is same as npub %}#} {# {% if app.user and app.user.userIdentifier is same as npub %}#}
@ -52,14 +52,14 @@
{# </div>#} {# </div>#}
{# {% endif %}#} {# {% endif %}#}
{% if nzine %} {# {% if nzine %}#}
<a href="{{ path('nzine_view', {npub: author.npub}) }}">View as N-Zine</a> {# <a href="{{ path('nzine_view', {npub: author.npub}) }}">View as N-Zine</a>#}
<h2>List of indices</h2> {# <h2>List of indices</h2>#}
{% for i in idx %} {# {% for i in idx %}#}
{{ i.title }} {# {{ i.title }}#}
{% endfor %} {# {% endfor %}#}
{% endif %} {# {% endif %}#}
<twig:Organisms:CardList :list="articles" class="article-list"></twig:Organisms:CardList> <twig:Organisms:CardList :list="articles" class="article-list"></twig:Organisms:CardList>
{% endblock %} {% endblock %}

Loading…
Cancel
Save