Browse Source

NIP-05

imwald
Nuša Pukšič 3 months ago
parent
commit
2694d47dae
  1. 1
      assets/app.js
  2. 29
      assets/styles/03-components/nip05-badge.css
  3. 197
      documentation/nip05-badge-component.md
  4. 23
      src/Controller/AuthorController.php
  5. 215
      src/Service/Nip05VerificationService.php
  6. 161
      src/Service/RedisCacheService.php
  7. 76
      src/Twig/Components/Atoms/Nip05Badge.php
  8. 5
      src/Twig/Components/Organisms/Comments.php
  9. 8
      templates/components/Atoms/Nip05Badge.html.twig
  10. 3
      templates/components/event_card.html.twig
  11. 197
      templates/pages/author-about.html.twig
  12. 22
      templates/pages/author-media.html.twig
  13. 26
      templates/pages/author.html.twig
  14. 300
      tests/NIPs/NIP-05.feature
  15. 2
      translations/messages.en.yaml

1
assets/app.js

@ -29,6 +29,7 @@ import './styles/03-components/spinner.css'; @@ -29,6 +29,7 @@ import './styles/03-components/spinner.css';
import './styles/03-components/a2hs.css';
import './styles/03-components/og.css';
import './styles/03-components/nostr-previews.css';
import './styles/03-components/nip05-badge.css';
import './styles/03-components/picture-event.css';
import './styles/03-components/search.css';
import './styles/03-components/image-upload.css';

29
assets/styles/03-components/nip05-badge.css

@ -0,0 +1,29 @@ @@ -0,0 +1,29 @@
/* NIP-05 Badge Component Styles */
.nip05-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.5rem;
background-color: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
border-radius: 0.375rem;
font-size: 0.875rem;
color: rgb(22, 163, 74);
font-weight: 500;
}
.nip05-badge.verified {
cursor: help;
}
.nip05-identifier {
white-space: nowrap;
}
@media (max-width: 640px) {
.nip05-identifier {
max-width: 150px;
}
}

197
documentation/nip05-badge-component.md

@ -0,0 +1,197 @@ @@ -0,0 +1,197 @@
# NIP-05 Badge Component
## Overview
The NIP-05 Badge component is a live Twig component that verifies NIP-05 identifiers (DNS-based internet identifiers for Nostr keys) and displays them as a verified badge when the verification succeeds.
## Features
- ✅ **Automatic verification**: Fetches and validates `.well-known/nostr.json` from the specified domain
- ✅ **Security compliant**: Follows NIP-05 security constraints (no redirect following)
- ✅ **Cached results**: Verification results are cached for 1 hour to reduce network requests
- ✅ **Relay discovery**: Extracts and stores relay information when available
- ✅ **Display formatting**: Automatically formats root identifiers (`_@domain.com` → `domain.com`)
- ✅ **Graceful failures**: Shows nothing when verification fails (no badge displayed)
- ✅ **Character validation**: Only accepts valid NIP-05 local-part characters (a-z0-9-_.)
## Usage
### Basic Usage
```twig
{# In any Twig template #}
<twig:Atoms:Nip05Badge
nip05="{{ author.nip05 }}"
pubkeyHex="{{ author.pubkey }}"
/>
```
### With Author Metadata
```twig
<div class="author-info">
<strong>{{ author.name ?? 'Anonymous' }}</strong>
{% if author.nip05 is defined and author.pubkey is defined %}
<twig:Atoms:Nip05Badge
nip05="{{ author.nip05 }}"
pubkeyHex="{{ author.pubkey }}"
/>
{% endif %}
</div>
```
### In a Card Component
```twig
<div class="profile-card">
<img src="{{ author.image }}" alt="{{ author.name }}" />
<div class="profile-info">
<h3>{{ author.name }}</h3>
<twig:Atoms:Nip05Badge
nip05="{{ author.nip05 }}"
pubkeyHex="{{ author.pubkey }}"
/>
</div>
</div>
```
## Props
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| `nip05` | `string` | Yes | The NIP-05 identifier (e.g., "bob@example.com") |
| `pubkeyHex` | `string` | Yes | The public key in hex format (64 characters) |
## Verification Process
The component performs the following verification steps:
1. **Validates identifier format**: Checks that the local part contains only `a-z0-9-_.`
2. **Splits identifier**: Extracts local part and domain from the identifier
3. **Fetches well-known document**: Makes a GET request to `https://<domain>/.well-known/nostr.json?name=<local-part>`
4. **Rejects redirects**: Any HTTP redirect causes verification to fail (NIP-05 security requirement)
5. **Validates response**: Checks for valid JSON with required `names` field
6. **Matches public key**: Compares the returned pubkey with the expected pubkey
7. **Validates hex format**: Ensures pubkey is in hex format (not npub)
8. **Extracts relays**: Optionally retrieves relay information if present
## Display Behavior
### When Verified ✅
Shows a green badge with a checkmark icon and the identifier:
- Regular identifier: `bob@example.com`
- Root identifier: `_@example.com` displays as `example.com`
- Tooltip shows relay count when available
### When Not Verified ❌
Shows nothing (no badge rendered)
## Cache Behavior
- Verification results are cached for **1 hour** in Redis
- Cache key format: `nip05_{md5(identifier)}`
- Failed verifications are also cached to prevent repeated failed requests
## Security Features
- ✅ **No redirect following**: HTTP redirects are blocked per NIP-05 spec
- ✅ **Timeout protection**: 5-second timeout on HTTP requests
- ✅ **Format validation**: Strict validation of identifier format
- ✅ **Hex-only pubkeys**: Rejects npub format keys
- ✅ **Case-insensitive matching**: Handles case variations properly
## Styling
The component includes built-in styles that can be customized:
```css
.nip05-badge {
/* Green badge with checkmark */
background-color: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
color: rgb(22, 163, 74);
}
.nip05-identifier {
/* Truncates long identifiers */
max-width: 200px;
text-overflow: ellipsis;
}
```
## Service Layer
The `Nip05VerificationService` can also be used independently:
```php
use App\Service\Nip05VerificationService;
class MyController
{
public function __construct(
private Nip05VerificationService $nip05Service
) {}
public function verifyIdentifier(string $nip05, string $pubkey): void
{
$result = $this->nip05Service->verify($nip05, $pubkey);
if ($result['verified']) {
// Identifier is verified!
$relays = $result['relays']; // Available relay URLs
}
}
}
```
## Testing
Comprehensive test scenarios are defined in `tests/NIPs/NIP-05.feature` covering:
- Successful verification flows
- User discovery
- Validation rules
- Security constraints
- Edge cases and error handling
## Examples
### Valid Identifiers
- `bob@example.com`
- `alice_123@example.com`
- `user-name@example.com`
- `test.user@example.com`
- `_@example.com` (root identifier)
### Invalid Identifiers
- `bob+tag@example.com` (+ not allowed)
- `bob space@example.com` (spaces not allowed)
- `bob#hash@example.com` (# not allowed)
- `npub1...` (not an identifier format)
## Troubleshooting
### Badge not showing
- Check that both `nip05` and `pubkeyHex` props are provided
- Verify the pubkey is in **hex format** (64 characters), not npub format
- Check logs for verification failures
- Ensure the domain serves `.well-known/nostr.json` with CORS headers
### CORS Issues
The server must return:
```
Access-Control-Allow-Origin: *
```
### Verification failures
Check application logs for specific error messages:
- Invalid identifier format
- Missing names field
- Pubkey mismatch
- Invalid hex format
- Network timeout
## Related Documentation
- [NIP-05 Specification](https://github.com/nostr-protocol/nips/blob/master/05.md)
- [Test Definitions](../../../tests/NIPs/NIP-05.feature)

23
src/Controller/AuthorController.php

@ -63,6 +63,29 @@ class AuthorController extends AbstractController @@ -63,6 +63,29 @@ class AuthorController extends AbstractController
]);
}
/**
* @throws Exception
*/
#[Route('/p/{npub}/about', name: 'author-about', requirements: ['npub' => '^npub1.*'])]
public function about($npub, RedisCacheService $redisCacheService): Response
{
$keys = new Key();
$pubkey = $keys->convertToHex($npub);
// Get metadata with raw event for debugging
$profileData = $redisCacheService->getMetadataWithRawEvent($npub);
$author = $profileData['metadata'];
$rawEvent = $profileData['rawEvent'];
return $this->render('pages/author-about.html.twig', [
'author' => $author,
'npub' => $npub,
'pubkey' => $pubkey,
'rawEvent' => $rawEvent,
'is_author_profile' => true,
]);
}
/**
* @throws Exception
*/

