diff --git a/assets/app.js b/assets/app.js index a75c28f..09a520f 100644 --- a/assets/app.js +++ b/assets/app.js @@ -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'; diff --git a/assets/styles/03-components/nip05-badge.css b/assets/styles/03-components/nip05-badge.css new file mode 100644 index 0000000..5c1ed52 --- /dev/null +++ b/assets/styles/03-components/nip05-badge.css @@ -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; + } +} + diff --git a/documentation/nip05-badge-component.md b/documentation/nip05-badge-component.md new file mode 100644 index 0000000..dda6680 --- /dev/null +++ b/documentation/nip05-badge-component.md @@ -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 #} + +``` + +### With Author Metadata + +```twig +
+ {{ author.name ?? 'Anonymous' }} + {% if author.nip05 is defined and author.pubkey is defined %} + + {% endif %} +
+``` + +### In a Card Component + +```twig +
+ {{ author.name }} +
+

{{ author.name }}

+ +
+
+``` + +## 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:///.well-known/nostr.json?name=` +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) + diff --git a/src/Controller/AuthorController.php b/src/Controller/AuthorController.php index eaed478..f7d51da 100644 --- a/src/Controller/AuthorController.php +++ b/src/Controller/AuthorController.php @@ -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 */ diff --git a/src/Service/Nip05VerificationService.php b/src/Service/Nip05VerificationService.php new file mode 100644 index 0000000..386a4d5 --- /dev/null +++ b/src/Service/Nip05VerificationService.php @@ -0,0 +1,215 @@ +} + */ + 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; + } +} + diff --git a/src/Service/RedisCacheService.php b/src/Service/RedisCacheService.php index e80b9b0..15b03eb 100644 --- a/src/Service/RedisCacheService.php +++ b/src/Service/RedisCacheService.php @@ -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 = []; } - $this->logger->info('Metadata:', ['meta' => json_encode($meta)]); - return json_decode($meta->content); + + // 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; + } + + // 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 } } + /** + * 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; diff --git a/src/Twig/Components/Atoms/Nip05Badge.php b/src/Twig/Components/Atoms/Nip05Badge.php new file mode 100644 index 0000000..3c3a12b --- /dev/null +++ b/src/Twig/Components/Atoms/Nip05Badge.php @@ -0,0 +1,76 @@ +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); + } +} + diff --git a/src/Twig/Components/Organisms/Comments.php b/src/Twig/Components/Organisms/Comments.php index fe10456..48ecec1 100644 --- a/src/Twig/Components/Organisms/Comments.php +++ b/src/Twig/Components/Organisms/Comments.php @@ -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; diff --git a/templates/components/Atoms/Nip05Badge.html.twig b/templates/components/Atoms/Nip05Badge.html.twig new file mode 100644 index 0000000..d71fb6e --- /dev/null +++ b/templates/components/Atoms/Nip05Badge.html.twig @@ -0,0 +1,8 @@ +
+ {% if this.isVerified %} + + {{ this.displayIdentifier }} + + {% endif %} +
diff --git a/templates/components/event_card.html.twig b/templates/components/event_card.html.twig index 5af927d..0a79840 100644 --- a/templates/components/event_card.html.twig +++ b/templates/components/event_card.html.twig @@ -7,9 +7,6 @@ {% endif %}
{{ author.name ?? 'Anonymous' }} - {% if author.nip05 is defined %} - {{ author.nip05 }} - {% endif %}
{% endif %}
diff --git a/templates/pages/author-about.html.twig b/templates/pages/author-about.html.twig new file mode 100644 index 0000000..58f05fc --- /dev/null +++ b/templates/pages/author-about.html.twig @@ -0,0 +1,197 @@ +{% extends 'layout.html.twig' %} + +{% block body %} + + {% if author.image is defined %} + {{ author.name }} + {% endif %} + +

+ {% if author.nip05 is defined %} + {% if author.nip05 is iterable %} + {% for nip05Value in author.nip05 %} + + {% endfor %} + {% else %} + + {% endif %} + {% endif %} + +
+ Articles + Media + About +
+ +
+
+

Profile Information

+ + {% if author.about is defined %} +
+

About

+
+ {{ author.about|markdown_to_html|mentionify|linkify }} +
+
+ {% endif %} + + {% if author.banner is defined %} +
+

Banner

