Browse Source

Add naddr support and static pages, refactor login

imwald
Nuša Pukšič 10 months ago
parent
commit
3087083079
  1. 56
      src/Controller/ArticleController.php
  2. 34
      src/Controller/AuthorController.php
  3. 36
      src/Controller/StaticController.php
  4. 13
      src/Security/NostrAuthenticator.php
  5. 35
      src/Service/NostrClient.php
  6. 12
      src/Twig/Components/Molecules/UserFromNpub.php
  7. 2
      templates/base.html.twig
  8. 9
      templates/components/Footer.html.twig
  9. 8
      templates/components/Molecules/Card.html.twig
  10. 6
      templates/components/SearchComponent.html.twig
  11. 2
      templates/home.html.twig
  12. 2
      templates/pages/article.html.twig
  13. 8
      templates/pages/author.html.twig
  14. 2
      templates/pages/category.html.twig
  15. 34
      templates/static/about.html.twig
  16. 43
      templates/static/pricing.html.twig
  17. 60
      templates/static/roadmap.html.twig
  18. 78
      templates/static/tos.html.twig

56
src/Controller/ArticleController.php

@ -6,6 +6,7 @@ use App\Entity\Article; @@ -6,6 +6,7 @@ use App\Entity\Article;
use App\Enum\KindsEnum;
use App\Form\EditorType;
use App\Service\NostrClient;
use App\Util\Bech32\Bech32Decoder;
use App\Util\CommonMark\Converter;
use Doctrine\ORM\EntityManagerInterface;
use League\CommonMark\Exception\CommonMarkException;
@ -19,6 +20,59 @@ use Symfony\Component\Workflow\WorkflowInterface; @@ -19,6 +20,59 @@ use Symfony\Component\Workflow\WorkflowInterface;
class ArticleController extends AbstractController
{
/**
* @throws InvalidArgumentException|CommonMarkException
* @throws \Exception
*/
#[Route('/article/{naddr}', name: 'article-naddr')]
public function naddr(NostrClient $nostrClient, Bech32Decoder $bech32Decoder, $naddr)
{
// decode naddr
list($hrp, $tlv) = $bech32Decoder->decodeAndParseNostrBech32($naddr);
if ($hrp !== 'naddr') {
throw new \Exception('Invalid naddr');
}
foreach ($tlv as $item) {
// d tag
if ($item['type'] === 0) {
$slug = implode('', array_map('chr', $item['value']));
}
// relays
if ($item['type'] === 1) {
$relays[] = implode('', array_map('chr', $item['value']));
}
// author
if ($item['type'] === 2) {
$str = '';
foreach ($item['value'] as $byte) {
$str .= str_pad(dechex($byte), 2, '0', STR_PAD_LEFT);
}
$author = $str;
}
if ($item['type'] === 3) {
// big-endian integer
$intValue = 0;
foreach ($item['value'] as $byte) {
$intValue = ($intValue << 8) | $byte;
}
$kind = $intValue;
}
}
if ($kind !== KindsEnum::LONGFORM->value) {
throw new \Exception('Not a long form article');
}
$nostrClient->getLongFormFromNaddr($slug, $relays, $author, $kind);
if ($slug) {
return $this->redirectToRoute('article-slug', ['slug' => $slug]);
}
throw new \Exception('No article.');
}
/**
* @throws InvalidArgumentException|CommonMarkException
*/
@ -30,7 +84,7 @@ class ArticleController extends AbstractController @@ -30,7 +84,7 @@ class ArticleController extends AbstractController
// check if an item with same eventId already exists in the db
$repository = $entityManager->getRepository(Article::class);
$articles = $repository->findBy(['slug' => $slug]);
$revisions = count($repository->findBy(['slug' => $slug]));
$revisions = count($articles);
if ($revisions > 1) {
// sort articles by created at date

34
src/Controller/AuthorController.php

@ -6,11 +6,11 @@ namespace App\Controller; @@ -6,11 +6,11 @@ namespace App\Controller;
use App\Entity\Article;
use App\Entity\Event;
use App\Entity\Nzine;
use App\Enum\KindsEnum;
use App\Service\NostrClient;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Cache\InvalidArgumentException;
use swentel\nostr\Key\Key;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@ -21,18 +21,19 @@ class AuthorController extends AbstractController @@ -21,18 +21,19 @@ class AuthorController extends AbstractController
* @throws \Exception
* @throws InvalidArgumentException
*/
#[Route('/p/{npub}', name: 'author-profile')]
#[Route('/p/{npub}', name: 'author-profile', requirements: ['npub' => '^npub1.*'])]
public function index($npub, EntityManagerInterface $entityManager, NostrClient $client): Response
{
$keys = new Key();
$meta = $client->getNpubMetadata($npub);
$author = (array) json_decode($meta->content ?? '{}');
// $client->getNpubLongForm($npub);
$list = $entityManager->getRepository(Article::class)->findBy(['pubkey' => $npub, 'kind' => KindsEnum::LONGFORM], ['createdAt' => 'DESC']);
$pubkey = $keys->convertToHex($npub);
$list = $entityManager->getRepository(Article::class)->findBy(['pubkey' => $pubkey, 'kind' => KindsEnum::LONGFORM], ['createdAt' => 'DESC']);
// deduplicate by slugs
$articles = [];
@ -42,19 +43,32 @@ class AuthorController extends AbstractController @@ -42,19 +43,32 @@ class AuthorController extends AbstractController
}
}
$indices = $entityManager->getRepository(Event::class)->findBy(['pubkey' => $npub, 'kind' => KindsEnum::PUBLICATION_INDEX]);
$indices = $entityManager->getRepository(Event::class)->findBy(['pubkey' => $pubkey, 'kind' => KindsEnum::PUBLICATION_INDEX]);
$nzines = $entityManager->getRepository(Nzine::class)->findBy(['editor' => $npub]);
// $nzines = $entityManager->getRepository(Nzine::class)->findBy(['editor' => $pubkey]);
$nzine = $entityManager->getRepository(Nzine::class)->findBy(['npub' => $npub]);
// $nzine = $entityManager->getRepository(Nzine::class)->findBy(['npub' => $npub]);
return $this->render('Pages/author.html.twig', [
'author' => $author,
'npub' => $npub,
'articles' => $articles,
'nzine' => $nzine,
'nzines' => $nzines,
'nzine' => null,
'nzines' => null,
'idx' => $indices
]);
}
/**
* @throws \Exception
*/
#[Route('/p/{pubkey}', name: 'author-redirect')]
public function authorRedirect($pubkey): Response
{
$keys = new Key();
$npub = $keys->convertPublicKeyToBech32($pubkey);
return $this->redirectToRoute('author-profile', ['npub' => $npub]);
}
}