215
src/Service/Nip05VerificationService.php

@ -0,0 +1,215 @@ @@ -0,0 +1,215 @@
<?php
namespace App\Service;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
readonly class Nip05VerificationService
{
private const int CACHE_TTL = 3600; // 1 hour
private const int REQUEST_TIMEOUT = 5; // 5 seconds
public function __construct(
private CacheInterface $redisCache,
private LoggerInterface $logger
) {
}
/**
* Verify a NIP-05 identifier against a public key
*
* @param string $nip05 The NIP-05 identifier (e.g., "bob@example.com")
* @param string $pubkeyHex The public key in hex format
* @return array{verified: bool, relays: array<string>}
*/
public function verify(string $nip05, string $pubkeyHex): array
{
// Validate the identifier format
if (!$this->isValidIdentifier($nip05)) {
$this->logger->warning('Invalid NIP-05 identifier format', ['nip05' => $nip05]);
return ['verified' => false, 'relays' => []];
}
// Split identifier into local part and domain
[$localPart, $domain] = $this->splitIdentifier($nip05);
// Create cache key
$cacheKey = 'nip05_' . md5($nip05);
try {
return $this->redisCache->get($cacheKey, function (ItemInterface $item) use ($localPart, $domain, $pubkeyHex, $nip05) {
$item->expiresAfter(self::CACHE_TTL);
$wellKnownUrl = "https://{$domain}/.well-known/nostr.json?name=" . urlencode(strtolower($localPart));
// Fetch the well-known document
$result = $this->fetchWellKnown($wellKnownUrl);
if (!$result['success']) {
return ['verified' => false, 'relays' => []];
}
$data = $result['data'];
// Check if names field exists
if (!isset($data['names'])) {
$this->logger->warning('Missing names field in well-known response', ['nip05' => $nip05]);
return ['verified' => false, 'relays' => []];
}
// Check if the name exists and matches
$normalizedLocalPart = strtolower($localPart);
if (!isset($data['names'][$normalizedLocalPart])) {
$this->logger->info('Name not found in well-known response', [
'nip05' => $nip05,
'localPart' => $normalizedLocalPart
]);
return ['verified' => false, 'relays' => []];
}
$returnedPubkey = $data['names'][$normalizedLocalPart];
// Validate hex format
if (!$this->isValidHexPubkey($returnedPubkey)) {
$this->logger->warning('Invalid pubkey format in well-known response', [
'nip05' => $nip05,
'pubkey' => $returnedPubkey
]);
return ['verified' => false, 'relays' => []];
}
// Check if pubkeys match
if (strtolower($returnedPubkey) !== strtolower($pubkeyHex)) {
$this->logger->info('Pubkey mismatch in NIP-05 verification', [
'nip05' => $nip05,
'expected' => $pubkeyHex,
'received' => $returnedPubkey
]);
return ['verified' => false, 'relays' => []];
}
// Extract relay information if available
$relays = [];
if (isset($data['relays'][$returnedPubkey]) && is_array($data['relays'][$returnedPubkey])) {
$relays = $data['relays'][$returnedPubkey];
}
$this->logger->info('NIP-05 verification successful', [
'nip05' => $nip05,
'pubkey' => $pubkeyHex,
'relays' => count($relays)
]);
return ['verified' => true, 'relays' => $relays];
});
} catch (\Exception $e) {
$this->logger->error('Error during NIP-05 verification', [
'nip05' => $nip05,
'error' => $e->getMessage()
]);
return ['verified' => false, 'relays' => []];
}
}
/**
* Fetch and parse the well-known nostr.json document
*/
private function fetchWellKnown(string $url): array
{
$context = stream_context_create([
'http' => [
'timeout' => self::REQUEST_TIMEOUT,
'follow_location' => 0, // Do NOT follow redirects (NIP-05 security requirement)
'ignore_errors' => true,
'header' => 'Accept: application/json'
]
]);
$response = @file_get_contents($url, false, $context);
// Check for redirects in response headers
if (isset($http_response_header)) {
foreach ($http_response_header as $header) {
if (preg_match('/^HTTP\/\d\.\d\s+(301|302|303|307|308)/', $header)) {
$this->logger->warning('NIP-05 verification rejected due to redirect', ['url' => $url]);
return ['success' => false, 'data' => null];
}
}
}
if ($response === false) {
$this->logger->warning('Failed to fetch well-known document', ['url' => $url]);
return ['success' => false, 'data' => null];
}
$data = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$this->logger->warning('Invalid JSON in well-known response', [
'url' => $url,
'error' => json_last_error_msg()
]);
return ['success' => false, 'data' => null];
}
return ['success' => true, 'data' => $data];
}
/**
* Validate NIP-05 identifier format
* Local part must contain only: a-z0-9-_.
*/
private function isValidIdentifier(string $identifier): bool
{
if (!str_contains($identifier, '@')) {
return false;
}
[$localPart, $domain] = explode('@', $identifier, 2);
// Validate local part (case-insensitive a-z0-9-_.)
if (!preg_match('/^[a-zA-Z0-9\-_.]+$/', $localPart)) {
return false;
}
// Validate domain (basic check)
if (empty($domain) || !str_contains($domain, '.')) {
return false;
}
return true;
}
/**
* Split identifier into local part and domain
*/
private function splitIdentifier(string $identifier): array
{
return explode('@', $identifier, 2);
}
/**
* Validate that the pubkey is in hex format (not npub)
*/
private function isValidHexPubkey(string $pubkey): bool
{
// Must be 64 character hex string
return preg_match('/^[0-9a-fA-F]{64}$/', $pubkey) === 1;
}
/**
* Format identifier for display
* "_@domain.com" should be displayed as "domain.com"
*/
public function formatForDisplay(string $nip05): string
{
if (str_starts_with($nip05, '_@')) {
return substr($nip05, 2);
}
return $nip05;
}
}

