From 35c6f63d589144a60c4266176dd3f018f7301c83 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 2 May 2026 21:51:14 +0200 Subject: [PATCH] add magazine editor --- assets/app.js | 1 + assets/bootstrap.js | 12 + .../footer_magazine_edit_controller.js | 34 ++ assets/controllers/login_controller.js | 122 ++++- .../magazine_hierarchy_editor_controller.js | 476 ++++++++++++++++++ assets/styles/layout.css | 5 + assets/styles/magazine-editor.css | 92 ++++ assets/styles/notice.css | 6 + config/packages/security.yaml | 1 + src/Controller/LoginController.php | 1 + src/Controller/MagazineEditorController.php | 87 ++++ src/Security/NostrAuthenticator.php | 7 +- .../MagazineHierarchyEditorService.php | 419 +++++++++++++++ .../MagazineHierarchyPublishService.php | 270 ++++++++++ src/Util/NostrEventTags.php | 43 ++ templates/components/Footer.html.twig | 7 + templates/components/UserMenu.html.twig | 17 +- templates/pages/magazine_edit.html.twig | 83 +++ translations/messages.en.yaml | 4 + 19 files changed, 1655 insertions(+), 32 deletions(-) create mode 100644 assets/controllers/footer_magazine_edit_controller.js create mode 100644 assets/controllers/magazine_hierarchy_editor_controller.js create mode 100644 assets/styles/magazine-editor.css create mode 100644 src/Controller/MagazineEditorController.php create mode 100644 src/Service/MagazineHierarchyEditorService.php create mode 100644 src/Service/MagazineHierarchyPublishService.php create mode 100644 templates/pages/magazine_edit.html.twig diff --git a/assets/app.js b/assets/app.js index a17c164..9fc2c77 100644 --- a/assets/app.js +++ b/assets/app.js @@ -19,3 +19,4 @@ import './styles/form.css'; import './styles/notice.css'; import './styles/spinner.css'; import './styles/a2hs.css'; +import './styles/magazine-editor.css'; diff --git a/assets/bootstrap.js b/assets/bootstrap.js index 732828b..ab27e11 100644 --- a/assets/bootstrap.js +++ b/assets/bootstrap.js @@ -5,6 +5,8 @@ import CopyTextController from './controllers/copy_text_controller.js'; import UserHighlightTooltipController from './controllers/user_highlight_tooltip_controller.js'; import NostrShareMenuController from './controllers/nostr_share_menu_controller.js'; import ColorSchemeController from './controllers/color_scheme_controller.js'; +import MagazineHierarchyEditorController from './controllers/magazine_hierarchy_editor_controller.js'; +import FooterMagazineEditController from './controllers/footer_magazine_edit_controller.js'; const app = startStimulusApp(); if (typeof app.debug === 'boolean') { app.debug = false; @@ -41,3 +43,13 @@ try { } catch { /* already registered by the bundle */ } +try { + app.register('magazine-hierarchy-editor', MagazineHierarchyEditorController); +} catch { + /* already registered by the bundle */ +} +try { + app.register('footer-magazine-edit', FooterMagazineEditController); +} catch { + /* already registered by the bundle */ +} diff --git a/assets/controllers/footer_magazine_edit_controller.js b/assets/controllers/footer_magazine_edit_controller.js new file mode 100644 index 0000000..48ccd8a --- /dev/null +++ b/assets/controllers/footer_magazine_edit_controller.js @@ -0,0 +1,34 @@ +import { Controller } from '@hotwired/stimulus'; + +/** + * Shows or hides the "Edit magazine" footer link when auth changes without a full page reload. + */ +export default class extends Controller { + static values = { + publisherNpub: String, + }; + + connect() { + this.boundOnAuth ??= this.onAuthChanged.bind(this); + window.removeEventListener('unfold:auth-changed', this.boundOnAuth); + window.addEventListener('unfold:auth-changed', this.boundOnAuth); + } + + disconnect() { + window.removeEventListener('unfold:auth-changed', this.boundOnAuth); + } + + onAuthChanged(event) { + const d = event.detail; + if (!d) { + return; + } + if (d.loggedIn === false) { + this.element.hidden = true; + return; + } + if (d.loggedIn && d.npub === this.publisherNpubValue) { + this.element.hidden = false; + } + } +} diff --git a/assets/controllers/login_controller.js b/assets/controllers/login_controller.js index f81c443..431d588 100644 --- a/assets/controllers/login_controller.js +++ b/assets/controllers/login_controller.js @@ -2,40 +2,108 @@ import { Controller } from '@hotwired/stimulus'; import { getComponent } from '@symfony/ux-live-component'; export default class extends Controller { + static targets = ['error', 'submitButton']; + static values = { + noExtensionMessage: String, + cancelledMessage: String, + failedMessage: String, + }; + async initialize() { this.component = await getComponent(this.element); } + + authLogout() { + window.dispatchEvent( + new CustomEvent('unfold:auth-changed', { detail: { loggedIn: false } }) + ); + } + + clearError() { + if (this.hasErrorTarget) { + this.errorTarget.hidden = true; + this.errorTarget.textContent = ''; + } + } + + showError(message) { + if (!this.hasErrorTarget || !message) { + return; + } + this.errorTarget.textContent = message; + this.errorTarget.hidden = false; + } + async loginAct() { - const tags = [ - ['u', window.location.origin + '/login'], - ['method', 'POST'] - ] - const ev = { - created_at: Math.floor(Date.now()/1000), - kind: 27235, - tags: tags, - content: '' + this.clearError(); + + if (!window.nostr?.signEvent) { + this.showError(this.noExtensionMessageValue); + return; } - const signed = await window.nostr.signEvent(ev); - // base64 encode and send as Auth header - const result = await fetch('/login', { - method: 'POST', - credentials: 'same-origin', - headers: { - 'Authorization': 'Nostr ' + btoa(JSON.stringify(signed)) + const submit = this.hasSubmitButtonTarget ? this.submitButtonTarget : null; + if (submit) { + submit.disabled = true; + } + + try { + const loginUrl = new URL('/login', window.location.origin).href; + const tags = [ + ['u', loginUrl], + ['method', 'POST'], + ]; + const ev = { + created_at: Math.floor(Date.now() / 1000), + kind: 27235, + tags, + content: '', + }; + + const signed = await window.nostr.signEvent(ev); + + const response = await fetch('/login', { + method: 'POST', + credentials: 'same-origin', + headers: { + Authorization: 'Nostr ' + btoa(JSON.stringify(signed)), + Accept: 'application/json', + }, + }); + + const raw = await response.text(); + let data = null; + if (raw) { + try { + data = JSON.parse(raw); + } catch { + data = null; + } + } + + if (response.ok && data?.npub) { + void this.component.render(); + window.dispatchEvent( + new CustomEvent('unfold:auth-changed', { + detail: { loggedIn: true, npub: data.npub }, + }) + ); + return; + } + + const serverMsg = + data && typeof data.message === 'string' ? data.message : ''; + this.showError(serverMsg || this.failedMessageValue); + } catch (e) { + const name = e && typeof e === 'object' && 'name' in e ? e.name : ''; + if (name === 'AbortError') { + return; + } + this.showError(this.cancelledMessageValue); + } finally { + if (submit) { + submit.disabled = false; } - }).then(response => { - if (!response.ok) return false; - return 'Authentication Successful'; - }) - if (result) { - // Do not await render(): in UX Live Component it can deadlock the same update/render loop. - void this.component.render(); - window.dispatchEvent( - new CustomEvent('unfold:auth-changed', { detail: { loggedIn: true } }) - ); } } } - diff --git a/assets/controllers/magazine_hierarchy_editor_controller.js b/assets/controllers/magazine_hierarchy_editor_controller.js new file mode 100644 index 0000000..e7bb1c5 --- /dev/null +++ b/assets/controllers/magazine_hierarchy_editor_controller.js @@ -0,0 +1,476 @@ +import { Controller } from '@hotwired/stimulus'; + +const KIND_PUBLICATION_INDEX = 30040; +const KIND_LONGFORM = 30023; +const KIND_LONGFORM_DRAFT = 30024; + +/** + * Owner-only magazine hierarchy: build kind-30040 tags from fieldsets, NIP-07 sign each, POST batch. + * Only signs nodes that differ from the page load snapshot, plus any other index required for + * graph closure (root + every nested kind-30040 `a` reachable from those events). + */ +export default class extends Controller { + static targets = ['status', 'node']; + + static values = { + publishUrl: String, + csrf: String, + ownerHex: String, + rootDTag: String, + }; + + connect() { + this.nodeBaseline = new WeakMap(); + this.captureBaselines(); + } + + captureBaselines() { + for (const el of this.nodeTargets) { + this.nodeBaseline.set(el, snapshotFromElement(el)); + } + } + + publish() { + void this._publish(); + } + + async _publish() { + this.setStatus(''); + if (!this.hasNip07()) { + this.setStatus('Install a Nostr extension (NIP-07) to sign index events.'); + return; + } + const ownerHex = (this.ownerHexValue || '').toLowerCase().trim(); + if (ownerHex.length !== 64) { + this.setStatus('Missing owner pubkey.'); + return; + } + const rootD = (this.rootDTagValue || '').trim(); + if (rootD === '') { + this.setStatus('Missing root index #d.'); + return; + } + + const nodes = this.nodeTargets; + if (nodes.length === 0) { + this.setStatus('Nothing to publish.'); + return; + } + + const dirty = nodes.filter((el) => this.isNodeDirty(el)); + if (dirty.length === 0) { + this.setStatus('No changes to publish.'); + return; + } + + let publishSet; + try { + publishSet = await this.expandPublishSet(rootD, dirty, ownerHex); + } catch (err) { + this.setStatus(err instanceof Error ? err.message : String(err)); + return; + } + + const ordered = nodes.filter((el) => publishSet.has(readDTag(el))); + if (ordered.length === 0) { + this.setStatus('Nothing to publish.'); + return; + } + + const baseTime = Math.floor(Date.now() / 1000); + const signedEvents = []; + + this.setStatus( + ordered.length === dirty.length + ? 'Signing…' + : `Signing ${ordered.length} index event(s) (${dirty.length} edited, ${ordered.length - dirty.length} required for nested links)…`, + ); + + for (let i = 0; i < ordered.length; i++) { + const el = ordered[i]; + const dTag = readDTag(el); + const title = el.querySelector('[data-magazine-hierarchy-editor-target="title"]')?.value ?? ''; + const summary = el.querySelector('[data-magazine-hierarchy-editor-target="summary"]')?.value ?? ''; + const content = el.querySelector('[data-magazine-hierarchy-editor-target="content"]')?.value ?? ''; + const aText = el.querySelector('[data-magazine-hierarchy-editor-target="aLines"]')?.value ?? ''; + const preservedRaw = el.querySelector('[data-magazine-hierarchy-editor-target="preservedJson"]')?.value ?? '[]'; + + let preserved; + try { + preserved = JSON.parse(preservedRaw); + } catch { + this.setStatus(`Invalid preserved-tags JSON for #d ${dTag}.`); + return; + } + if (!Array.isArray(preserved)) { + this.setStatus(`Preserved tags must be a JSON array for #d ${dTag}.`); + return; + } + + let tags; + try { + tags = await this.buildTags(dTag, preserved, title, summary, aText, ownerHex); + } catch (err) { + this.setStatus(err instanceof Error ? err.message : String(err)); + return; + } + + const unsigned = { + kind: KIND_PUBLICATION_INDEX, + created_at: baseTime + i, + tags, + content: content ?? '', + }; + + let signed; + try { + signed = await window.nostr.signEvent(unsigned); + } catch (err) { + this.setStatus(`Signing failed (#d ${dTag}): ${err instanceof Error ? err.message : String(err)}`); + return; + } + signedEvents.push(signed); + } + + this.setStatus('Publishing…'); + let res; + try { + res = await fetch(this.publishUrlValue, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + credentials: 'same-origin', + body: JSON.stringify({ csrf: this.csrfValue, events: signedEvents }), + }); + } catch (err) { + this.setStatus(`Network error: ${err instanceof Error ? err.message : String(err)}`); + return; + } + + const data = await res.json().catch(() => ({})); + if (!res.ok) { + this.setStatus(typeof data.error === 'string' ? data.error : `HTTP ${res.status}`); + return; + } + const n = Number(data.published); + for (const el of ordered) { + this.nodeBaseline.set(el, snapshotFromElement(el)); + } + this.setStatus(Number.isFinite(n) ? `Published and stored ${n} index event(s).` : 'Published.'); + } + + isNodeDirty(el) { + const cur = snapshotFromElement(el); + const base = this.nodeBaseline.get(el); + if (!base) { + return true; + } + return ( + cur.title !== base.title || + cur.summary !== base.summary || + cur.content !== base.content || + cur.aText !== base.aText || + cur.preservedRaw !== base.preservedRaw + ); + } + + /** + * Minimal set of #d tags that must appear in the POST body: dirty nodes, the root, and every + * kind-30040 child referenced (transitively) from those events' `a` tags — matches server + * {@see MagazineHierarchyPublishService::validateGraphClosure}. + * + * @param {HTMLElement} dirtyFieldsets + * @returns {Promise>} + */ + async expandPublishSet(rootD, dirtyFieldsets, ownerHex) { + /** @type {Map} */ + const dToEl = new Map(); + for (const el of this.nodeTargets) { + const d = readDTag(el); + if (d) { + dToEl.set(d, el); + } + } + + const W = new Set(); + for (const el of dirtyFieldsets) { + const d = readDTag(el); + if (d) { + W.add(d); + } + } + W.add(rootD); + + let growing = true; + let guard = 0; + while (growing && guard < 256) { + guard += 1; + growing = false; + const snapshot = [...W]; + for (const d of snapshot) { + const el = dToEl.get(d); + if (!el) { + continue; + } + const { dTag, title, summary, content, aText, preserved } = readFieldsForBuild(el); + const tags = await this.buildTags(dTag, preserved, title, summary, aText, ownerHex); + for (const childD of ownedNested30040DsFromTags(tags, ownerHex)) { + if (!W.has(childD)) { + W.add(childD); + growing = true; + } + } + } + } + + return W; + } + + /** + * @param {string} dTag + * @param {unknown[]} preserved + * @param {string} title + * @param {string} summary + * @param {string} aText + * @param {string} ownerHex + * @returns {Promise} + */ + async buildTags(dTag, preserved, title, summary, aText, ownerHex) { + if (!dTag) { + throw new Error('Every node must have a #d value.'); + } + /** @type {string[][]} */ + const tags = [['d', dTag]]; + for (const row of preserved) { + if (!Array.isArray(row) || row.length === 0) { + continue; + } + const norm = row.map((c) => String(c)); + if (norm[0].toLowerCase() === 'd') { + continue; + } + tags.push(norm); + } + tags.push(['title', title]); + tags.push(['summary', summary]); + + const lines = aText + .split('\n') + .map((l) => l.trim()) + .filter((l) => l.length > 0); + for (const line of lines) { + const canonical = await normalizeAddressableCoordinate(line); + this.assertValidCoordinate(canonical, ownerHex); + tags.push(['a', canonical]); + } + return tags; + } + + /** + * @param {string} coord + * @param {string} ownerHex + */ + assertValidCoordinate(coord, ownerHex) { + const parts = splitThree(coord); + if (!parts) { + throw new Error(`Invalid coordinate (use kind:pubkey:identifier): ${coord}`); + } + const kind = parseInt(parts.kind, 10); + const pk = parts.pubkey.toLowerCase(); + const id = parts.identifier.trim(); + if (id.length === 0 || pk.length !== 64 || !/^[0-9a-f]+$/.test(pk)) { + throw new Error(`Invalid pubkey or identifier in: ${coord}`); + } + if (kind === KIND_PUBLICATION_INDEX && pk !== ownerHex) { + throw new Error(`Nested 30040 address must use magazine owner pubkey: ${coord}`); + } + if (kind !== KIND_PUBLICATION_INDEX && kind !== KIND_LONGFORM && kind !== KIND_LONGFORM_DRAFT) { + throw new Error(`Only kinds 30040, 30023, 30024 allowed in a tag: ${coord}`); + } + } + + setStatus(msg) { + if (this.hasStatusTarget) { + this.statusTarget.textContent = msg; + } + } + + hasNip07() { + return typeof window.nostr !== 'undefined' && typeof window.nostr.signEvent === 'function'; + } +} + +/** + * @param {string[][]} tags + * @param {string} ownerHex + * @returns {Set} + */ +function ownedNested30040DsFromTags(tags, ownerHex) { + const out = new Set(); + const oh = ownerHex.toLowerCase(); + for (const row of tags) { + if (row.length < 2 || String(row[0]).toLowerCase() !== 'a') { + continue; + } + const coord = String(row[1]).trim(); + const parts = splitThree(coord); + if (!parts) { + continue; + } + const kind = parseInt(parts.kind, 10); + if (kind !== KIND_PUBLICATION_INDEX) { + continue; + } + const pk = parts.pubkey.toLowerCase(); + if (pk !== oh) { + continue; + } + const id = parts.identifier.trim(); + if (id !== '') { + out.add(id); + } + } + return out; +} + +/** + * @param {HTMLElement} el + */ +function readDTag(el) { + return (el.querySelector('[data-magazine-hierarchy-editor-target="dTag"]')?.value || '').trim(); +} + +/** + * @param {HTMLElement} el + */ +function snapshotFromElement(el) { + return { + title: el.querySelector('[data-magazine-hierarchy-editor-target="title"]')?.value ?? '', + summary: el.querySelector('[data-magazine-hierarchy-editor-target="summary"]')?.value ?? '', + content: el.querySelector('[data-magazine-hierarchy-editor-target="content"]')?.value ?? '', + aText: el.querySelector('[data-magazine-hierarchy-editor-target="aLines"]')?.value ?? '', + preservedRaw: el.querySelector('[data-magazine-hierarchy-editor-target="preservedJson"]')?.value ?? '[]', + }; +} + +/** + * @param {HTMLElement} el + * @returns {{ dTag: string, title: string, summary: string, content: string, aText: string, preserved: unknown[] }} + */ +function readFieldsForBuild(el) { + const dTag = readDTag(el); + const title = el.querySelector('[data-magazine-hierarchy-editor-target="title"]')?.value ?? ''; + const summary = el.querySelector('[data-magazine-hierarchy-editor-target="summary"]')?.value ?? ''; + const content = el.querySelector('[data-magazine-hierarchy-editor-target="content"]')?.value ?? ''; + const aText = el.querySelector('[data-magazine-hierarchy-editor-target="aLines"]')?.value ?? ''; + const preservedRaw = el.querySelector('[data-magazine-hierarchy-editor-target="preservedJson"]')?.value ?? '[]'; + let preserved; + try { + preserved = JSON.parse(preservedRaw); + } catch { + throw new Error(`Invalid preserved-tags JSON for #d ${dTag}.`); + } + if (!Array.isArray(preserved)) { + throw new Error(`Preserved tags must be a JSON array for #d ${dTag}.`); + } + return { dTag, title, summary, content, aText, preserved }; +} + +/** + * @param {string} coord + * @returns {{ kind: string, pubkey: string, identifier: string } | null} + */ +function splitThree(coord) { + const s = coord.trim(); + const i = s.indexOf(':'); + if (i < 1) { + return null; + } + const j = s.indexOf(':', i + 1); + if (j <= i) { + return null; + } + return { + kind: s.slice(0, i), + pubkey: s.slice(i + 1, j), + identifier: s.slice(j + 1), + }; +} + +/** @type {Promise<(s: string) => unknown> | null} */ +let nip19DecodeLoader = null; + +/** + * @returns {Promise<(s: string) => unknown>} + */ +function loadNip19Decode() { + if (!nip19DecodeLoader) { + nip19DecodeLoader = import('nostr-tools/nip19').then((m) => m.decode); + } + return nip19DecodeLoader; +} + +/** + * Strips optional `nostr:` scheme and surrounding whitespace. + * @param {string} raw + */ +function stripNostrScheme(raw) { + let s = raw.trim(); + if (/^nostr:/i.test(s)) { + s = s.slice(6).trim(); + } + return s; +} + +/** + * Accepts `kind:pubkey:identifier` or NIP-19 `naddr1…` (optional `nostr:` prefix). + * @param {string} raw + * @returns {Promise} + */ +async function normalizeAddressableCoordinate(raw) { + const s = stripNostrScheme(raw); + if (s === '') { + throw new Error('Empty `a` line.'); + } + if (/^naddr1[0-9a-z]+$/i.test(s)) { + let decode; + try { + decode = await loadNip19Decode(); + } catch (e) { + throw new Error(`Could not load NIP-19 decoder: ${e instanceof Error ? e.message : String(e)}`); + } + let decoded; + try { + decoded = decode(s); + } catch (e) { + throw new Error(`Invalid naddr: ${e instanceof Error ? e.message : String(e)}`); + } + if (decoded.type !== 'naddr' || !decoded.data || typeof decoded.data !== 'object') { + throw new Error('Decoded value is not an naddr.'); + } + const { kind, pubkey, identifier } = decoded.data; + if (typeof kind !== 'number' || !Number.isFinite(kind)) { + throw new Error('naddr is missing kind.'); + } + if (typeof pubkey !== 'string' || pubkey.length !== 64 || !/^[0-9a-fA-F]+$/.test(pubkey)) { + throw new Error('naddr is missing valid author pubkey hex.'); + } + if (typeof identifier !== 'string' || identifier.trim() === '') { + throw new Error('naddr is missing identifier (#d).'); + } + return `${kind}:${pubkey.toLowerCase()}:${identifier}`; + } + const parts = splitThree(s); + if (!parts) { + throw new Error( + `Invalid line (use kind:pubkey:identifier or naddr1…): ${raw.length > 120 ? raw.slice(0, 120) + '…' : raw}`, + ); + } + const kind = parseInt(parts.kind, 10); + if (!Number.isFinite(kind)) { + throw new Error(`Invalid kind in coordinate: ${parts.kind}`); + } + return `${kind}:${parts.pubkey.toLowerCase()}:${parts.identifier}`; +} diff --git a/assets/styles/layout.css b/assets/styles/layout.css index 502799a..00d74b8 100644 --- a/assets/styles/layout.css +++ b/assets/styles/layout.css @@ -1107,6 +1107,11 @@ footer { max-width: 100%; } +/* [hidden] alone is ignored when display:flex is set on these
  • s (author CSS beats hidden’s UA style). */ +.site-footer__syndication-list > li[hidden] { + display: none !important; +} + .site-footer__syndication-list > li + li::before { content: "·"; color: var(--color-text-mid, #666); diff --git a/assets/styles/magazine-editor.css b/assets/styles/magazine-editor.css new file mode 100644 index 0000000..42ea5bd --- /dev/null +++ b/assets/styles/magazine-editor.css @@ -0,0 +1,92 @@ +.magazine-editor { + max-width: 52rem; + margin-inline: auto; +} + +.magazine-editor__intro { + margin: 0 0 1.25rem; + line-height: 1.5; + color: var(--color-text-mid, inherit); +} + +.magazine-editor__status { + min-height: 1.5rem; + margin: 0 0 1rem; + font-size: 0.95rem; +} + +.magazine-editor__nodes { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.magazine-editor__node { + margin: 0; + padding: 1rem 1rem 1.1rem; + border: 1px solid var(--color-border); + background: var(--color-bg-light, var(--color-bg)); + --mag-node-depth: 0; +} + +/* Tree rail for nested kind-30040 sections (depth ≥ 2 under root). */ +.magazine-editor__node--nested { + position: relative; + padding-left: calc(1rem + 0.35rem); + border-left: 2px solid var(--color-border, rgba(120, 120, 120, 0.42)); + border-radius: 0 2px 2px 0; +} + +.magazine-editor__node--nested::before { + content: ""; + position: absolute; + left: -2px; + top: 0.85rem; + width: 0.65rem; + height: 2px; + background: var(--color-border, rgba(120, 120, 120, 0.42)); + border-radius: 1px; +} + +.magazine-editor__legend { + padding: 0 0.35rem; + font-weight: 600; + font-size: 0.95rem; +} + +.magazine-editor__slug { + font-weight: 400; + font-size: 0.85rem; + margin-left: 0.35rem; +} + +.magazine-editor__label { + display: flex; + flex-direction: column; + gap: 0.35rem; + margin: 0 0 0.9rem; +} + +.magazine-editor__label-text { + font-size: 0.82rem; + font-weight: 600; + letter-spacing: 0.02em; +} + +.magazine-editor__input, +.magazine-editor__textarea { + width: 100%; + box-sizing: border-box; +} + +.magazine-editor__textarea--mono { + font-family: ui-monospace, monospace; + font-size: 0.82rem; + line-height: 1.45; +} + +.magazine-editor__actions { + margin-top: 1.25rem; + padding-top: 1rem; + border-top: 1px solid var(--color-border); +} diff --git a/assets/styles/notice.css b/assets/styles/notice.css index 7ecc1e3..2b0304d 100644 --- a/assets/styles/notice.css +++ b/assets/styles/notice.css @@ -12,3 +12,9 @@ background-color: var(--color-bg-light); /* Light version of --color-primary */ color: var(--color-text); /* Use theme text color for better contrast */ } + +.notice.error { + background-color: color-mix(in srgb, var(--color-bg-light) 85%, #c0392b); + color: var(--color-text); + border: 1px solid color-mix(in srgb, #c0392b 35%, transparent); +} diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 8245f93..7ae8a20 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -28,4 +28,5 @@ security: access_control: - { path: ^/admin, roles: ROLE_USER } - { path: ^/search, roles: ROLE_USER } + - { path: ^/magazine/edit, roles: ROLE_USER } # - { path: ^/profile, roles: ROLE_USER } diff --git a/src/Controller/LoginController.php b/src/Controller/LoginController.php index 5c2e7de..7c68b08 100644 --- a/src/Controller/LoginController.php +++ b/src/Controller/LoginController.php @@ -19,6 +19,7 @@ class LoginController extends AbstractController if (null !== $user) { return new JsonResponse([ 'message' => 'Authentication Successful', + 'npub' => $user->getNpub(), ], 200); } diff --git a/src/Controller/MagazineEditorController.php b/src/Controller/MagazineEditorController.php new file mode 100644 index 0000000..558f206 --- /dev/null +++ b/src/Controller/MagazineEditorController.php @@ -0,0 +1,87 @@ +getUser(); + if (!$user instanceof User) { + throw $this->createAccessDeniedException(); + } + $configured = (string) $this->getParameter('npub'); + $npub = (string) ($user->getNpub() ?? ''); + if ($npub === '' || !hash_equals($configured, $npub)) { + throw $this->createAccessDeniedException('Only the configured magazine owner npub may use this editor.'); + } + + return $user; + } + + #[Route('/magazine/edit', name: 'magazine_edit')] + #[IsGranted('ROLE_USER')] + public function edit(MagazineHierarchyEditorService $editor, CsrfTokenManagerInterface $csrfTokenManager): Response + { + $this->assertMagazineOwner(); + $payload = $editor->buildEditorPayload(); + + return $this->render('pages/magazine_edit.html.twig', [ + 'editor_payload' => $payload, + 'magazine_edit_csrf' => $csrfTokenManager->getToken('magazine_edit')->getValue(), + ]); + } + + #[Route('/magazine/edit/publish', name: 'magazine_edit_publish', methods: ['POST'])] + #[IsGranted('ROLE_USER')] + public function publish( + Request $request, + MagazineHierarchyPublishService $publishService, + CsrfTokenManagerInterface $csrfTokenManager, + ): JsonResponse { + $user = $this->assertMagazineOwner(); + + try { + $data = json_decode($request->getContent(), true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return new JsonResponse(['ok' => false, 'error' => 'Invalid JSON body'], 400); + } + if (!\is_array($data)) { + return new JsonResponse(['ok' => false, 'error' => 'Invalid JSON body'], 400); + } + + $token = isset($data['csrf']) && \is_string($data['csrf']) ? $data['csrf'] : ''; + if (!$csrfTokenManager->isTokenValid(new CsrfToken('magazine_edit', $token))) { + return new JsonResponse(['ok' => false, 'error' => 'Invalid CSRF token'], 400); + } + + $events = $data['events'] ?? null; + if (!\is_array($events)) { + return new JsonResponse(['ok' => false, 'error' => 'Missing events array'], 400); + } + + $result = $publishService->publishOwnerMagazineBatch($user, $events); + if ($result['ok'] === true) { + return new JsonResponse($result); + } + + return new JsonResponse( + ['ok' => false, 'error' => $result['error']], + min(599, max(400, $result['code'])), + ); + } +} diff --git a/src/Security/NostrAuthenticator.php b/src/Security/NostrAuthenticator.php index 29013e6..464e38d 100644 --- a/src/Security/NostrAuthenticator.php +++ b/src/Security/NostrAuthenticator.php @@ -96,14 +96,17 @@ class NostrAuthenticator extends AbstractAuthenticator implements InteractiveAut /** * Handles successful authentication. * + * Returns null so the request reaches {@see \App\Controller\LoginController}, which responds with JSON + * (including npub). Returning a plain-text body here broke clients that parse JSON from /login. + * * @param Request $request The HTTP request. * @param TokenInterface $token The authenticated token. * @param string $firewallName The firewall name. - * @return Response|null The response to return, or null to continue. + * @return Response|null The response to return, or null to continue to the controller. */ public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response { - return new Response('Authentication Successful', 200); + return null; } /** diff --git a/src/Service/MagazineHierarchyEditorService.php b/src/Service/MagazineHierarchyEditorService.php new file mode 100644 index 0000000..b834e1a --- /dev/null +++ b/src/Service/MagazineHierarchyEditorService.php @@ -0,0 +1,419 @@ +>, + * a_coordinates: list + * }> + * } + */ + public function buildEditorPayload(): array + { + $npub = (string) $this->params->get('npub'); + $dTag = (string) $this->params->get('d_tag'); + $siteName = (string) $this->params->get('name'); + $ownerHex = strtolower($this->nostrKeyHelper->convertToHex($npub)); + + $root = $this->store->getRoot($npub, $dTag); + if ($root === null) { + try { + $fetched = $this->nostrClient->getMagazineIndex($npub, $dTag); + if ($fetched !== null) { + $this->store->putRoot($npub, $dTag, $fetched); + $root = $fetched; + } + } catch (\Throwable) { + } + } + + $parentByChild = $this->buildCategoryParentByChildD($npub, $dTag, $ownerHex); + + $nodes = []; + if ($root !== null) { + $nodes[] = $this->withDepth($this->nodeFromEvent($root, $dTag, true, $ownerHex, $siteName), 0); + } else { + $nodes[] = $this->withDepth($this->emptyRootNode($dTag, $ownerHex, $siteName), 0); + } + + foreach ($this->orderedCategorySlugsForEditor($npub, $dTag, $ownerHex) as $slug) { + $slug = trim($slug); + if ($slug === '') { + continue; + } + $cat = $this->store->getCategory($slug); + if ($cat !== null) { + $nodes[] = $this->withDepth( + $this->nodeFromEvent($cat, $slug, false, $ownerHex, $siteName), + $this->depthForCategorySlug($slug, $dTag, $parentByChild), + ); + } else { + $nodes[] = $this->withDepth( + $this->emptyCategoryNode($slug, $ownerHex, $siteName), + $this->depthForCategorySlug($slug, $dTag, $parentByChild), + ); + } + } + + return [ + 'owner_hex' => $ownerHex, + 'root_d_tag' => $dTag, + 'site_name' => $siteName, + 'nodes' => $nodes, + ]; + } + + /** + * Category #d values in depth-first pre-order: each index is immediately followed by its nested + * kind-30040 children (same order as {@code a} tags), matching the magazine tree rather than BFS. + * Appends any store slugs not linked from the cached root, then orphan refs (unchanged behaviour). + * + * @return list + */ + private function orderedCategorySlugsForEditor(string $npub, string $rootD, string $ownerHex): array + { + $out = []; + $seen = []; + $rootEvent = $this->store->getRoot($npub, $rootD); + if ($rootEvent !== null) { + $dfs = function (string $slug) use (&$dfs, &$out, &$seen, $ownerHex, $rootD): void { + $slug = trim($slug); + if ($slug === '' || hash_equals($rootD, $slug) || isset($seen[$slug])) { + return; + } + $seen[$slug] = true; + $out[] = $slug; + $cat = $this->store->getCategory($slug); + if ($cat === null) { + return; + } + foreach (NostrEventTags::publicationIndexNestedDSlugsForOwner($cat->getTags(), $ownerHex) as $child) { + $dfs($child); + } + }; + foreach (NostrEventTags::publicationIndexNestedDSlugsForOwner($rootEvent->getTags(), $ownerHex) as $child) { + $dfs($child); + } + } + foreach ($this->magazineContent->getCategorySlugsFromStore() as $slug) { + $slug = trim((string) $slug); + if ($slug === '' || isset($seen[$slug])) { + continue; + } + $seen[$slug] = true; + $out[] = $slug; + } + foreach ($this->collectOrphan30040SlugsReferencedInStore($npub, $rootD, $ownerHex, array_keys($seen)) as $slug) { + $slug = trim((string) $slug); + if ($slug === '' || isset($seen[$slug])) { + continue; + } + $seen[$slug] = true; + $out[] = $slug; + } + + return $out; + } + + /** + * @param array{d_tag: string, is_root: bool, title: string, summary: string, content: string, preserved_tags: list>, a_coordinates: list} $node + * + * @return array{ + * d_tag: string, + * is_root: bool, + * depth: int, + * title: string, + * summary: string, + * content: string, + * preserved_tags: list>, + * a_coordinates: list + * } + */ + private function withDepth(array $node, int $depth): array + { + $node['depth'] = $depth; + + return $node; + } + + /** + * Maps each category #d to the parent index #d (root magazine #d for top-level categories). + * First parent wins when multiple indices reference the same child. + * + * @return array + */ + private function buildCategoryParentByChildD(string $npub, string $rootD, string $ownerHex): array + { + $ownerHex = strtolower($ownerHex); + $parentByChild = []; + $rootEvent = $this->store->getRoot($npub, $rootD); + if ($rootEvent !== null) { + foreach (NostrEventTags::publicationIndexNestedDSlugsForOwner($rootEvent->getTags(), $ownerHex) as $childD) { + if ($childD === '' || hash_equals($rootD, $childD)) { + continue; + } + if (!isset($parentByChild[$childD])) { + $parentByChild[$childD] = $rootD; + } + } + } + $listed = $this->magazineContent->getCategorySlugsFromStore(); + foreach ($listed as $slug) { + $slug = trim((string) $slug); + if ($slug === '') { + continue; + } + $cat = $this->store->getCategory($slug); + if ($cat === null) { + continue; + } + foreach (NostrEventTags::publicationIndexNestedDSlugsForOwner($cat->getTags(), $ownerHex) as $childD) { + if ($childD === '' || hash_equals($rootD, $childD)) { + continue; + } + if (!isset($parentByChild[$childD])) { + $parentByChild[$childD] = $slug; + } + } + } + foreach ($this->collectOrphan30040SlugsReferencedInStore($npub, $rootD, $ownerHex, $listed) as $slug) { + $slug = trim((string) $slug); + if ($slug === '') { + continue; + } + $cat = $this->store->getCategory($slug); + if ($cat === null) { + continue; + } + foreach (NostrEventTags::publicationIndexNestedDSlugsForOwner($cat->getTags(), $ownerHex) as $childD) { + if ($childD === '' || hash_equals($rootD, $childD)) { + continue; + } + if (!isset($parentByChild[$childD])) { + $parentByChild[$childD] = $slug; + } + } + } + + return $parentByChild; + } + + /** + * 1 = category linked from the root index; larger = deeper nested kind-30040. + */ + private function depthForCategorySlug(string $slug, string $rootD, array $parentByChild): int + { + if ($slug === $rootD) { + return 0; + } + if (!isset($parentByChild[$slug])) { + return 1; + } + $depth = 1; + $current = $slug; + for ($i = 0; $i < 64; ++$i) { + $parent = $parentByChild[$current] ?? null; + if ($parent === null) { + return $depth; + } + if (hash_equals($rootD, $parent)) { + return $depth; + } + $current = $parent; + ++$depth; + } + + return $depth; + } + + /** + * @return list> + */ + private function defaultCategoryPreservedTags(string $ownerHex, string $siteName): array + { + return [ + ['type', 'magazine'], + ['l', 'en, ISO-639-1'], + ['reading-direction', 'left-to-right, top-to-bottom'], + ['published_by', $siteName], + ['p', $ownerHex], + ]; + } + + /** + * @return array{ + * d_tag: string, + * is_root: bool, + * title: string, + * summary: string, + * content: string, + * preserved_tags: list>, + * a_coordinates: list + * } + */ + private function emptyRootNode(string $dTag, string $ownerHex, string $siteName): array + { + return [ + 'd_tag' => $dTag, + 'is_root' => true, + 'title' => '', + 'summary' => '', + 'content' => '', + 'preserved_tags' => $this->defaultCategoryPreservedTags($ownerHex, $siteName), + 'a_coordinates' => [], + ]; + } + + /** + * @return array{ + * d_tag: string, + * is_root: bool, + * title: string, + * summary: string, + * content: string, + * preserved_tags: list>, + * a_coordinates: list + * } + */ + private function emptyCategoryNode(string $slug, string $ownerHex, string $siteName): array + { + return [ + 'd_tag' => $slug, + 'is_root' => false, + 'title' => $slug, + 'summary' => '', + 'content' => '', + 'preserved_tags' => $this->defaultCategoryPreservedTags($ownerHex, $siteName), + 'a_coordinates' => [], + ]; + } + + /** + * @return array{ + * d_tag: string, + * is_root: bool, + * title: string, + * summary: string, + * content: string, + * preserved_tags: list>, + * a_coordinates: list + * } + */ + private function nodeFromEvent(PublicationEventEntity $event, string $dTag, bool $isRoot, string $ownerHex, string $siteName): array + { + $title = ''; + $summary = ''; + $preserved = []; + $aCoords = []; + foreach ($event->getTags() as $row) { + $seq = NostrEventTags::rowToStringList($row); + if ($seq === null || $seq === []) { + continue; + } + $name = strtolower((string) $seq[0]); + if ($name === 'd') { + continue; + } + if ($name === 'title') { + $title = isset($seq[1]) ? (string) $seq[1] : ''; + + continue; + } + if ($name === 'summary') { + $summary = isset($seq[1]) ? (string) $seq[1] : ''; + + continue; + } + if ($name === 'a') { + $coord = isset($seq[1]) ? trim((string) $seq[1]) : ''; + if ($coord !== '') { + $aCoords[] = $coord; + } + + continue; + } + $preserved[] = array_map(static fn (mixed $v): string => (string) $v, $seq); + } + if ($preserved === []) { + $preserved = $this->defaultCategoryPreservedTags($ownerHex, $siteName); + } + + return [ + 'd_tag' => $dTag, + 'is_root' => $isRoot, + 'title' => $title, + 'summary' => $summary, + 'content' => $event->getContent(), + 'preserved_tags' => $preserved, + 'a_coordinates' => $aCoords, + ]; + } + + /** + * Category #d values that appear in a kind-30040 `a` tag on the root or any stored category + * but are not returned by {@see MagazineContentService::getCategorySlugsFromStore()} (e.g. newly + * linked before the next prewarm BFS). + * + * @param list $alreadyListed + * + * @return list + */ + private function collectOrphan30040SlugsReferencedInStore(string $npub, string $rootD, string $ownerHex, array $alreadyListed): array + { + $have = array_fill_keys($alreadyListed, true); + $out = []; + $events = []; + $r = $this->store->getRoot($npub, $rootD); + if ($r !== null) { + $events[] = $r; + } + foreach ($alreadyListed as $slug) { + $c = $this->store->getCategory($slug); + if ($c !== null) { + $events[] = $c; + } + } + foreach ($events as $event) { + foreach (NostrEventTags::publicationIndexNestedDSlugsForOwner($event->getTags(), $ownerHex) as $childD) { + if ($childD === '' || hash_equals($rootD, $childD) || isset($have[$childD])) { + continue; + } + $have[$childD] = true; + $out[] = $childD; + } + } + + return $out; + } +} diff --git a/src/Service/MagazineHierarchyPublishService.php b/src/Service/MagazineHierarchyPublishService.php new file mode 100644 index 0000000..a5ee860 --- /dev/null +++ b/src/Service/MagazineHierarchyPublishService.php @@ -0,0 +1,270 @@ + $rawEvents Decoded JSON event objects + * + * @return array{ok: true, published: int, stored: int}|array{ok: false, error: string, code: int} + */ + public function publishOwnerMagazineBatch(User $user, array $rawEvents): array + { + $npub = (string) $this->params->get('npub'); + $rootD = (string) $this->params->get('d_tag'); + $userNpub = $user->getNpub() ?? ''; + if ($userNpub === '' || !hash_equals($npub, $userNpub)) { + return ['ok' => false, 'error' => 'Only the configured magazine npub may publish this batch.', 'code' => 403]; + } + $ownerHex = strtolower($this->nostrKeyHelper->convertToHex($npub)); + if ($ownerHex === '' || 64 !== \strlen($ownerHex) || !ctype_xdigit($ownerHex)) { + return ['ok' => false, 'error' => 'Invalid owner pubkey configuration.', 'code' => 500]; + } + + if ($rawEvents === []) { + return ['ok' => false, 'error' => 'No events in batch.', 'code' => 400]; + } + if (\count($rawEvents) > 200) { + return ['ok' => false, 'error' => 'Too many events in one batch.', 'code' => 400]; + } + + /** @var array $byD */ + $byD = []; + foreach ($rawEvents as $i => $raw) { + if (!\is_array($raw)) { + return ['ok' => false, 'error' => 'Invalid event at index '.$i.'.', 'code' => 400]; + } + try { + $json = json_encode($raw, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE); + } catch (\JsonException) { + return ['ok' => false, 'error' => 'Invalid JSON for event at index '.$i.'.', 'code' => 400]; + } + $wire = NostrWireEvent::fromVerified($json); + if ($wire === null) { + return ['ok' => false, 'error' => 'Invalid or unverifiable event at index '.$i.'.', 'code' => 400]; + } + if ($wire->getKind() !== KindsEnum::PUBLICATION_INDEX->value) { + return ['ok' => false, 'error' => 'Event at index '.$i.' must be kind 30040.', 'code' => 400]; + } + if (!hash_equals($ownerHex, strtolower($wire->getPublicKey()))) { + return ['ok' => false, 'error' => 'Event pubkey does not match the magazine owner.', 'code' => 403]; + } + $now = time(); + if ($now - $wire->getCreatedAt() > self::STALE_EVENT_MAX_AGE_SEC || $wire->getCreatedAt() > $now + 60) { + return ['ok' => false, 'error' => 'Event created_at out of range at index '.$i.'.', 'code' => 400]; + } + $d = $this->extractSingleDTag($wire->getTags()); + if ($d === '') { + return ['ok' => false, 'error' => 'Missing or duplicate #d tag at index '.$i.'.', 'code' => 400]; + } + if (isset($byD[$d])) { + return ['ok' => false, 'error' => 'Duplicate #d tag in batch: '.$d.'.', 'code' => 400]; + } + $err = $this->validateTagsForWire($wire->getTags(), $ownerHex); + if ($err !== null) { + return ['ok' => false, 'error' => $err.' (event #d '.$d.')', 'code' => 400]; + } + $byD[$d] = $wire; + } + + $graphErr = $this->validateGraphClosure($byD, $rootD); + if ($graphErr !== null) { + return ['ok' => false, 'error' => $graphErr, 'code' => 400]; + } + + $relays = $this->nostrClient->getArticleWriteRelayUrls(); + if ($relays === []) { + return ['ok' => false, 'error' => 'No write relays configured.', 'code' => 500]; + } + + $published = 0; + foreach ($byD as $wire) { + $res = $this->nostrClient->publishEvent($wire, $relays); + $okRelays = 0; + foreach ($res as $relayRes) { + if ($relayRes instanceof \Throwable) { + continue; + } + ++$okRelays; + } + if ($okRelays < 1) { + $this->logger->warning('magazine_hierarchy.publish_failed_all_relays', [ + 'id' => $wire->getId(), + ]); + + return ['ok' => false, 'error' => 'Publish failed on all relays for event '.$wire->getId().'.', 'code' => 502]; + } + ++$published; + } + + $stored = 0; + foreach ($byD as $d => $wire) { + $entity = $this->wireMerge->magazineEventToPublicationEntity(json_decode($wire->toJson(), true)); + if ($entity === null) { + return ['ok' => false, 'error' => 'Could not map event to storage model (#d '.$d.').', 'code' => 500]; + } + if (hash_equals($rootD, $d)) { + $this->store->putRoot($npub, $rootD, $entity); + } else { + $this->store->putCategory($d, $entity); + } + ++$stored; + } + + $this->logger->info('magazine_hierarchy.published', [ + 'published' => $published, + 'stored' => $stored, + ]); + + return ['ok' => true, 'published' => $published, 'stored' => $stored]; + } + + /** + * @param array $tags + */ + private function validateTagsForWire(array $tags, string $ownerHex): ?string + { + foreach ($tags as $row) { + if (!NostrEventTags::tagNameMatches($row, 'a')) { + continue; + } + $seq = NostrEventTags::rowToStringList($row); + if ($seq === null || !isset($seq[1])) { + return 'Malformed `a` tag'; + } + $coord = trim((string) $seq[1]); + $parts = explode(':', $coord, 3); + if (\count($parts) < 3) { + return 'Invalid `a` coordinate'; + } + $kind = (int) $parts[0]; + $pk = strtolower(trim((string) $parts[1])); + $id = trim((string) $parts[2]); + if ($id === '' || 64 !== \strlen($pk) || !ctype_xdigit($pk)) { + return 'Invalid `a` coordinate pubkey or identifier'; + } + if ($kind === KindsEnum::PUBLICATION_INDEX->value && !hash_equals($ownerHex, $pk)) { + return 'Nested 30040 `a` tags must use the magazine owner pubkey'; + } + if (!\in_array($kind, [ + KindsEnum::PUBLICATION_INDEX->value, + KindsEnum::LONGFORM->value, + KindsEnum::LONGFORM_DRAFT->value, + ], true)) { + return 'Unsupported kind in `a` tag (only 30040, 30023, 30024)'; + } + } + + return null; + } + + /** + * @param array $byD + */ + private function validateGraphClosure(array $byD, string $rootD): ?string + { + if (!isset($byD[$rootD])) { + return 'Batch must include the magazine root index (#d '.$rootD.').'; + } + + $queue = [$rootD]; + $visited = []; + while ($queue !== []) { + $d = array_shift($queue); + if ($d === '' || isset($visited[$d])) { + continue; + } + $visited[$d] = true; + $ev = $byD[$d]; + foreach ($ev->getTags() as $row) { + if (!NostrEventTags::tagNameMatches($row, 'a')) { + continue; + } + $seq = NostrEventTags::rowToStringList($row); + if ($seq === null || !isset($seq[1])) { + continue; + } + $coord = trim((string) $seq[1]); + $parts = explode(':', $coord, 3); + if (\count($parts) < 3) { + return 'Malformed nested coordinate under #d '.$d; + } + $kind = (int) $parts[0]; + if ($kind !== KindsEnum::PUBLICATION_INDEX->value) { + continue; + } + $childD = trim((string) $parts[2]); + if ($childD === '') { + return 'Empty nested magazine #d under #d '.$d; + } + if (!isset($byD[$childD])) { + return 'Every nested kind-30040 `a` tag must have a matching event in this batch (missing #d '.$childD.' referenced from '.$d.').'; + } + if (!isset($visited[$childD])) { + $queue[] = $childD; + } + } + } + + foreach (array_keys($byD) as $d) { + if (!isset($visited[$d])) { + return 'Event #d '.$d.' is not reachable from the magazine root via kind-30040 links.'; + } + } + + return null; + } + + /** + * @param array $tags + */ + private function extractSingleDTag(array $tags): string + { + $found = ''; + foreach ($tags as $row) { + if (!NostrEventTags::tagNameMatches($row, 'd')) { + continue; + } + $seq = NostrEventTags::rowToStringList($row); + if ($seq === null || !isset($seq[1])) { + continue; + } + $v = trim((string) $seq[1]); + if ($v === '') { + continue; + } + if ($found !== '') { + return ''; + } + $found = $v; + } + + return $found; + } +} diff --git a/src/Util/NostrEventTags.php b/src/Util/NostrEventTags.php index 3cbf28b..3a00bd3 100644 --- a/src/Util/NostrEventTags.php +++ b/src/Util/NostrEventTags.php @@ -82,4 +82,47 @@ final class NostrEventTags return $out; } + + /** + * Like {@see publicationIndexNestedDSlugs} but only {@code a} coordinates whose pubkey matches + * {@code $ownerHexLower} (hex). + * + * @param iterable $tagRows + * + * @return list + */ + public static function publicationIndexNestedDSlugsForOwner(iterable $tagRows, string $ownerHexLower): array + { + $ownerHexLower = strtolower($ownerHexLower); + $out = []; + $seen = []; + foreach ($tagRows as $tag) { + if (!self::tagNameMatches($tag, 'a')) { + continue; + } + $seq = self::rowToStringList($tag); + if ($seq === null || !isset($seq[1]) || (string) $seq[1] === '') { + continue; + } + $parts = explode(':', (string) $seq[1], 3); + if (\count($parts) < 3) { + continue; + } + if ((int) ($parts[0] ?? 0) !== KindsEnum::PUBLICATION_INDEX->value) { + continue; + } + $pk = strtolower(trim((string) $parts[1])); + if (!hash_equals($ownerHexLower, $pk)) { + continue; + } + $d = trim((string) $parts[2]); + if ($d === '' || isset($seen[$d])) { + continue; + } + $seen[$d] = true; + $out[] = $d; + } + + return $out; + } } diff --git a/templates/components/Footer.html.twig b/templates/components/Footer.html.twig index 99956a5..72bd3a2 100644 --- a/templates/components/Footer.html.twig +++ b/templates/components/Footer.html.twig @@ -10,6 +10,13 @@