36
src/Controller/StaticController.php

@ -0,0 +1,36 @@ @@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class StaticController extends AbstractController
{
#[Route('/about')]
public function about(): Response
{
return $this->render('static/about.html.twig');
}
#[Route('/roadmap')]
public function roadmap(): Response
{
return $this->render('static/roadmap.html.twig');
}
#[Route('/pricing')]
public function pricing(): Response
{
return $this->render('static/pricing.html.twig');
}
#[Route('/tos')]
public function tos(): Response
{
return $this->render('static/tos.html.twig');
}
}

13
src/Security/NostrAuthenticator.php

@ -4,6 +4,7 @@ namespace App\Security; @@ -4,6 +4,7 @@ namespace App\Security;
use App\Entity\Event;
use Mdanter\Ecc\Crypto\Signature\SchnorrSignature;
use swentel\nostr\Key\Key;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
@ -44,13 +45,15 @@ class NostrAuthenticator extends AbstractAuthenticator implements InteractiveAut @@ -44,13 +45,15 @@ class NostrAuthenticator extends AbstractAuthenticator implements InteractiveAut
if (time() > $event->getCreatedAt() + 60) {
throw new AuthenticationException('Expired');
}
// $validity = (new SchnorrSignature())->verify($event->getPubkey(), $event->getSig(), $event->getId());
// if (!$validity) {
// throw new AuthenticationException('Invalid Authorization header');
// }
$validity = (new SchnorrSignature())->verify($event->getPubkey(), $event->getSig(), $event->getId());
if (!$validity) {
throw new AuthenticationException('Invalid Authorization header');
}
$key = new Key();
return new SelfValidatingPassport(
new UserBadge($event->getPubkey())
new UserBadge($key->convertPublicKeyToBech32($event->getPubkey()))
);
}