161
src/Service/RedisCacheService.php

@ -34,16 +34,72 @@ readonly class RedisCacheService @@ -34,16 +34,72 @@ readonly class RedisCacheService
return $this->redisCache->get($cacheKey, function (ItemInterface $item) use ($npub) {
$item->expiresAfter(3600); // 1 hour, adjust as needed
try {
$meta = $this->nostrClient->getNpubMetadata($npub);
$rawEvent = $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);
$rawEvent = new \stdClass();
$rawEvent->content = json_encode([
'name' => substr($npub, 0, 8) . '…' . substr($npub, -4)
]);
$rawEvent->tags = [];
}
// Parse content as JSON
$contentData = json_decode($rawEvent->content ?? '{}');
if (!$contentData) {
$contentData = new \stdClass();
}
// Fields that should be collected as arrays when multiple values exist
$arrayFields = ['nip05', 'lud16', 'lud06'];
$arrayCollectors = [];
// Parse tags and merge/override content data
// Common metadata tags: name, about, picture, banner, nip05, lud16, website, etc.
$tags = $rawEvent->tags ?? [];
foreach ($tags as $tag) {
if (is_array($tag) && count($tag) >= 2) {
$tagName = $tag[0];
// Check if this field should be collected as an array
if (in_array($tagName, $arrayFields)) {
if (!isset($arrayCollectors[$tagName])) {
$arrayCollectors[$tagName] = [];
}
// Collect all values from position 1 onwards (tag can have multiple values)
for ($i = 1; $i < count($tag); $i++) {
$arrayCollectors[$tagName][] = $tag[$i];
}
} else {
// Override content field with tag value (first occurrence wins for non-array fields)
// For non-array fields, only use the first value (tag[1])
if (!isset($contentData->$tagName) && isset($tag[1])) {
$contentData->$tagName = $tag[1];
}
}
}
}
// Merge array collectors into content data
foreach ($arrayCollectors as $fieldName => $values) {
// Remove duplicates
$values = array_unique($values);
$contentData->$fieldName = $values;
}
$this->logger->info('Metadata:', ['meta' => json_encode($meta)]);
return json_decode($meta->content);
// If content had a single value for an array field but no tags, convert to array
foreach ($arrayFields as $fieldName) {
if (isset($contentData->$fieldName) && !is_array($contentData->$fieldName)) {
$contentData->$fieldName = [$contentData->$fieldName];
}
}
$this->logger->info('Metadata (with tags):', [
'meta' => json_encode($contentData),
'tags' => json_encode($tags)
]);
return $contentData;
});
} catch (InvalidArgumentException $e) {
$this->logger->error('Error getting user data.', ['exception' => $e]);
@ -53,6 +109,97 @@ readonly class RedisCacheService @@ -53,6 +109,97 @@ readonly class RedisCacheService
}
}
/**
* Get metadata with raw event for debugging purposes.
*
* @param string $npub
* @return array{metadata: \stdClass, rawEvent: \stdClass}
*/
public function getMetadataWithRawEvent(string $npub): array
{
$cacheKey = '0_with_raw_' . $npub;
try {
return $this->redisCache->get($cacheKey, function (ItemInterface $item) use ($npub) {
$item->expiresAfter(3600); // 1 hour, adjust as needed
try {
$rawEvent = $this->nostrClient->getNpubMetadata($npub);
} catch (\Exception $e) {
$this->logger->error('Error getting user data.', ['exception' => $e]);
$rawEvent = new \stdClass();
$rawEvent->content = json_encode([
'name' => substr($npub, 0, 8) . '…' . substr($npub, -4)
]);
$rawEvent->tags = [];
}
// Parse content as JSON
$contentData = json_decode($rawEvent->content ?? '{}');
if (!$contentData) {
$contentData = new \stdClass();
}
// Fields that should be collected as arrays when multiple values exist
$arrayFields = ['nip05', 'lud16', 'lud06'];
$arrayCollectors = [];
// Parse tags and merge/override content data
$tags = $rawEvent->tags ?? [];
foreach ($tags as $tag) {
if (is_array($tag) && count($tag) >= 2) {
$tagName = $tag[0];
// Check if this field should be collected as an array
if (in_array($tagName, $arrayFields)) {
if (!isset($arrayCollectors[$tagName])) {
$arrayCollectors[$tagName] = [];
}
// Collect all values from position 1 onwards (tag can have multiple values)
for ($i = 1; $i < count($tag); $i++) {
$arrayCollectors[$tagName][] = $tag[$i];
}
} else {
// Override content field with tag value (first occurrence wins for non-array fields)
// For non-array fields, only use the first value (tag[1])
if (!isset($contentData->$tagName) && isset($tag[1])) {
$contentData->$tagName = $tag[1];
}
}
}
}
// Merge array collectors into content data
foreach ($arrayCollectors as $fieldName => $values) {
// Remove duplicates
$values = array_unique($values);
$contentData->$fieldName = $values;
}
// If content had a single value for an array field but no tags, convert to array
foreach ($arrayFields as $fieldName) {
if (isset($contentData->$fieldName) && !is_array($contentData->$fieldName)) {
$contentData->$fieldName = [$contentData->$fieldName];
}
}
return [
'metadata' => $contentData,
'rawEvent' => $rawEvent
];
});
} catch (InvalidArgumentException $e) {
$this->logger->error('Error getting user data with raw event.', ['exception' => $e]);
$content = new \stdClass();
$content->name = substr($npub, 0, 8) . '…' . substr($npub, -4);
$rawEvent = new \stdClass();
$rawEvent->content = json_encode($content);
$rawEvent->tags = [];
return [
'metadata' => $content,
'rawEvent' => $rawEvent
];
}
}
public function getRelays($npub)
{
$cacheKey = '10002_' . $npub;

76
src/Twig/Components/Atoms/Nip05Badge.php

@ -0,0 +1,76 @@ @@ -0,0 +1,76 @@
<?php
namespace App\Twig\Components\Atoms;
use App\Service\Nip05VerificationService;
use Psr\Log\LoggerInterface;
use swentel\nostr\Key\Key;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent]
final class Nip05Badge
{
use DefaultActionTrait;
#[LiveProp]
public string $nip05;
#[LiveProp]
public string $npub;
private bool $verified = false;
private array $relays = [];
private ?string $displayIdentifier = null;
public function __construct(
private readonly Nip05VerificationService $nip05Service,
private readonly LoggerInterface $logger
) {
}
public function mount($nip05, $npub): void
{
$this->nip05 = $nip05;
$this->npub = $npub;
// Only verify if both nip05 and pubkey are provided
if ($this->nip05 && $this->npub) {
$key = new Key();
try {
$result = $this->nip05Service->verify($this->nip05, $key->convertToHex($this->npub));
$this->verified = $result['verified'];
$this->relays = $result['relays'];
} catch (\Exception $e) {
$this->logger->error('Error verifying NIP-05 identifier', ['exception' => $e, 'nip05' => $this->nip05, 'npub' => $this->npub]);
$this->verified = false;
$this->relays = [];
}
if ($this->verified) {
$this->displayIdentifier = $this->nip05Service->formatForDisplay($this->nip05);
}
}
}
public function isVerified(): bool
{
return $this->verified;
}
public function getDisplayIdentifier(): ?string
{
return $this->displayIdentifier;
}
public function getRelays(): array
{
return $this->relays;
}
public function hasRelays(): bool
{
return !empty($this->relays);
}
}