+
+ Profile banner +
+
+ {% endif %} + + {% if author.website is defined %} +
+

Website

+ +
+ {% endif %} + + {% if author.lud16 is defined %} +
+

Lightning Address{{ author.lud16 is iterable and author.lud16|length > 1 ? 'es' : '' }}

+
+ {% if author.lud16 is iterable %} + {% for address in author.lud16 %} +
+ {{ address }} +
+ {% endfor %} + {% else %} + {{ author.lud16 }} + {% endif %} +
+
+ {% endif %} + + {% if author.lud06 is defined %} +
+

LNURL{{ author.lud06 is iterable and author.lud06|length > 1 ? 's' : '' }}

+
+ {% if author.lud06 is iterable %} + {% for lnurl in author.lud06 %} +
+ {{ lnurl }} +
+ {% endfor %} + {% else %} + {{ author.lud06 }} + {% endif %} +
+
+ {% endif %} + +
+

Public Key (hex)

+
+ {{ pubkey }} +
+
+ +
+

Public Key (npub)

+
+ {{ npub }} +
+
+ + {# 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 %} +
+

{{ key|title }}

+
+ {% if value starts with 'http://' or value starts with 'https://' %} + {{ value }} + {% else %} + {{ value }} + {% endif %} +
+
+ {% endif %} + {% endfor %} +
+ + {# Raw Event Debug Section #} +
+
+ Raw Profile Event (Debug) +
+

Event ID:

+
{{ rawEvent.id ?? 'N/A' }}
+ +

Created At:

+
{{ rawEvent.created_at ?? 'N/A' }} ({{ rawEvent.created_at is defined ? rawEvent.created_at|date('Y-m-d H:i:s') : 'N/A' }})
+ +

Tags:

+
{{ rawEvent.tags is defined ? rawEvent.tags|json_encode(constant('JSON_PRETTY_PRINT')) : '[]' }}
+ +

Content (JSON):

+
{{ rawEvent.content ?? '{}' }}
+ +

Signature:

+
{{ rawEvent.sig ?? 'N/A' }}
+ +

Full Event Object:

+
{{ rawEvent|json_encode(constant('JSON_PRETTY_PRINT')) }}
+
+
+
+
+ + +{% endblock %} diff --git a/templates/pages/author-media.html.twig b/templates/pages/author-media.html.twig index 2919235..c6b7e2e 100644 --- a/templates/pages/author-media.html.twig +++ b/templates/pages/author-media.html.twig @@ -7,7 +7,22 @@ {% endif %}

-
+ {% if author.nip05 is defined %} + {% if author.nip05 is iterable %} + {% for nip05Value in author.nip05 %} + + {% endfor %} + {% else %} + + {% endif %} + {% endif %} +
{% if author.about is defined %} {{ author.about|markdown_to_html|mentionify|linkify }} {% endif %} @@ -16,7 +31,9 @@ + {% if is_granted('ROLE_ADMIN') %} + About + {% endif %}
{% if pictureEvents|length > 0 %} @@ -87,4 +104,3 @@ {% endif %}
{% endblock %} - diff --git a/templates/pages/author.html.twig b/templates/pages/author.html.twig index 60d20f2..c97dceb 100644 --- a/templates/pages/author.html.twig +++ b/templates/pages/author.html.twig @@ -7,7 +7,22 @@ {% endif %}

-
+ {% if author.nip05 is defined %} + {% if author.nip05 is iterable %} + {% for nip05Value in author.nip05 %} + + {% endfor %} + {% else %} + + {% endif %} + {% endif %} +
{% if author.about is defined %} {{ author.about|markdown_to_html|mentionify|linkify }} {% endif %} @@ -16,6 +31,9 @@
Articles Media + {% if is_granted('ROLE_ADMIN') %} + About + {% endif %}
{# {% if relays|length > 0 %}#} @@ -67,7 +85,13 @@ {# {% endif %}#}
+ {% if articles|length > 0 %} + {% else %} +
+

No articles found for this author.

+
+ {% endif %}
{% endblock %} diff --git a/tests/NIPs/NIP-05.feature b/tests/NIPs/NIP-05.feature new file mode 100644 index 0000000..6cb8cf8 --- /dev/null +++ b/tests/NIPs/NIP-05.feature @@ -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 "" + Then it should be "" + + 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 diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index af9e391..8b557a4 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -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'