Browse Source

bug-fix highlights

imwald
Silberengel 1 day ago
parent
commit
fedbed5824
  1. 40
      assets/controllers/user_highlight_tooltip_controller.js
  2. 17
      assets/styles/article.css
  3. 52
      composer.json
  4. 2485
      composer.lock
  5. 11
      config/packages/csrf.yaml
  6. 11
      config/packages/nyholm_psr7.yaml
  7. 3
      config/packages/property_info.yaml
  8. 9
      config/services.yaml
  9. 35
      src/Security/NostrAuthenticator.php
  10. 204
      src/Service/ArticleBodyHighlightInjector.php
  11. 2
      src/Service/CacheService.php
  12. 13
      src/Service/HighlightAuthorMetadataProvider.php
  13. 30
      src/Util/HighlightEventTags.php
  14. 36
      symfony.lock
  15. 99
      tests/Security/NostrAuthenticatorTest.php
  16. 177
      tests/Service/ArticleBodyHighlightInjectorTest.php
  17. 2
      tests/bootstrap.php

40
assets/controllers/user_highlight_tooltip_controller.js

@ -118,6 +118,43 @@ export default class extends Controller { @@ -118,6 +118,43 @@ export default class extends Controller {
}
};
window.addEventListener('resize', this._onResize);
this._onHashChange = () => {
this._scrollToHashHighlight();
};
window.addEventListener('hashchange', this._onHashChange);
this._scrollToHashHighlight();
}
/**
* Browsers are inconsistent about scrolling to #highlight-<event id> (inline marks, alias spans,
* late layout). Mirror native intent after paint.
*/
_scrollToHashHighlight() {
const hash = window.location.hash;
if (!hash?.startsWith('#highlight-')) {
return;
}
const id = decodeURIComponent(hash.slice(1));
if (!/^highlight-[a-f0-9]{64}$/i.test(id)) {
return;
}
const run = () => {
const node = document.getElementById(id);
if (!(node instanceof HTMLElement)) {
return;
}
const next = node.nextElementSibling;
const target =
node.classList.contains('user-highlight__fragment-target') &&
next?.classList?.contains('user-highlight__marker')
? next
: node;
target.scrollIntoView({ block: 'start', inline: 'nearest' });
};
requestAnimationFrame(() => {
requestAnimationFrame(run);
});
}
disconnect() {
@ -126,6 +163,9 @@ export default class extends Controller { @@ -126,6 +163,9 @@ export default class extends Controller {
this.element.removeEventListener('focusin', this._onFocus);
this.element.removeEventListener('focusout', this._onBlur);
window.removeEventListener('resize', this._onResize);
if (this._onHashChange) {
window.removeEventListener('hashchange', this._onHashChange);
}
this._cancelHide();
this.tip.remove();
}

17
assets/styles/article.css

@ -448,10 +448,25 @@ @@ -448,10 +448,25 @@
-webkit-box-decoration-break: clone;
}
.article-main mark.user-highlight__marker {
.article-main mark.user-highlight__marker,
.article-main .user-highlight__fragment-target {
scroll-margin-top: calc(var(--site-fixed-header-offset, 140px) + 0.75rem);
}
/* Invisible #highlight-{eid} anchors (same group as an older mark) — zero visual footprint. */
.article-main .user-highlight__fragment-target {
display: inline;
font-size: 0;
line-height: 0;
width: 0.01em;
height: 0;
overflow: hidden;
margin: 0;
padding: 0;
border: 0;
vertical-align: baseline;
}
/* When `content` is not a substring of `context` (rare) */
.user-highlight__marker-orphan {
margin: 0.5rem 0 0;

52
composer.json

@ -6,7 +6,7 @@ @@ -6,7 +6,7 @@
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=8.3.13",
"php": "^8.3",
"ext-ctype": "*",
"ext-iconv": "*",
"ext-openssl": "*",
@ -23,31 +23,31 @@ @@ -23,31 +23,31 @@
"phpstan/phpdoc-parser": "^2.0",
"runtime/frankenphp-symfony": "^0.2.0",
"swentel/nostr-php": "^1.9.4",
"symfony/asset": "7.1.*",
"symfony/asset-mapper": "7.1.*",
"symfony/console": "7.1.*",
"symfony/dotenv": "7.1.*",
"symfony/asset": "7.3.*",
"symfony/asset-mapper": "7.3.*",
"symfony/console": "7.3.*",
"symfony/dotenv": "7.3.*",
"symfony/flex": "^2",
"symfony/form": "7.1.*",
"symfony/framework-bundle": "7.1.*",
"symfony/html-sanitizer": "7.1.*",
"symfony/http-foundation": "7.1.*",
"symfony/intl": "7.1.*",
"symfony/monolog-bridge": "7.1.*",
"symfony/form": "7.3.*",
"symfony/framework-bundle": "7.3.*",
"symfony/html-sanitizer": "7.3.*",
"symfony/http-foundation": "7.3.*",
"symfony/intl": "7.3.*",
"symfony/monolog-bridge": "7.3.*",
"symfony/monolog-bundle": "^3.11",
"symfony/process": "7.1.*",
"symfony/property-access": "7.1.*",
"symfony/property-info": "7.1.*",
"symfony/runtime": "7.1.*",
"symfony/security-bundle": "7.1.*",
"symfony/serializer": "7.1.*",
"symfony/process": "7.3.*",
"symfony/property-access": "7.3.*",
"symfony/property-info": "7.3.*",
"symfony/runtime": "7.3.*",
"symfony/security-bundle": "7.3.*",
"symfony/serializer": "7.3.*",
"symfony/stimulus-bundle": "^2.22",
"symfony/translation": "7.1.*",
"symfony/twig-bundle": "7.1.*",
"symfony/translation": "7.3.*",
"symfony/twig-bundle": "7.3.*",
"symfony/ux-icons": "^2.22",
"symfony/ux-live-component": "^2.21",
"symfony/workflow": "7.1.*",
"symfony/yaml": "7.1.*",
"symfony/workflow": "7.3.*",
"symfony/yaml": "7.3.*",
"twig/extra-bundle": "^2.12|^3.0",
"twig/markdown-extra": "^3.21",
"twig/string-extra": "^3.21",
@ -101,7 +101,7 @@ @@ -101,7 +101,7 @@
"extra": {
"symfony": {
"allow-contrib": false,
"require": "7.1.*",
"require": "7.3.*",
"docker": true
},
"runtime": {
@ -110,11 +110,11 @@ @@ -110,11 +110,11 @@
},
"require-dev": {
"phpunit/phpunit": "^9.5",
"symfony/browser-kit": "7.1.*",
"symfony/css-selector": "7.1.*",
"symfony/browser-kit": "7.3.*",
"symfony/css-selector": "7.3.*",
"symfony/maker-bundle": "^1.63",
"symfony/phpunit-bridge": "^7.2",
"symfony/stopwatch": "7.1.*",
"symfony/web-profiler-bundle": "7.1.*"
"symfony/stopwatch": "7.3.*",
"symfony/web-profiler-bundle": "7.3.*"
}
}

2485
composer.lock generated

File diff suppressed because it is too large Load Diff

11
config/packages/csrf.yaml

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
# Enable stateless CSRF protection for forms and logins/logouts
framework:
form:
csrf_protection:
token_id: submit
csrf_protection:
stateless_token_ids:
- submit
- authenticate
- logout

11
config/packages/nyholm_psr7.yaml

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
services:
# Register nyholm/psr7 services for autowiring with PSR-17 (HTTP factories)
Psr\Http\Message\RequestFactoryInterface: '@nyholm.psr7.psr17_factory'
Psr\Http\Message\ResponseFactoryInterface: '@nyholm.psr7.psr17_factory'
Psr\Http\Message\ServerRequestFactoryInterface: '@nyholm.psr7.psr17_factory'
Psr\Http\Message\StreamFactoryInterface: '@nyholm.psr7.psr17_factory'
Psr\Http\Message\UploadedFileFactoryInterface: '@nyholm.psr7.psr17_factory'
Psr\Http\Message\UriFactoryInterface: '@nyholm.psr7.psr17_factory'
nyholm.psr7.psr17_factory:
class: Nyholm\Psr7\Factory\Psr17Factory

3
config/packages/property_info.yaml

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
framework:
property_info:
with_constructor_extractor: true

9
config/services.yaml

@ -12,6 +12,9 @@ parameters: @@ -12,6 +12,9 @@ parameters:
env(TRUSTED_PROXIES): '127.0.0.0/8,::1'
services:
App\Service\HighlightAuthorMetadataProvider:
alias: App\Service\CacheService
# default configuration for services in *this* file
_defaults:
autowire: true # Automatically injects dependencies in your services.
@ -62,3 +65,9 @@ services: @@ -62,3 +65,9 @@ services:
App\Service\Nip05VerificationService:
arguments:
$appCache: '@cache.app'
when@test:
services:
Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler:
class: Symfony\Component\HttpFoundation\Session\Storage\Handler\NativeFileSessionHandler
arguments: ['%kernel.project_dir%/var/sessions_test']

35
src/Security/NostrAuthenticator.php

@ -2,8 +2,8 @@ @@ -2,8 +2,8 @@
namespace App\Security;
use App\Entity\Event;
use Mdanter\Ecc\Crypto\Signature\SchnorrSignature;
use swentel\nostr\Event\Event;
use swentel\nostr\Key\Key;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@ -13,9 +13,6 @@ use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator; @@ -13,9 +13,6 @@ use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\InteractiveAuthenticatorInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
/**
* Authenticator for Nostr protocol-based authentication.
@ -57,15 +54,31 @@ class NostrAuthenticator extends AbstractAuthenticator implements InteractiveAut @@ -57,15 +54,31 @@ class NostrAuthenticator extends AbstractAuthenticator implements InteractiveAut
}
$eventStr = base64_decode(substr($authHeader, 6), true);
$encoders = [new JsonEncoder()];
$normalizers = [new ObjectNormalizer()];
$serializer = new Serializer($normalizers, $encoders);
/** @var Event $event */
$event = $serializer->deserialize($eventStr, Event::class, 'json');
if (false === $eventStr) {
throw new AuthenticationException('Invalid Authorization header');
}
try {
$data = json_decode($eventStr, false, 512, \JSON_THROW_ON_ERROR);
} catch (\JsonException) {
throw new AuthenticationException('Invalid Authorization header');
}
if (!\is_object($data) || !isset(
$data->id, $data->pubkey, $data->created_at, $data->kind, $data->content, $data->sig
)) {
throw new AuthenticationException('Invalid Authorization header');
}
if (!isset($data->tags) || !\is_array($data->tags)) {
$data->tags = [];
}
$event = (new Event())->populate($data);
if (time() > $event->getCreatedAt() + 60) {
throw new AuthenticationException('Expired');
}
$validity = (new SchnorrSignature())->verify($event->getPubkey(), $event->getSig(), $event->getId());
$validity = (new SchnorrSignature())->verify(
$event->getPublicKey(),
$event->getSignature(),
$event->getId()
);
if (!$validity) {
throw new AuthenticationException('Invalid Authorization header');
}
@ -73,7 +86,7 @@ class NostrAuthenticator extends AbstractAuthenticator implements InteractiveAut @@ -73,7 +86,7 @@ class NostrAuthenticator extends AbstractAuthenticator implements InteractiveAut
$key = new Key();
return new SelfValidatingPassport(
new UserBadge($key->convertPublicKeyToBech32($event->getPubkey()))
new UserBadge($key->convertPublicKeyToBech32($event->getPublicKey()))
);
}

204
src/Service/ArticleBodyHighlightInjector.php

@ -13,11 +13,17 @@ use DOMXPath; @@ -13,11 +13,17 @@ use DOMXPath;
use swentel\nostr\Key\Key;
/**
* Injects kind-9802 highlight ranges into the rendered article body by finding each event’s
* `content` in the visible text (the `context` tag is ignored; the article is the full context).
* Matches across inline elements (e.g. em, strong) by concatenating text in document order.
* If a literal match fails, compares a normalized form (NBSP→space, strip U+00AD / ZW, etc.),
* then maps the match back to the original HTML text (for e‑book style soft hyphens in 9802 content).
* Injects kind-9802 highlight marks into the rendered article body by searching the visible text
* in NIP-84 order: event `content` (highlighted span) first, then the `context` tag when present and
* non-empty, then `textquoteselector` passage. The first string that matches the body wins.
* Matches across inline elements (e.g. em, strong) by concatenating text in document order. Text
* inside a prior `mark.user-highlight__marker` is still considered so a narrower 9802 can
* be nested and receive its own fragment id (deep link from the landing aside).
* If a literal match fails, compares a normalized form (NBSP→space, strip U+00AD / ZW, line breaks,
* etc.) via {@see HighlightEventTags::stringForSearch}, then maps the match back to the original
* HTML text (for e‑book style soft hyphens in 9802 content). CommonMark footnote callouts
* (League CommonMark `sup#fnref…`) are ignored for matching so “realm 1 always” in the DOM does not
* block a NIP-84 passage that says “realm always”.
*/
final class ArticleBodyHighlightInjector
{
@ -28,7 +34,7 @@ final class ArticleBodyHighlightInjector @@ -28,7 +34,7 @@ final class ArticleBodyHighlightInjector
private ?DOMElement $root = null;
public function __construct(
private readonly CacheService $cacheService,
private readonly HighlightAuthorMetadataProvider $highlightAuthorMetadata,
) {
}
@ -186,17 +192,89 @@ final class ArticleBodyHighlightInjector @@ -186,17 +192,89 @@ final class ArticleBodyHighlightInjector
return [];
}
$authorJson = $this->buildHighlightAuthorsJson($group);
$resolved = $this->resolveInjectionNeedle($first);
foreach ($this->needleSearchVariants($resolved) as $needle) {
if ($needle === '') {
$bases = $this->injectionNeedleBasesInPriority($first);
if ($bases === []) {
return [];
}
foreach ($bases as $base) {
foreach ($this->needleSearchVariants($base) as $needle) {
if ($needle === '') {
continue;
}
if ($this->tryWrapInDocument($root, $needle, $eid, $authorJson)) {
$this->addFragmentIdAliasesForHighlightGroup($eid, $outEids);
return $outEids;
}
}
}
return [];
}
/**
* One <mark> per passage group, with id highlight-{oldest eid}. The landing aside links each
* 9802 by that row's event id, so we add zero-footprint #highlight-{id} spans for every other
* event in the same group (same place in the text as the mark).
*
* @param list<string> $outEids lowercase 64-hex, includes $canonicalEid; first is the oldest
*/
private function addFragmentIdAliasesForHighlightGroup(string $canonicalEid, array $outEids): void
{
if (\count($outEids) < 2) {
return;
}
$mark = $this->getHighlightMarkElementById('highlight-'.$canonicalEid);
if (null === $mark) {
return;
}
$parent = $mark->parentNode;
if (null === $parent) {
return;
}
foreach ($outEids as $other) {
if ($other === $canonicalEid) {
continue;
}
if (64 !== \strlen($other) || !ctype_xdigit($other)) {
continue;
}
if ($this->tryWrapInDocument($root, $needle, $eid, $authorJson)) {
return $outEids;
if ($this->getHighlightMarkElementById('highlight-'.$other) !== null) {
continue;
}
$span = $this->dom->createElement('span');
if (false === $span) {
continue;
}
$span->setAttribute('id', 'highlight-'.$other);
$span->setAttribute('class', 'user-highlight__fragment-target');
$span->setAttribute('aria-hidden', 'true');
$span->appendChild($this->dom->createTextNode("\u{200B}"));
$parent->insertBefore($span, $mark);
}
}
return [];
private function getHighlightMarkElementById(string $id): ?DOMElement
{
if (null === $this->root || $id === '') {
return null;
}
$el = $this->dom->getElementById($id);
if ($el instanceof DOMElement) {
return $el;
}
if (! \preg_match('/^highlight-[a-f0-9]{64}$/D', $id)) {
return null;
}
$xp = new DOMXPath($this->dom);
$q = '//*[@id="'.(string) $id.'"]';
$nodes = $xp->query($q, $this->root);
if (false === $nodes || 0 === $nodes->length) {
return null;
}
$n = $nodes->item(0);
return $n instanceof DOMElement ? $n : null;
}
/**
@ -208,13 +286,13 @@ final class ArticleBodyHighlightInjector @@ -208,13 +286,13 @@ final class ArticleBodyHighlightInjector
{
$buckets = [];
foreach ($sorted as $h) {
$resolved = $this->resolveInjectionNeedle($h);
if ($resolved === '') {
$primary = $this->primaryNeedleForGrouping($h);
if ($primary === '') {
continue;
}
$key = HighlightEventTags::stringForSearch(\trim($resolved));
$key = HighlightEventTags::stringForSearch($primary);
if ($key === '') {
$key = 'x'.\md5($resolved);
$key = 'x'.\md5($primary);
}
if (!isset($buckets[$key])) {
$buckets[$key] = [];
@ -264,7 +342,7 @@ final class ArticleBodyHighlightInjector @@ -264,7 +342,7 @@ final class ArticleBodyHighlightInjector
$name = '';
$pic = '';
try {
$meta = $this->cacheService->getMetadata($npub);
$meta = $this->highlightAuthorMetadata->getMetadata($npub);
if (isset($meta->display_name) && \is_string($meta->display_name) && $meta->display_name !== '') {
$name = $meta->display_name;
} elseif (isset($meta->name) && \is_string($meta->name) && $meta->name !== '') {
@ -288,14 +366,37 @@ final class ArticleBodyHighlightInjector @@ -288,14 +366,37 @@ final class ArticleBodyHighlightInjector
return \json_encode(\array_values($byNpub), \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR);
}
private function resolveInjectionNeedle(ArticleHighlight $h): string
/**
* Same priority as the card: event `content` (NIP-84 sub-span) first; if empty, `context` tag; if
* still empty, `textquoteselector` passage. Article injection tries each in order until one
* matches the rendered body (so a highlight with only `textquoteselector` still inlines a mark).
*/
private function primaryNeedleForGrouping(ArticleHighlight $h): string
{
$b = $this->injectionNeedleBasesInPriority($h);
return $b[0] ?? '';
}
/**
* @return list<string> unique non-empty strings, highest priority first
*/
private function injectionNeedleBasesInPriority(ArticleHighlight $h): array
{
$c = \trim($h->getContent());
if ($c !== '') {
return $c;
$ctx = \trim(HighlightEventTags::contextFromTags($h->getTags()));
$tq = \trim(HighlightEventTags::textquoteselectorPassageFromTags($h->getTags()));
$out = [];
$seen = [];
foreach ([$c, $ctx, $tq] as $s) {
if ($s === '' || isset($seen[$s])) {
continue;
}
$seen[$s] = true;
$out[] = $s;
}
return \trim(HighlightEventTags::contextFromTags($h->getTags()));
return $out;
}
/**
@ -312,6 +413,14 @@ final class ArticleBodyHighlightInjector @@ -312,6 +413,14 @@ final class ArticleBodyHighlightInjector
$base,
$this->replaceTypographicQuotes($base),
];
$noLineBreaks = (string) \preg_replace('/\R/u', '', $base);
if ($noLineBreaks !== $base && $noLineBreaks !== '') {
$candidates[] = $noLineBreaks;
}
$nEnd = (string) \preg_replace('/[.!?…,;:]+$/u', '', $base);
if ($nEnd !== $base && $nEnd !== '') {
$candidates[] = $nEnd;
}
if (\class_exists(\Normalizer::class)) {
$c = \Normalizer::normalize($base, \Normalizer::FORM_C);
if (\is_string($c) && $c !== '' && $c !== $base) {
@ -460,13 +569,47 @@ final class ArticleBodyHighlightInjector @@ -460,13 +569,47 @@ final class ArticleBodyHighlightInjector
private function shouldNotDescendInto(DOMElement $c): bool
{
$n = $c->nodeName;
return 'script' === $n
if ('script' === $n
|| 'style' === $n
|| 'pre' === $n
|| 'textarea' === $n
|| 'code' === $n
|| 'mark' === $n;
|| 'code' === $n) {
return true;
}
if ('div' === $n && $this->isFootnotesOrEndnotesElement($c)) {
// End-of-article footnote list (League CommonMark): must not mix into the body search string
// or after main content, which would desync “flat text” from NIP-84 passages.
return true;
}
if ('sup' === $n && $this->isFootnoteCalloutElement($c)) {
// Inline [^ref] callouts: skip the superscript so "realm" + "1" + " always" does not
// break matching "realm always" from kind-9802 `content` (cards use raw Nostr, not the DOM).
return true;
}
if ('mark' === $n) {
$cl = (string) $c->getAttribute('class');
return ! \str_contains($cl, 'user-highlight__marker');
}
return false;
}
private function isFootnoteCalloutElement(DOMElement $c): bool
{
$id = (string) $c->getAttribute('id');
return $id !== '' && \str_starts_with($id, 'fnref');
}
private function isFootnotesOrEndnotesElement(DOMElement $c): bool
{
if (\str_contains((string) $c->getAttribute('class'), 'footnotes')
|| $c->getAttribute('role') === 'doc-endnotes') {
return true;
}
return false;
}
private function isSafeTextContext(DOMText $textNode): bool
@ -484,11 +627,12 @@ final class ArticleBodyHighlightInjector @@ -484,11 +627,12 @@ final class ArticleBodyHighlightInjector
if ('code' === $n) {
return false;
}
if ('mark' === $n) {
$cl = (string) $p->getAttribute('class');
if (\str_contains($cl, 'user-highlight__marker')) {
return false;
}
if (('div' === $n && $this->isFootnotesOrEndnotesElement($p))
|| ('sup' === $n && $this->isFootnoteCalloutElement($p))) {
return false;
}
if ('a' === $n && \str_contains((string) $p->getAttribute('class'), 'footnote-ref')) {
return false;
}
$p = $p->parentNode;
}

2
src/Service/CacheService.php

@ -11,7 +11,7 @@ use Doctrine\ORM\EntityManagerInterface; @@ -11,7 +11,7 @@ use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use swentel\nostr\Key\Key;
readonly class CacheService
readonly class CacheService implements HighlightAuthorMetadataProvider
{
public function __construct(
private NostrClient $nostrClient,

13
src/Service/HighlightAuthorMetadataProvider.php

@ -0,0 +1,13 @@ @@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Service;
/**
* Subset of {@see CacheService} for {@see ArticleBodyHighlightInjector} (mockable; readonly services cannot be doubled in PHPUnit).
*/
interface HighlightAuthorMetadataProvider
{
public function getMetadata(string $npub): \stdClass;
}

30
src/Util/HighlightEventTags.php

@ -132,6 +132,14 @@ final class HighlightEventTags @@ -132,6 +132,14 @@ final class HighlightEventTags
* Same character normalization as {@see \App\Service\ArticleBodyHighlightInjector} so
* `content` can match the `context` tag when Unicode (NBSP, soft hyphen, etc.) differs — NIP-84
* requires `content` to be a substring of the passage, but clients often diverge on code points.
*
* Newlines and Unicode line/paragraph separators are removed: Nostr `context` often contains
* `\\n` between sentences, while the article DOM’s flattened text has no line breaks at block
* boundaries, so they must not break matching.
*
* Smart punctuation (curly quotes, en/em dash, Unicode ellipsis) is folded to ASCII so the
* article HTML from {@see \League\CommonMark\Extension\SmartPunct\SmartPunctExtension} still
* matches highlight `content` copied with straight quotes from the source article.
*/
public static function stringForSearch(string $s): string
{
@ -318,6 +326,15 @@ final class HighlightEventTags @@ -318,6 +326,15 @@ final class HighlightEventTags
private static function searchCharacterNormalized(string $ch): string
{
if ($ch === "\n" || $ch === "\r" || $ch === "\f" || $ch === "\v") {
return '';
}
if ($ch === "\xC2\x85") { // U+0085 (NEL)
return '';
}
if ($ch === "\xE2\x80\xA8" || $ch === "\xE2\x80\xA9") { // U+2028 LINE, U+2029 PARA separator
return '';
}
if ($ch === "\xC2\xAD") { // U+00AD soft hyphen
return '';
}
@ -333,6 +350,19 @@ final class HighlightEventTags @@ -333,6 +350,19 @@ final class HighlightEventTags
) {
return ' ';
}
// CommonMark SmartPunct / e-book typography → match Nostr `content` with ASCII punctuation
if ($ch === "\xE2\x80\x99" || $ch === "\xE2\x80\x98") { // U+2019, U+2018
return "'";
}
if ($ch === "\xE2\x80\x9C" || $ch === "\xE2\x80\x9D") { // U+201C, U+201D
return '"';
}
if ($ch === "\xE2\x80\x93" || $ch === "\xE2\x80\x94") { // en dash, em dash
return '-';
}
if ($ch === "\xE2\x80\xA6") { // U+2026 HORIZONTAL ELLIPSIS
return '...';
}
return $ch;
}

36
symfony.lock

@ -35,6 +35,18 @@ @@ -35,6 +35,18 @@
"./migrations/.gitignore"
]
},
"nyholm/psr7": {
"version": "1.8",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "4a8c0345442dcca1d8a2c65633dcf0285dd5a5a2"
},
"files": [
"config/packages/nyholm_psr7.yaml"
]
},
"phpunit/phpunit": {
"version": "9.6",
"recipe": {
@ -88,6 +100,18 @@ @@ -88,6 +100,18 @@
".env"
]
},
"symfony/form": {
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.2",
"ref": "7d86a6723f4a623f59e2bf966b6aad2fc461d36b"
},
"files": [
"config/packages/csrf.yaml"
]
},
"symfony/framework-bundle": {
"version": "7.1",
"recipe": {
@ -143,6 +167,18 @@ @@ -143,6 +167,18 @@
"tests/bootstrap.php"
]
},
"symfony/property-info": {
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.3",
"ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7"
},
"files": [
"config/packages/property_info.yaml"
]
},
"symfony/routing": {
"version": "7.1",
"recipe": {

99
tests/Security/NostrAuthenticatorTest.php

@ -4,83 +4,62 @@ declare(strict_types=1); @@ -4,83 +4,62 @@ declare(strict_types=1);
namespace App\Tests\Security;
use App\Kernel;
use App\Security\NostrAuthenticator;
use PHPUnit\Framework\TestCase;
use swentel\nostr\Event\Event;
use swentel\nostr\Key\Key;
use swentel\nostr\Sign\Sign;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
class NostrAuthenticatorTest extends WebTestCase
/**
* Unit tests for {@see NostrAuthenticator} (no full HTTP stack / DB: no nsec parameter, no Docker MySQL).
*/
class NostrAuthenticatorTest extends TestCase
{
/**
* Tests various authentication scenarios for the Nostr authenticator.
*
* This test sends a GET request to the /login endpoint with different Authorization headers
* and asserts that the response status code and content match the expected values provided
* by the data provider.
*
* @dataProvider provideAuthenticationData
*/
public function testAuthenticationScenarios(string $authorizationHeader, int $expectedStatusCode, string $expectedContent)
public function testValidNostrEventReturnsPassport(): void
{
$client = static::createClient();
$nsec = (new Key())->convertPrivateKeyToBech32((new Key())->generatePrivateKey());
$token = 'Nostr '.$this->signedAuthEventBase64($nsec);
$request = Request::create('/login', 'GET', [], [], [], ['HTTP_AUTHORIZATION' => $token]);
$client->request('GET', '/login', [], [], [
'HTTP_Authorization' => $authorizationHeader,
]);
$out = (new NostrAuthenticator())->authenticate($request);
$response = $client->getResponse();
$this->assertSame($expectedStatusCode, $response->getStatusCode());
$this->assertStringContainsString($expectedContent, $response->getContent());
$this->assertInstanceOf(SelfValidatingPassport::class, $out);
}
/**
* @throws \JsonException
*/
public function provideAuthenticationData(): array
public function testInvalidAuthorizationHeaderThrows(): void
{
// Boot the kernel manually
$kernel = new Kernel('local', true);
$kernel->boot();
$container = $kernel->getContainer();
$this->expectException(AuthenticationException::class);
$request = Request::create('/login', 'GET', [], [], [], [
'HTTP_AUTHORIZATION' => 'InvalidHeader',
]);
(new NostrAuthenticator())->authenticate($request);
}
$nsec = $container->getParameter('nsec');
public function testExpiredEventThrows(): void
{
$this->expectException(AuthenticationException::class);
$this->expectExceptionMessage('Expired');
$expiredToken = 'Nostr eyJjcmVhdGVkX2F0IjoxNzMzMzIxMzUyLCJraW5kIjoyNzIzNSwidGFncyI6W1sidSIsImh0dHBzOi8vbG9jYWxob3N0L2xvZ2luIl0sWyJtZXRob2QiLCJHRVQiXV0sImNvbnRlbnQiOiIiLCJwdWJrZXkiOiJkNDc1Y2U0YjM5Nzc1MDcxMzBmNDJjN2Y4NjM0NmVmOTM2ODAwZjNhZTc0ZDVlY2Y4MDg5MjgwY2RjMTkyM2U5IiwiaWQiOiJhYjA4NGM1NWQ5Y2UzMDliN2UxNzIyZGI2ODNjZTc2ZDg5NGNjN2QyYTIzZTRkNWUyMTUyYTM2Y2M2ODI1MTQ5Iiwic2lnIjoiOWI1Yjk2YjhkN2U2ZGM4YWU3ZmM4NjU2ZTE0NDVlZjkwYzc1YWQxNzZkYTRmNmNhMjI0NTRkNTJjNTk3ZTBmNjYwZjAwZjE3MmIxYjMzYzM4YTg2Y2U0YTBiMTdmMDgwMWEyNzJmZmVmYWU0NmY2OTgzZGZjYjRlM2YyZDgwZGYifQ==';
$request = Request::create('/login', 'GET', [], [], [], [
'HTTP_AUTHORIZATION' => $expiredToken,
]);
(new NostrAuthenticator())->authenticate($request);
}
private function signedAuthEventBase64(string $nsec): string
{
$note = new Event();
$note->setContent('');
$note->setKind(27235);
$note->setTags([
["u", "https://localhost/login"],
["method", "POST"]
['u', 'https://localhost/login'],
['method', 'POST'],
]);
$signer = new Sign();
$signer->signEvent($note, $nsec);
$ser = $note->toJson();
$validToken = 'Nostr ' . base64_encode($ser);
$expiredToken = 'Nostr eyJjcmVhdGVkX2F0IjoxNzMzMzIxMzUyLCJraW5kIjoyNzIzNSwidGFncyI6W1sidSIsImh0dHBzOi8vbG9jYWxob3N0L2xvZ2luIl0sWyJtZXRob2QiLCJHRVQiXV0sImNvbnRlbnQiOiIiLCJwdWJrZXkiOiJkNDc1Y2U0YjM5Nzc1MDcxMzBmNDJjN2Y4NjM0NmVmOTM2ODAwZjNhZTc0ZDVlY2Y4MDg5MjgwY2RjMTkyM2U5IiwiaWQiOiJhYjA4NGM1NWQ5Y2UzMDliN2UxNzIyZGI2ODNjZTc2ZDg5NGNjN2QyYTIzZTRkNWUyMTUyYTM2Y2M2ODI1MTQ5Iiwic2lnIjoiOWI1Yjk2YjhkN2U2ZGM4YWU3ZmM4NjU2ZTE0NDVlZjkwYzc1YWQxNzZkYTRmNmNhMjI0NTRkNTJjNTk3ZTBmNjYwZjAwZjE3MmIxYjMzYzM4YTg2Y2U0YTBiMTdmMDgwMWEyNzJmZmVmYWU0NmY2OTgzZGZjYjRlM2YyZDgwZGYifQ==';
$invalidToken = 'InvalidHeader';
(new Sign())->signEvent($note, $nsec);
return [
// Scenario: Valid token
'valid_token' => [
'authorizationHeader' => $validToken,
'expectedStatusCode' => Response::HTTP_OK,
'expectedContent' => 'Authentication Successful',
],
// Scenario: Expired token
'expired_token' => [
'authorizationHeader' => $expiredToken,
'expectedStatusCode' => Response::HTTP_UNAUTHORIZED,
'expectedContent' => 'Unauthenticated',
],
// Scenario: Invalid header
'invalid_token' => [
'authorizationHeader' => $invalidToken,
'expectedStatusCode' => Response::HTTP_UNAUTHORIZED,
'expectedContent' => 'Unauthenticated',
]
];
return base64_encode($note->toJson());
}
}

177
tests/Service/ArticleBodyHighlightInjectorTest.php

@ -0,0 +1,177 @@ @@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Entity\ArticleHighlight;
use App\Service\ArticleBodyHighlightInjector;
use App\Service\HighlightAuthorMetadataProvider;
use PHPUnit\Framework\TestCase;
/**
* In-article marks use the same matching rules as production ({@see \App\Controller\ArticleController::renderArticle}:
* CommonMark HTML → {@see \App\Service\ArticleBodyHighlightInjector::inject}).
*
* Cards on the home page use NIP-84 strings as stored; the body is rendered HTML, so tests must
* include real DOM differences (SmartPunct curly quotes / ellipsis vs ASCII in 9802 `content`).
*/
final class ArticleBodyHighlightInjectorTest extends TestCase
{
/** Valid 64-hex secp256k1 pubkey (see NostrAuthenticatorTest signing key). */
private const AUTHOR_HEX = 'd475ce4b3977507130f42c7f8634fef936800f3ae74d5ecf8089280cdc1923e9';
public function testEachDistinctPassageRendersWithHighlightFragmentId(): void
{
$html = '<p>First passage alpha.</p><p>Second passage beta.</p><p>Third passage gamma.</p>';
$e1 = '00000000000000000000000000000000000000000000000000000000000000a1';
$e2 = '00000000000000000000000000000000000000000000000000000000000000a2';
$e3 = '00000000000000000000000000000000000000000000000000000000000000a3';
$highlights = [
$this->makeHighlight($e1, 'First passage alpha.', [], 100),
$this->makeHighlight($e2, 'Second passage beta.', [], 200),
$this->makeHighlight($e3, 'Third passage gamma.', [], 300),
];
$injector = $this->createInjector();
$out = $injector->inject($html, $highlights);
$this->assertCount(3, $out['injectedEventIds'], 'Each highlight with a unique matching passage should inject.');
$this->assertHighlightFragmentsPresent($out['html'], [$e1, $e2, $e3]);
}
public function testSamePassageFromTwoEventsYieldsFragmentIdForEachEventId(): void
{
$html = '<p>Shared quote text for two readers.</p>';
$older = '00000000000000000000000000000000000000000000000000000000000000b1';
$newer = '00000000000000000000000000000000000000000000000000000000000000b2';
$highlights = [
$this->makeHighlight($older, 'Shared quote text for two readers.', [], 10),
$this->makeHighlight($newer, 'Shared quote text for two readers.', [], 20),
];
$out = $this->createInjector()->inject($html, $highlights);
$this->assertCount(2, $out['injectedEventIds']);
$this->assertHighlightFragmentsPresent($out['html'], [$older, $newer]);
}
public function testContextTagUsedWhenContentIsSubspanOfContext(): void
{
$html = '<p>Before the important bit the rest of the sentence.</p>';
$eid = '00000000000000000000000000000000000000000000000000000000000000c1';
$context = 'Before the important bit the rest of the sentence.';
$content = 'important bit';
$highlights = [
$this->makeHighlight(
$eid,
$content,
[['context', $context]],
100
),
];
$out = $this->createInjector()->inject($html, $highlights);
$this->assertContains($eid, $out['injectedEventIds'], 'NIP-84: highlight `content` as subspan of `context` should still match the body.');
$this->assertHighlightFragmentsPresent($out['html'], [$eid]);
}
public function testCurlyApostropheAndEllipsisInBodyMatchAsciiNeedleFromEvent(): void
{
// Landing “highlight” cards use NIP-84 text as stored; the article body is rendered by
// CommonMark + SmartPunct, which uses U+2019 (’) and U+2026 (…) in the DOM while clients
// often send straight ASCII in 9802 `content`. stringForSearch must fold typography.
$apostrophe = "\xE2\x80\x99";
$ellipsis = "\xE2\x80\xA6";
// "Here" + ’ + "s" (SmartPunct), not "it" + ’ + "s"
$html = '<p>Here'.$apostrophe.'s the point'.$ellipsis.'</p>';
$eid = '00000000000000000000000000000000000000000000000000000000000000d1';
$highlights = [
$this->makeHighlight($eid, "Here's the point...", [], 1),
];
$out = $this->createInjector()->inject($html, $highlights);
$this->assertContains($eid, $out['injectedEventIds']);
$this->assertHighlightFragmentsPresent($out['html'], [$eid]);
}
public function testBorisBitcoinIsTimeHighlightWithSoftHyphensInjectsDespiteFootnoteCalloutInBody(): void
{
// Real kind-9802: `content` uses U+00AD in "infor­ma­tion­al" (read.withboris / Gigi’s article).
// The article body is plain "informational"; footnotes break substring search if we keep <sup> text.
$content = 'keeping track of things in the infor'."\xC2\xAD".'ma'."\xC2\xAD".'tional realm always implies keeping track of time';
$html = '<p>keeping track of things in the informational realm'
.'<sup id="fnref:a"><a class="footnote-ref" href="#fn:a" role="doc-noteref">1</a></sup>'
.' always implies keeping track of time</p>';
$eid = 'f56a6221e8575b051cd6df34e9b61654e08a241b4c5ced3b48c0b769b24ada7d';
$highlights = [
$this->makeHighlight(
$eid,
$content,
[['a', '30023:6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93:bitcoin-is-time']],
1762697677
),
];
$out = $this->createInjector()->inject($html, $highlights);
$this->assertContains($eid, $out['injectedEventIds']);
$this->assertHighlightFragmentsPresent($out['html'], [$eid]);
}
public function testSoftHyphenInBodyMatchesPlainNeedleFromEvent(): void
{
$softHyphen = "\xC2\xAD";
$html = '<p>Time and order have a very intimate relation'.$softHyphen.'ship.</p>';
$eid = '00000000000000000000000000000000000000000000000000000000000000e1';
$highlights = [
$this->makeHighlight($eid, 'Time and order have a very intimate relationship.', [], 1),
];
$out = $this->createInjector()->inject($html, $highlights);
$this->assertContains($eid, $out['injectedEventIds']);
$this->assertHighlightFragmentsPresent($out['html'], [$eid]);
}
private function createInjector(): ArticleBodyHighlightInjector
{
$meta = $this->createMock(HighlightAuthorMetadataProvider::class);
$meta->method('getMetadata')->willReturn(
(object) [
'display_name' => 'Test',
'name' => 'Test',
'picture' => '',
]
);
return new ArticleBodyHighlightInjector($meta);
}
/**
* @param list<string> $eventIdsLowerOrMixed 64-char hex event ids
*/
private function assertHighlightFragmentsPresent(string $html, array $eventIds): void
{
foreach ($eventIds as $eid) {
$eid = strtolower($eid);
$this->assertMatchesRegularExpression(
'/\bid="highlight-'.preg_quote($eid, '/').'"/',
$html,
'Expected in-article fragment id highlight-'.$eid
);
}
}
private function makeHighlight(
string $eventId64,
string $content,
array $tags,
int $createdAt,
): ArticleHighlight {
$h = new ArticleHighlight();
$h->setEventId($eventId64);
$h->setContent($content);
$h->setTags($tags);
$h->setEventCreatedAt($createdAt);
$h->setAuthorPubkey(self::AUTHOR_HEX);
return $h;
}
}

2
tests/bootstrap.php

@ -8,6 +8,6 @@ if (method_exists(Dotenv::class, 'bootEnv')) { @@ -8,6 +8,6 @@ if (method_exists(Dotenv::class, 'bootEnv')) {
(new Dotenv())->bootEnv(dirname(__DIR__).'/.env');
}
if ($_SERVER['APP_DEBUG']) {
if (!empty($_SERVER['APP_DEBUG'])) {
umask(0000);
}

Loading…
Cancel
Save