5
src/Twig/Components/Organisms/Comments.php

@ -85,6 +85,11 @@ final class Comments @@ -85,6 +85,11 @@ final class Comments
}
$description = json_decode($descriptionJson);
// 2) If description has content, add it to the comment
if (!empty($description->content)) {
$comment->content = $description->content;
}
// 3) Get amount: prefer explicit 'amount' tag (msat), fallback to BOLT11 invoice parsing
$amountSats = null;

8
templates/components/Atoms/Nip05Badge.html.twig

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
<div {{ attributes }}>
{% if this.isVerified %}
<span class="nip05-badge verified"
title="Verified NIP-05 identifier{% if this.hasRelays() %} ({{ this.relays|length }} relay{{ this.relays|length > 1 ? 's' : '' }}){% endif %}">
<span class="nip05-identifier">{{ this.displayIdentifier }}</span>
</span>
{% endif %}
</div>

3
templates/components/event_card.html.twig

@ -7,9 +7,6 @@ @@ -7,9 +7,6 @@
{% endif %}
<div class="author-info">
<strong>{{ author.name ?? 'Anonymous' }}</strong>
{% if author.nip05 is defined %}
<span class="nip05">{{ author.nip05 }}</span>
{% endif %}
</div>
{% endif %}
<div class="event-meta">