35
src/Service/NostrClient.php

@ -206,6 +206,41 @@ class NostrClient @@ -206,6 +206,41 @@ class NostrClient
public function getLongFormFromNaddr($slug, $relayList, $author, $kind): void
{
$subscription = new Subscription();
$subscriptionId = $subscription->setId();
$filter = new Filter();
$filter->setKinds([$kind]);
$filter->setAuthors([$author]);
$filter->setTag('#d', [$slug]);
$requestMessage = new RequestMessage($subscriptionId, [$filter]);
if (empty($relayList)) {
$relays = $this->defaultRelaySet;
} else {
$relays = new RelaySet();
$relays->addRelay(new Relay($relayList[0]));
}
$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

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

@ -4,12 +4,14 @@ namespace App\Twig\Components\Molecules; @@ -4,12 +4,14 @@ namespace App\Twig\Components\Molecules;
use App\Service\NostrClient;
use Psr\Cache\InvalidArgumentException;
use swentel\nostr\Key\Key;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
final class UserFromNpub
{
public string $pubkey;
public string $npub;
public ?array $user = null;
@ -17,14 +19,16 @@ final class UserFromNpub @@ -17,14 +19,16 @@ final class UserFromNpub
{
}
public function mount(string $npub): void
public function mount(string $pubkey): void
{
$this->npub = $npub;
$keys = new Key();
$this->pubkey = $pubkey;
$this->npub = $keys->convertPublicKeyToBech32($pubkey);
try {
$this->user = $this->redisCache->get('user_' . $npub, function () use ($npub) {
$this->user = $this->redisCache->get('user_' . $this->npub, function () {
try {
$meta = $this->nostrClient->getNpubMetadata($npub);
$meta = $this->nostrClient->getNpubMetadata($this->npub);
return (array) json_decode($meta->content);
} catch (InvalidArgumentException|\Exception) {
return null;

2
templates/base.html.twig

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-theme="{{ app.session.get('theme', 'dark') }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">

9
templates/components/Footer.html.twig

@ -1 +1,8 @@ @@ -1 +1,8 @@
<p>{{ "now"|date("Y") }} Newsroom</p>
<div class="footer-links">
<a href="{{ path('app_static_about') }}">About</a> -
<a href="{{ path('app_static_roadmap') }}">Roadmap</a> -
{# <a href="{{ path('app_static_pricing') }}">Pricing</a> -#}
<a href="{{ path('app_static_tos') }}">Terms of service</a>
{# <a href="https://geyser.fund/project/newsroom?hero=nuapuki">Decent Newsroom - Geyser fund</a>#}
</div>
<p>{{ "now"|date("Y") }} Decent Newsroom - Preprint</p>

8
templates/components/Molecules/Card.html.twig

@ -7,16 +7,16 @@ @@ -7,16 +7,16 @@
{% endif %}
</div>
<div class="card-body">
<small>{{ article.createdAt|date('F j') }}</small>
{# <small>{{ article.createdAt|date('F j') }}</small>#}
<h2 class="card-title">{{ article.title }}</h2>
<p class="lede">
{{ article.summary }}
</p>
</div>
</{{ tag }}>
<div class="card card-footer">
<a href="{{ path('author-profile', { npub: article.pubkey })}}"><twig:Molecules:UserFromNpub npub="{{ article.pubkey }}" /></a>
</div>
{#<div class="card card-footer">#}
{# <a href="{{ path('author-profile', { npub: article.pubkey })}}"><twig:Molecules:UserFromNpub pubkey="{{ article.pubkey }}" /></a>#}
{#</div>#}
{% endif %}
{% if user is defined %}
<{{ tag }} {{ attributes }}>

6
templates/components/SearchComponent.html.twig

@ -7,10 +7,10 @@ @@ -7,10 +7,10 @@
/>
<button type="submit" data-action="live#$render"><twig:ux:icon name="iconoir:search" class="icon" /></button>
</label>
<!-- -->
<!--
<div style="text-align: right">
<small class="help-text"><em>Powered by Silk</em></small>
</div>
</div> -->
<!-- Loading Indicator -->
<div style="text-align: center">
@ -20,7 +20,7 @@ @@ -20,7 +20,7 @@
<!-- Results -->
{% if this.results is not empty %}
<twig:Organisms:CardList :list="this.results" />
<twig:Organisms:CardList :list="this.results" class="article-list" />
{% elseif this.query is not empty %}
<p><small>{{ 'text.noResults'|trans }}</small></p>
{% endif %}

2
templates/home.html.twig

@ -7,7 +7,7 @@ @@ -7,7 +7,7 @@
{# content #}
{# replace list with featured #}
<twig:Organisms:CardList :list="list" />
<twig:Organisms:CardList :list="list" class="article-list"/>
{% endblock %}
{% block aside %}

2
templates/pages/article.html.twig

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

8
templates/pages/author.html.twig

@ -2,9 +2,9 @@ @@ -2,9 +2,9 @@
{% block body %}
{% if author.image is defined %}
<img src="{{ author.image }}" alt="{{ author.name }}" />
{% endif %}
{# {% if author.image is defined %}#}
{# <img src="{{ author.image }}" class="avatar" alt="{{ author.name }}" />#}
{# {% endif %}#}
<h1><twig:atoms:NameOrNpub :author="author"></twig:atoms:NameOrNpub></h1>
{% if author.about is defined %}
@ -54,7 +54,7 @@ @@ -54,7 +54,7 @@
{% endfor %}
{% endif %}
<twig:Organisms:CardList :list="articles"></twig:Organisms:CardList>
<twig:Organisms:CardList :list="articles" class="article-list"></twig:Organisms:CardList>
{% endblock %}
{% block aside %}

2
templates/pages/category.html.twig

@ -4,7 +4,7 @@ @@ -4,7 +4,7 @@
{% endblock %}
{% block body %}
<twig:Organisms:CardList :list="list" />
<twig:Organisms:CardList :list="list" class="article-list" />
{% endblock %}
{% block aside %}

34
templates/static/about.html.twig

@ -0,0 +1,34 @@ @@ -0,0 +1,34 @@
{% extends 'base.html.twig' %}
{% block nav %}
{% endblock %}
{% block body %}
<h1>About Decent Newsroom</h1>
<h2>Rebuilding Trust in Journalism</h2>
<p>The internet, and especially social media, made news instant and abundant—but also cheap and unreliable. The value of simply reporting what happened and where has dropped to zero, buried under waves of misinformation, clickbait, and AI-generated noise. Worse, sorting truth from falsehood now costs more than spreading lies.</p>
<p>Decent Newsroom is our answer to this crisis. We are building a curated, decentralized magazine featuring high-quality, long-form journalism published on Nostr.</p>
<h2>How It Works</h2>
<dl>
<dt><strong>Curated Excellence</strong></dt>
<dd>We showcase a selection of featured articles.</dd>
<dt><strong>Indexed & Searchable</strong></dt>
<dd>Every article in Decent Newsroom is easily discoverable, improving access for readers and exposure for authors.</dd>
<dd>Simple search is available to logged-in users.</dd>
<dd>Semantic search is in development.</dd>
<dt><strong>Open to Writers & Publishers</strong> <span class="badge">Soon</span></dt>
<dd>Content creators can request indexing for their work, making it searchable and eligible for inclusion.</dd>
</dl>
<h2>Why It Matters</h2>
<p>The age of the newsroom isn’t over—it’s just evolving. We believe in bringing back editorial standards, collaboration, and high-value reporting in a decentralized way. Decent Newsroom is here to cut through the noise and rebuild trust in digital journalism.</p>
<br>
<p>To support us and get early access to the future of publishing, visit our crowdfunding page <a href="https://geyser.fund/project/newsroom" target="_blank">Geyser fund - Newsroom</a>.</p>
{% endblock %}

43
templates/static/pricing.html.twig

@ -0,0 +1,43 @@ @@ -0,0 +1,43 @@
{% extends 'base.html.twig' %}
{% block nav %}
{% endblock %}
{% block body %}
<h1>Pricing</h1>
<p>Choose a plan that fits your publishing needs.</p>
<div class="price-list">
<div class="card">
<h3>Scoop Seeker</h3>
<p class="price">Free</p>
<ul class="features">
<li>Read all the articles featured in the Newsroom magazine</li>
</ul>
<button disabled="disabled">Get Started</button>
</div>
<div class="card">
<h3>Publisher</h3>
<p class="price">5.000 sats/month</p>
<ul class="features">
<li>Submit indexing requests for priority indexing</li>
<li>Access to built-in content editor</li>
<li>Content search</li>
</ul>
<button disabled="disabled">Subscribe</button>
</div>
<div class="card">
<h3>Curator</h3>
<p class="price">15.000 sats/month</p>
<ul class="features">
<li>Create and manage your own magazines</li>
<li>Advanced discovery tools</li>
<li>Featured magazine placement</li>
</ul>
<button disabled="disabled">Subscribe</button>
</div>
</div>
{% endblock %}

60
templates/static/roadmap.html.twig

@ -0,0 +1,60 @@ @@ -0,0 +1,60 @@
{% extends 'base.html.twig' %}
{% block nav %}
{% endblock %}
{% block body %}
<h1>Roadmap: The Future of Decent Newsroom</h1>
<p>We are building a decentralized, high-value journalism platform step by step. Each phase unlocks new features, empowering writers, publishers, and readers to engage with quality, independent reporting in a whole new way.</p>
<div class="roadmap-section">
<h2>v0.1.0 – <strong>Preprint</strong> <span class="badge badge__secondary"><small>Current</small></span></h2>
<div class="milestone">
<h3>A first glimpse into the future of journalism</h3>
<p>This early version of First Edition Newsroom is all about discovery. We’re launching a sample magazine to showcase what’s possible.</p>
<ul>
<li>Hand-curated, high-quality articles in magazine format</li>
<li>Full-text search to find the stories that matter to you</li>
<li>No content editor or user submissions yet</li>
</ul>
<p>Preprint is just the beginning.</p>
</div>
</div>
<div class="roadmap-section">
<h2>v0.2.0 – <strong>First Edition</strong> <span class="badge badge__secondary"><small>Soon</small></span></h2>
<div class="milestone">
<h3>The real newsroom takes shape. Writers publish. Readers support. Journalism thrives.</h3>
<p>This milestone version introduces key features that bring Decent Newsroom to life:</p>
<ul>
<li>Zaps</li>
<li>Search and indexing requests enabled – writers and publishers can request inclusion</li>
<li>Payable subscriptions and indexing via Lightning invoices</li>
<li>Built-in content editor – publishers can create directly on the platform</li>
</ul>
<p>With First Edition, journalism becomes more than just content — it becomes an ecosystem.</p>
<p></p>
</div>
</div>
<div class="roadmap-section">
<h2>v0.3.0 – <strong>Newsstand</strong></h2>
<div class="milestone">
<h3>Curate. Publish. Own your newsroom.</h3>
<p>At this stage, Decent Newsroom evolves into a true platform for independent journalism. Not only can creators publish, but they can also curate their own magazines.</p>
<ul>
<li>Users can create and manage their own curated publications</li>
<li>Collaborative publishing tools – editors, writers, and photographers can work together</li>
<li>Expanded discovery and personalized reading experiences</li>
</ul>
<p>Your news, your way. Newsstand transforms First Edition into a powerful hub for decentralized journalism, driven by the community.</p>
</div>
</div>
<h2>The Journey Ahead</h2>
<p>Every step we take brings us closer to a new era of independent, decentralized journalism. Decent Newsroom isn’t just another publishing platform — it’s a movement to rebuild trust, quality, and collaboration in media.</p>
<p>First Edition is coming soon. Be part of it.</p>
<br>
<p>To support us and get early access to the future of publishing, visit our crowdfunding page <a href="https://geyser.fund/project/newsroom" target="_blank">Geyser fund - Newsroom</a>.</p>
{% endblock %}

78
templates/static/tos.html.twig

@ -0,0 +1,78 @@ @@ -0,0 +1,78 @@
{% extends 'base.html.twig' %}
{% block nav %}
{% endblock %}
{% block body %}
<h1>Terms of service</h1>
<p>Effective Date: April 1, 2025</p>
<p>Welcome to Decent Newsroom. These Terms of Service ("Terms") govern your access to and use of our platform,
including the ability to publish, index, search, and subscribe to content. By using our services, you agree to these Terms.</p>
<ol>
<li>
<h2>Introduction</h2>
<p>Decent Newsroom is a platform for discovering, curating, and publishing journalism on Nostr.
We provide tools for searching, indexing, and managing long-form content.</p>
<p>Decent Newsroom also publishes an open-access magazine, Newsroom magazine, comprised of a curated collection of english language articles.</p>
</li>
<li>
<h2>User Accounts & Responsibilities</h2>
<p>We do not store user credentials, emails, or passwords. Authentication is done via Nostr public keys (npubs).</p>
{# <p>Some data is stored and linked to your npub, including:</p>#}
{# <ul>#}
{# <li>Subscription status and payment history (if applicable)</li>#}
{# <li>Indexing requests</li>#}
{# </ul>#}
{# <p>Content submitted for indexing may remain publicly discoverable even if a user stops using the platform.</p>#}
{# #}
</li>
<li>
<h2>Content & Ownership</h2>
<p>The platform operates on the basis of open access content.</p>
<p>We do not endorse or verify third-party content and are not responsible for its accuracy.</p>
<p>We reserve the right to include and exclude content from the indexer at our own discretion.</p>
{# <p>By requesting indexing, you agree that your content may be made publicly discoverable unless otherwise specified.</p>#}
</li>
<li>
<h2>Payments & Subscriptions</h2>
<p>We use Lightning invoices for processing payments.</p>
<p>Some features, such as indexing requests, high-volume search, and premium content, require a paid subscription.</p>
<p>Payments are generally non-refundable, except in cases of accidental charges or platform malfunctions.</p>
</li>
<li>
<h2>Disclaimer of Warranties</h2>
<p>The platform is provided "as is" and "as available" without warranties of any kind.</p>
<p>We do not guarantee uninterrupted service, accuracy, or security of content.</p>
</li>
<li>
<h2>Limitation of Liability</h2>
<p>We are not responsible for any direct, indirect, or incidental damages arising from your use of the platform.</p>
</li>
<li>
<h2>Termination & Suspension</h2>
<p>We reserve the right to suspend or terminate access if users violate these Terms.</p>
<p>Users can request deletion of their data at any time, though some content may remain indexed based on prior agreements.</p>
</li>
<li>
<h2>Changes to These Terms</h2>
<p>We may update these Terms periodically. Continued use of the platform after modifications constitutes acceptance of the new Terms.</p>
</li>
</ol>
{% endblock %}
Loading…
Cancel
Save