197
templates/pages/author-about.html.twig

@ -0,0 +1,197 @@ @@ -0,0 +1,197 @@
{% extends 'layout.html.twig' %}
{% block body %}
{% if author.image is defined %}
<img src="{{ author.image }}" class="avatar" alt="{{ author.name }}" onerror="this.style.display = 'none'" />
{% endif %}
<h1><twig:Atoms:NameOrNpub :author="author" :npub="npub"></twig:Atoms:NameOrNpub></h1>
{% if author.nip05 is defined %}
{% if author.nip05 is iterable %}
{% for nip05Value in author.nip05 %}
<twig:Atoms:Nip05Badge
nip05="{{ nip05Value }}"
:npub="npub"
/>
{% endfor %}
{% else %}
<twig:Atoms:Nip05Badge
nip05="{{ author.nip05 }}"
:npub="npub"
/>
{% endif %}
{% endif %}
<div class="profile-tabs">
<a href="{{ path('author-profile', {'npub': npub}) }}" class="tab-link">Articles</a>
<a href="{{ path('author-media', {'npub': npub}) }}" class="tab-link">Media</a>
<a href="{{ path('author-about', {'npub': npub}) }}" class="tab-link active">About</a>
</div>
<div class="w-container mt-4">
<div class="profile-details">
<h2>Profile Information</h2>
{% if author.about is defined %}
<div class="profile-field">
<h3>About</h3>
<div class="profile-value">
{{ author.about|markdown_to_html|mentionify|linkify }}
</div>
</div>
{% endif %}
{% if author.banner is defined %}
<div class="profile-field">
<h3>Banner</h3>
<div class="profile-value">
<img src="{{ author.banner }}" alt="Profile banner" style="max-width: 100%; height: auto;" onerror="this.style.display = 'none'" />
</div>
</div>
{% endif %}
{% if author.website is defined %}
<div class="profile-field">
<h3>Website</h3>
<div class="profile-value">
<a href="{{ author.website }}" target="_blank" rel="noopener noreferrer">{{ author.website }}</a>
</div>
</div>
{% endif %}
{% if author.lud16 is defined %}
<div class="profile-field">
<h3>Lightning Address{{ author.lud16 is iterable and author.lud16|length > 1 ? 'es' : '' }}</h3>
<div class="profile-value">
{% if author.lud16 is iterable %}
{% for address in author.lud16 %}
<div class="mb-1">
<code>{{ address }}</code>
</div>
{% endfor %}
{% else %}
<code>{{ author.lud16 }}</code>
{% endif %}
</div>
</div>
{% endif %}
{% if author.lud06 is defined %}
<div class="profile-field">
<h3>LNURL{{ author.lud06 is iterable and author.lud06|length > 1 ? 's' : '' }}</h3>
<div class="profile-value">
{% if author.lud06 is iterable %}
{% for lnurl in author.lud06 %}
<div class="mb-1">
<code style="word-break: break-all;">{{ lnurl }}</code>
</div>
{% endfor %}
{% else %}
<code style="word-break: break-all;">{{ author.lud06 }}</code>
{% endif %}
</div>
</div>
{% endif %}
<div class="profile-field">
<h3>Public Key (hex)</h3>
<div class="profile-value">
<code style="word-break: break-all;">{{ pubkey }}</code>
</div>
</div>
<div class="profile-field">
<h3>Public Key (npub)</h3>
<div class="profile-value">
<code style="word-break: break-all;">{{ npub }}</code>
</div>
</div>
{# Display any additional fields that might be present #}
{% set standardFields = ['name', 'display_name', 'about', 'picture', 'banner', 'nip05', 'website', 'lud16', 'lud06', 'image'] %}
{% for key, value in author %}
{% if key not in standardFields and value is not empty %}
<div class="profile-field">
<h3>{{ key|title }}</h3>
<div class="profile-value">
{% if value starts with 'http://' or value starts with 'https://' %}
<a href="{{ value }}" target="_blank" rel="noopener noreferrer">{{ value }}</a>
{% else %}
{{ value }}
{% endif %}
</div>
</div>
{% endif %}
{% endfor %}
</div>
{# Raw Event Debug Section #}
<div class="mt-6 p-4 bg-gray-100 rounded">
<details>
<summary class="cursor-pointer font-semibold text-lg mb-2">Raw Profile Event (Debug)</summary>
<div class="mt-2">
<h4 class="font-semibold">Event ID:</h4>
<pre class="bg-white p-2 rounded overflow-x-auto"><code>{{ rawEvent.id ?? 'N/A' }}</code></pre>
<h4 class="font-semibold mt-3">Created At:</h4>
<pre class="bg-white p-2 rounded overflow-x-auto"><code>{{ rawEvent.created_at ?? 'N/A' }} ({{ rawEvent.created_at is defined ? rawEvent.created_at|date('Y-m-d H:i:s') : 'N/A' }})</code></pre>
<h4 class="font-semibold mt-3">Tags:</h4>
<pre class="bg-white p-2 rounded overflow-x-auto"><code>{{ rawEvent.tags is defined ? rawEvent.tags|json_encode(constant('JSON_PRETTY_PRINT')) : '[]' }}</code></pre>
<h4 class="font-semibold mt-3">Content (JSON):</h4>
<pre class="bg-white p-2 rounded overflow-x-auto"><code>{{ rawEvent.content ?? '{}' }}</code></pre>
<h4 class="font-semibold mt-3">Signature:</h4>
<pre class="bg-white p-2 rounded overflow-x-auto text-xs"><code>{{ rawEvent.sig ?? 'N/A' }}</code></pre>
<h4 class="font-semibold mt-3">Full Event Object:</h4>
<pre class="bg-white p-2 rounded overflow-x-auto text-xs"><code>{{ rawEvent|json_encode(constant('JSON_PRETTY_PRINT')) }}</code></pre>
</div>
</details>
</div>
</div>
<style>
.profile-details {
max-width: 800px;
}
.profile-field {
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid #e5e7eb;
}
.profile-field:last-child {
border-bottom: none;
}
.profile-field h3 {
font-size: 0.875rem;
font-weight: 600;
color: #6b7280;
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.profile-value {
color: #1f2937;
word-wrap: break-word;
}
.profile-value code {
background-color: #f3f4f6;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.875rem;
}
details summary {
user-select: none;
}
details[open] summary {
margin-bottom: 1rem;
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
}
</style>
{% endblock %}

22
templates/pages/author-media.html.twig

@ -7,7 +7,22 @@ @@ -7,7 +7,22 @@
{% endif %}
<h1><twig:Atoms:NameOrNpub :author="author" :npub="npub"></twig:Atoms:NameOrNpub></h1>
<div>
{% if author.nip05 is defined %}
{% if author.nip05 is iterable %}
{% for nip05Value in author.nip05 %}
<twig:Atoms:Nip05Badge
nip05="{{ nip05Value }}"
:npub="npub"
/>
{% endfor %}
{% else %}
<twig:Atoms:Nip05Badge
nip05="{{ author.nip05 }}"
:npub="npub"
/>
{% endif %}
{% endif %}
<div class="mt-2">
{% if author.about is defined %}
{{ author.about|markdown_to_html|mentionify|linkify }}
{% endif %}
@ -16,7 +31,9 @@ @@ -16,7 +31,9 @@
<div class="profile-tabs">
<a href="{{ path('author-profile', {'npub': npub}) }}" class="tab-link">Articles</a>
<a href="{{ path('author-media', {'npub': npub}) }}" class="tab-link active">Media</a>
</div>
{% if is_granted('ROLE_ADMIN') %}
<a href="{{ path('author-about', {'npub': npub}) }}" class="tab-link">About</a>
{% endif %} </div>
<div class="w-container">
{% if pictureEvents|length > 0 %}
@ -87,4 +104,3 @@ @@ -87,4 +104,3 @@
{% endif %}
</div>
{% endblock %}

26
templates/pages/author.html.twig

@ -7,7 +7,22 @@ @@ -7,7 +7,22 @@
{% endif %}
<h1><twig:Atoms:NameOrNpub :author="author" :npub="npub"></twig:Atoms:NameOrNpub></h1>
<div>
{% if author.nip05 is defined %}
{% if author.nip05 is iterable %}
{% for nip05Value in author.nip05 %}
<twig:Atoms:Nip05Badge
nip05="{{ nip05Value }}"
:npub="npub"
/>
{% endfor %}
{% else %}
<twig:Atoms:Nip05Badge
nip05="{{ author.nip05 }}"
:npub="npub"
/>
{% endif %}
{% endif %}
<div class="mt-2">
{% if author.about is defined %}
{{ author.about|markdown_to_html|mentionify|linkify }}
{% endif %}
@ -16,6 +31,9 @@ @@ -16,6 +31,9 @@
<div class="profile-tabs">
<a href="{{ path('author-profile', {'npub': npub}) }}" class="tab-link active">Articles</a>
<a href="{{ path('author-media', {'npub': npub}) }}" class="tab-link">Media</a>
{% if is_granted('ROLE_ADMIN') %}
<a href="{{ path('author-about', {'npub': npub}) }}" class="tab-link">About</a>
{% endif %}
</div>
{# {% if relays|length > 0 %}#}
@ -67,7 +85,13 @@ @@ -67,7 +85,13 @@
{# {% endif %}#}
<div class="w-container">
{% if articles|length > 0 %}
<twig:Organisms:CardList :list="articles" class="article-list"></twig:Organisms:CardList>
{% else %}
<div class="no-media">
<p>No articles found for this author.</p>
</div>
{% endif %}
</div>
{% endblock %}

300
tests/NIPs/NIP-05.feature

@ -0,0 +1,300 @@ @@ -0,0 +1,300 @@
Feature: NIP-05 Mapping Nostr Keys to DNS-based Internet Identifiers
As a Nostr client
I want to map Nostr public keys to DNS-based internet identifiers
So that users can be identified by human-readable email-like addresses
Background:
Given the newsroom application is running
And I have a valid Nostr keypair
# ===== Verification Flow =====
Scenario: Successful verification of NIP-05 identifier
Given I see a kind 0 event with pubkey "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9"
And the event content includes nip05 identifier "bob@example.com"
When I split the identifier into local part "bob" and domain "example.com"
And I make a GET request to "https://example.com/.well-known/nostr.json?name=bob"
And the response contains:
"""json
{
"names": {
"bob": "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9"
}
}
"""
Then the pubkey should match the one in the names mapping
And the NIP-05 identifier should be marked as valid
And the identifier should be displayed to the user
Scenario: Verification with relay information
Given I see a kind 0 event with pubkey "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9"
And the event content includes nip05 identifier "bob@example.com"
When I make a GET request to "https://example.com/.well-known/nostr.json?name=bob"
And the response contains relay information:
"""json
{
"names": {
"bob": "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9"
},
"relays": {
"b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9": [
"wss://relay.example.com",
"wss://relay2.example.com"
]
}
}
"""
Then the pubkey should match the one in the names mapping
And the client should learn the user's preferred relays
And the relay list should be saved for future connections
# ===== User Discovery =====
Scenario: Finding a user by NIP-05 identifier
Given a user wants to find "bob@example.com"
When I fetch "https://example.com/.well-known/nostr.json?name=bob"
And the response contains pubkey "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9"
Then I should fetch the kind 0 event for that pubkey
And verify it has a matching nip05 field "bob@example.com"
And suggest the user profile to the searcher
Scenario: User search box implementation
Given a search box is available in the client
When a user types "bob@example.com"
And the client recognizes the email-like format
Then the client should perform NIP-05 lookup
And retrieve the associated pubkey
And display the user profile in search results
# ===== Validation Rules =====
Scenario: Valid local-part characters
Given I have identifiers with various local parts
When I validate "<identifier>"
Then it should be "<valid_or_invalid>"
Examples:
| identifier | valid_or_invalid |
| bob@example.com | valid |
| alice_123@example.com | valid |
| user-name@example.com | valid |
| test.user@example.com | valid |
| User@Example.com | valid |
| bob+tag@example.com | invalid |
| bob space@example.com | invalid |
| bob#hash@example.com | invalid |
Scenario: Case-insensitive local-part matching
Given I have identifier "Bob@Example.com"
When I make a request to the well-known endpoint
Then the query should be normalized to lowercase
And "bob" should match "Bob" in the response
# ===== Public Key Format =====
Scenario: Public keys must be in hex format
Given I fetch "https://example.com/.well-known/nostr.json?name=bob"
When the response contains pubkey "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9"
Then the pubkey should be in hex format
And the pubkey should NOT be in npub format
And the client should accept it for verification
Scenario: Rejecting npub format in well-known response
Given I fetch "https://example.com/.well-known/nostr.json?name=bob"
When the response contains pubkey starting with "npub1"
Then the client should reject the identifier as invalid
And display an error about incorrect key format
# ===== Root Domain Identifier =====
Scenario: Displaying root identifier without redundancy
Given I see a kind 0 event with nip05 identifier "_@bob.com"
When the client processes the identifier
Then it should display as "bob.com" only
And treat it as the root identifier for the domain
Scenario: Regular identifier display
Given I see a kind 0 event with nip05 identifier "bob@bob.com"
When the client processes the identifier
Then it should display as "bob@bob.com"
And not apply root identifier special handling
# ===== Multiple NIP-05 Identifiers =====
Scenario: User with multiple NIP-05 identifiers in tags
Given I see a kind 0 event with pubkey "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9"
And the event has a tag ["nip05", "bob@example.com", "bob@business.com", "bob@personal.org"]
When the client processes the metadata
Then all three identifiers should be collected into an array
And each identifier should be verified independently
And all verified identifiers should be displayed as badges
Scenario: Displaying multiple verified NIP-05 badges
Given I see a kind 0 event with multiple nip05 identifiers
And "bob@example.com" verification succeeds
And "bob@business.com" verification succeeds
And "bob@personal.org" verification fails
When the profile is displayed
Then I should see a badge for "bob@example.com"
And I should see a badge for "bob@business.com"
And I should NOT see a badge for "bob@personal.org"
Scenario: Multiple NIP-05 from tags override content field
Given I see a kind 0 event with content field nip05 "old@example.com"
And the event has a tag ["nip05", "bob@example.com", "bob@business.com"]
When the client processes the metadata
Then the nip05 field should be ["bob@example.com", "bob@business.com"]
And "old@example.com" should be ignored
And tags take priority over content
Scenario: Single NIP-05 in content is converted to array
Given I see a kind 0 event with only content field nip05 "bob@example.com"
And the event has no nip05 tags
When the client processes the metadata
Then the nip05 field should be converted to array ["bob@example.com"]
And it should be displayed consistently with multi-value cases
Scenario: Duplicate NIP-05 values are removed
Given I see a kind 0 event with tag ["nip05", "bob@example.com", "bob@example.com", "bob@business.com"]
When the client processes the metadata
Then duplicates should be removed
And the nip05 field should contain ["bob@example.com", "bob@business.com"]
And each identifier should only be verified once
Scenario: Multiple lightning addresses in tags
Given I see a kind 0 event with tag ["lud16", "bob@getalby.com", "bob@primal.net", "bob@wallet.com"]
When the client processes the metadata
Then all three addresses should be collected into an array
And the profile about page should display "Lightning Addresses" (plural)
And all addresses should be listed
Scenario: Single lightning address displays singular form
Given I see a kind 0 event with tag ["lud16", "bob@getalby.com"]
When the client processes the metadata
Then the lud16 field should be array ["bob@getalby.com"]
And the profile about page should display "Lightning Address" (singular)
# ===== Following and Key Management =====
Scenario: Client must follow public keys, not identifiers
Given I find that "bob@bob.com" has pubkey "abc...def"
When the user follows this profile
Then the client should store a reference to pubkey "abc...def"
And NOT store a primary reference to "bob@bob.com"
And use the NIP-05 identifier only for display
Scenario: Handling identifier changes
Given I am following pubkey "abc...def" displayed as "bob@bob.com"
When "https://bob.com/.well-known/nostr.json?name=bob" starts returning pubkey "1d2...e3f"
Then the client should continue following "abc...def"
And stop displaying "bob@bob.com" for that user
And mark the NIP-05 identifier as invalid
# ===== Verification Failures =====
Scenario: Pubkey mismatch in verification
Given I see a kind 0 event with pubkey "abc...def"
And the event content includes nip05 identifier "bob@example.com"
When I fetch "https://example.com/.well-known/nostr.json?name=bob"
And the response contains a different pubkey "xyz...123"
Then the verification should fail
And the NIP-05 identifier should not be displayed
And the user should be shown without verification
Scenario: Well-known endpoint not found
Given I see a kind 0 event with nip05 identifier "bob@example.com"
When I fetch "https://example.com/.well-known/nostr.json?name=bob"
And the response is 404 Not Found
Then the verification should fail
And the identifier should be marked as unverified
Scenario: Network timeout during verification
Given I see a kind 0 event with nip05 identifier "bob@example.com"
When I fetch "https://example.com/.well-known/nostr.json?name=bob"
And the request times out
Then the verification should fail gracefully
And the client should retry after a reasonable delay
And display the user without verification in the meantime
# ===== CORS Support =====
Scenario: Successful CORS-enabled request from JavaScript app
Given a JavaScript Nostr app is running in a browser
When I fetch "https://example.com/.well-known/nostr.json?name=bob"
And the response includes header "Access-Control-Allow-Origin: *"
Then the JavaScript app should successfully receive the response
And complete the verification process
Scenario: CORS policy blocking JavaScript request
Given a JavaScript Nostr app is running in a browser
When I fetch "https://example.com/.well-known/nostr.json?name=bob"
And the server does not include CORS headers
Then the browser should block the request
And the app should see it as a network failure
And recommend the user check their server's CORS policy
# ===== Security Constraints =====
Scenario: Rejecting HTTP redirects
Given I fetch "https://example.com/.well-known/nostr.json?name=bob"
When the server responds with a 301 or 302 redirect
Then the client MUST ignore the redirect
And the verification should fail
And the identifier should be marked as invalid
Scenario: Following redirect attempt should be blocked
Given I fetch "https://example.com/.well-known/nostr.json?name=bob"
When the server responds with redirect to "https://malicious.com/nostr.json"
Then the client MUST NOT follow the redirect
And MUST NOT make a request to the redirected URL
And treat this as a verification failure
# ===== Dynamic vs Static Server Support =====
Scenario: Dynamic server with query string support
Given the server generates JSON on-demand
When I request "https://example.com/.well-known/nostr.json?name=bob"
Then the server should return data specific to "bob"
And optionally include relay information
When I request "https://example.com/.well-known/nostr.json?name=alice"
Then the server should return different data for "alice"
Scenario: Static server with multiple names
Given the server has a static nostr.json file
When I request "https://example.com/.well-known/nostr.json?name=bob"
Then the response should contain multiple names:
"""json
{
"names": {
"bob": "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9",
"alice": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
}
}
"""
And the client should extract only the "bob" mapping
# ===== Edge Cases =====
Scenario: Empty response from well-known endpoint
Given I fetch "https://example.com/.well-known/nostr.json?name=bob"
When the response is empty or malformed JSON
Then the verification should fail
And log the error for debugging
Scenario: Missing names field in response
Given I fetch "https://example.com/.well-known/nostr.json?name=bob"
When the response is valid JSON but missing the "names" field
Then the verification should fail
And the identifier should not be displayed
Scenario: Name not found in response
Given I fetch "https://example.com/.well-known/nostr.json?name=bob"
When the response contains names but not "bob"
Then the verification should fail
And the identifier should be marked as invalid
Scenario: Invalid hex format in response
Given I fetch "https://example.com/.well-known/nostr.json?name=bob"
When the response contains pubkey with invalid hex characters
Then the verification should fail
And log a format error

2
translations/messages.en.yaml

@ -7,7 +7,7 @@ text: @@ -7,7 +7,7 @@ text:
heading:
roles: 'Roles'
logout: 'Log out'
logIn: 'Log in options'
logIn: 'Login options'
createNzine: 'Create an N-Zine'
editNzine: 'Edit your N-Zine'
search: 'Search'

Loading…
Cancel
Save