Browse Source

add magazine editor

imwald
Silberengel 1 month ago
parent
commit
35c6f63d58
  1. 1
      assets/app.js
  2. 12
      assets/bootstrap.js
  3. 34
      assets/controllers/footer_magazine_edit_controller.js
  4. 102
      assets/controllers/login_controller.js
  5. 476
      assets/controllers/magazine_hierarchy_editor_controller.js
  6. 5
      assets/styles/layout.css
  7. 92
      assets/styles/magazine-editor.css
  8. 6
      assets/styles/notice.css
  9. 1
      config/packages/security.yaml
  10. 1
      src/Controller/LoginController.php
  11. 87
      src/Controller/MagazineEditorController.php
  12. 7
      src/Security/NostrAuthenticator.php
  13. 419
      src/Service/MagazineHierarchyEditorService.php
  14. 270
      src/Service/MagazineHierarchyPublishService.php
  15. 43
      src/Util/NostrEventTags.php
  16. 7
      templates/components/Footer.html.twig
  17. 17
      templates/components/UserMenu.html.twig
  18. 83
      templates/pages/magazine_edit.html.twig
  19. 4
      translations/messages.en.yaml

1
assets/app.js

@ -19,3 +19,4 @@ import './styles/form.css'; @@ -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';

12
assets/bootstrap.js vendored

@ -5,6 +5,8 @@ import CopyTextController from './controllers/copy_text_controller.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 { @@ -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 */
}

34
assets/controllers/footer_magazine_edit_controller.js

@ -0,0 +1,34 @@ @@ -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;
}
}
}

102
assets/controllers/login_controller.js

@ -2,40 +2,108 @@ import { Controller } from '@hotwired/stimulus'; @@ -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() {
this.clearError();
if (!window.nostr?.signEvent) {
this.showError(this.noExtensionMessageValue);
return;
}
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', window.location.origin + '/login'],
['method', 'POST']
]
['u', loginUrl],
['method', 'POST'],
];
const ev = {
created_at: Math.floor(Date.now() / 1000),
kind: 27235,
tags: tags,
content: ''
}
tags,
content: '',
};
const signed = await window.nostr.signEvent(ev);
// base64 encode and send as Auth header
const result = await fetch('/login', {
const response = await fetch('/login', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Authorization': 'Nostr ' + btoa(JSON.stringify(signed))
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;
}
}).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.
}
if (response.ok && data?.npub) {
void this.component.render();
window.dispatchEvent(
new CustomEvent('unfold:auth-changed', { detail: { loggedIn: true } })
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;
}
}
}
}

476
assets/controllers/magazine_hierarchy_editor_controller.js

@ -0,0 +1,476 @@ @@ -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<Set<string>>}
*/
async expandPublishSet(rootD, dirtyFieldsets, ownerHex) {
/** @type {Map<string, HTMLElement>} */
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<string[][]>}
*/
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<string>}
*/
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<string>}
*/
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}`;
}

5
assets/styles/layout.css

@ -1107,6 +1107,11 @@ footer { @@ -1107,6 +1107,11 @@ footer {
max-width: 100%;
}
/* [hidden] alone is ignored when display:flex is set on these <li>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);

92
assets/styles/magazine-editor.css

@ -0,0 +1,92 @@ @@ -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);
}

6
assets/styles/notice.css

@ -12,3 +12,9 @@ @@ -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);
}

1
config/packages/security.yaml

@ -28,4 +28,5 @@ security: @@ -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 }

1
src/Controller/LoginController.php

@ -19,6 +19,7 @@ class LoginController extends AbstractController @@ -19,6 +19,7 @@ class LoginController extends AbstractController
if (null !== $user) {
return new JsonResponse([
'message' => 'Authentication Successful',
'npub' => $user->getNpub(),
], 200);
}

87
src/Controller/MagazineEditorController.php

@ -0,0 +1,87 @@ @@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\User;
use App\Service\MagazineHierarchyEditorService;
use App\Service\MagazineHierarchyPublishService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\Component\Security\Http\Attribute\IsGranted;
final class MagazineEditorController extends AbstractController
{
private function assertMagazineOwner(): User
{
$user = $this->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'])),
);
}
}

7
src/Security/NostrAuthenticator.php

@ -96,14 +96,17 @@ class NostrAuthenticator extends AbstractAuthenticator implements InteractiveAut @@ -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;
}
/**

419
src/Service/MagazineHierarchyEditorService.php

@ -0,0 +1,419 @@ @@ -0,0 +1,419 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Event as PublicationEventEntity;
use App\Util\NostrEventTags;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
/**
* Builds JSON-safe payloads for the owner-only magazine hierarchy editor (kind 30040 root + categories).
*/
final class MagazineHierarchyEditorService
{
public function __construct(
private readonly MagazineIndexStore $store,
private readonly MagazineContentService $magazineContent,
private readonly NostrClient $nostrClient,
private readonly NostrKeyHelper $nostrKeyHelper,
private readonly ParameterBagInterface $params,
) {
}
/**
* @return array{
* owner_hex: string,
* root_d_tag: string,
* site_name: string,
* nodes: list<array{
* d_tag: string,
* is_root: bool,
* depth: int,
* title: string,
* summary: string,
* content: string,
* preserved_tags: list<list<string>>,
* a_coordinates: list<string>
* }>
* }
*/
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<string>
*/
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<list<string>>, a_coordinates: list<string>} $node
*
* @return array{
* d_tag: string,
* is_root: bool,
* depth: int,
* title: string,
* summary: string,
* content: string,
* preserved_tags: list<list<string>>,
* a_coordinates: list<string>
* }
*/
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<string, string>
*/
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<list<string>>
*/
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<list<string>>,
* a_coordinates: list<string>
* }
*/
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<list<string>>,
* a_coordinates: list<string>
* }
*/
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<list<string>>,
* a_coordinates: list<string>
* }
*/
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<string> $alreadyListed
*
* @return list<string>
*/
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;
}
}

270
src/Service/MagazineHierarchyPublishService.php

@ -0,0 +1,270 @@ @@ -0,0 +1,270 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\User;
use App\Enum\KindsEnum;
use App\Util\NostrEventTags;
use Psr\Log\LoggerInterface;
use swentel\nostr\Event\Event as NostrWireEvent;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
/**
* Validates NIP-07–signed kind-30040 magazine batches from the site owner, publishes to article relays,
* and updates {@see MagazineIndexStore}.
*/
final class MagazineHierarchyPublishService
{
private const STALE_EVENT_MAX_AGE_SEC = 600;
public function __construct(
private readonly NostrClient $nostrClient,
private readonly MagazineIndexStore $store,
private readonly NostrWireEventMerge $wireMerge,
private readonly NostrKeyHelper $nostrKeyHelper,
private readonly ParameterBagInterface $params,
private readonly LoggerInterface $logger,
) {
}
/**
* @param array<int, mixed> $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<string, NostrWireEvent> $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<int, mixed> $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<string, NostrWireEvent> $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<int, mixed> $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;
}
}

43
src/Util/NostrEventTags.php

@ -82,4 +82,47 @@ final class NostrEventTags @@ -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<mixed> $tagRows
*
* @return list<string>
*/
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;
}
}

7
templates/components/Footer.html.twig

@ -10,6 +10,13 @@ @@ -10,6 +10,13 @@
<nav class="site-footer__nav" aria-label="Sitemap, feeds, and index">
<ul class="site-footer__syndication-list">
<li><a class="site-footer__link" href="{{ path('featured_authors') }}">Featured authors</a></li>
<li
data-controller="footer-magazine-edit"
data-footer-magazine-edit-publisher-npub-value="{{ publisher_npub }}"
{% if not (app.user and app.user.userIdentifier == publisher_npub) %}hidden{% endif %}
>
<a class="site-footer__link" href="{{ path('magazine_edit') }}">Edit magazine</a>
</li>
<li><a class="site-footer__link" href="{{ path('sitemap') }}">Sitemap (XML)</a></li>
<li><a class="site-footer__link" href="{{ path('robots_txt') }}">Robots</a></li>
<li class="site-footer__syndication-list__feeds">

17
templates/components/UserMenu.html.twig

@ -1,4 +1,11 @@ @@ -1,4 +1,11 @@
<div class="user-menu{{ inline ? ' user-menu--inline' : '' }}" {{ attributes.defaults(stimulus_controller('login')) }}>
<div
class="user-menu{{ inline ? ' user-menu--inline' : '' }}"
{{ attributes.defaults(stimulus_controller('login', {
noExtensionMessage: 'login.error_no_extension'|trans,
cancelledMessage: 'login.error_cancelled'|trans,
failedMessage: 'login.error_failed'|trans,
})) }}
>
{% if app.user %}
<div class="notice info">
<twig:Molecules:UserFromNpub ident="{{ app.user.npub }}" />
@ -16,7 +23,7 @@ @@ -16,7 +23,7 @@
{# <a href="{{ path('editor-create') }}">Write an article</a>#}
{# </li>#}
<li>
<a href="/logout" data-action="live#$render">{{ 'heading.logout'|trans }}</a>
<a href="/logout" data-action="click->login#authLogout click->live#$render">{{ 'heading.logout'|trans }}</a>
</li>
<li>
<a href="{{ path('search') }}">{{ 'heading.search'|trans }}</a>
@ -26,7 +33,11 @@ @@ -26,7 +33,11 @@
<div class="notice info">
<p>Log in to access search.</p>
</div>
<twig:Atoms:Button {{ ...stimulus_action('login', 'loginAct') }}>{{ 'heading.logIn'|trans }}</twig:Atoms:Button>
<p class="notice error user-menu__login-error" data-login-target="error" role="alert" hidden></p>
<twig:Atoms:Button
data-login-target="submitButton"
{{ ...stimulus_action('login', 'loginAct') }}
>{{ 'heading.logIn'|trans }}</twig:Atoms:Button>
{% endif %}
<div>
<div class="spinner" data-loading>

83
templates/pages/magazine_edit.html.twig

@ -0,0 +1,83 @@ @@ -0,0 +1,83 @@
{% extends 'base.html.twig' %}
{% block title %}Magazine index editor — {{ website_name }}{% endblock %}
{% block meta_description %}
<meta name="description" content="{{ 'Edit kind-30040 magazine hierarchy (owner only).'|e('html_attr') }}">
{% endblock %}
{% block body %}
<div class="card magazine-editor">
<div class="card-header">
<h1 class="card-title">Magazine index editor</h1>
</div>
<div class="card-body">
<p class="magazine-editor__intro">
Edit kind <strong>30040</strong> indices (root, categories, subcategories), then sign with your Nostr extension. Only indices you changed are signed; any other index required so nested <code>a</code> links stay consistent is still included in the same batch (same rules as before).
</p>
<div
class="magazine-editor__panel"
data-controller="magazine-hierarchy-editor"
data-magazine-hierarchy-editor-publish-url-value="{{ path('magazine_edit_publish') }}"
data-magazine-hierarchy-editor-csrf-value="{{ magazine_edit_csrf }}"
data-magazine-hierarchy-editor-owner-hex-value="{{ editor_payload.owner_hex }}"
data-magazine-hierarchy-editor-root-d-tag-value="{{ editor_payload.root_d_tag|e('html_attr') }}"
>
<p class="magazine-editor__status" data-magazine-hierarchy-editor-target="status" aria-live="polite"></p>
<div class="magazine-editor__nodes">
{% for node in editor_payload.nodes %}
{% set depth = node.depth|default(0) %}
<fieldset
class="magazine-editor__node{% if depth > 1 %} magazine-editor__node--nested{% endif %}"
style="--mag-node-depth: {{ depth }}; margin-left: calc(max(0, var(--mag-node-depth) - 1) * 1.15rem);"
data-magazine-hierarchy-editor-target="node"
>
<legend class="magazine-editor__legend">
{% if node.is_root %}
Root index
{% elseif depth > 1 %}
Subcategory
{% else %}
Category
{% endif %}
<code class="magazine-editor__slug">{{ node.d_tag }}</code>
</legend>
<input type="hidden" data-magazine-hierarchy-editor-target="preservedJson" value="{{ node.preserved_tags|json_encode(constant('JSON_UNESCAPED_UNICODE'))|e('html_attr') }}">
<label class="magazine-editor__label">
<span class="magazine-editor__label-text">Title</span>
<input type="text" class="magazine-editor__input" data-magazine-hierarchy-editor-target="title" value="{{ node.title|e('html_attr') }}" autocomplete="off">
</label>
<label class="magazine-editor__label">
<span class="magazine-editor__label-text">Summary</span>
<textarea class="magazine-editor__textarea" data-magazine-hierarchy-editor-target="summary" rows="3">{{ node.summary }}</textarea>
</label>
<label class="magazine-editor__label">
<span class="magazine-editor__label-text">Content</span>
<textarea class="magazine-editor__textarea" data-magazine-hierarchy-editor-target="content" rows="2">{{ node.content }}</textarea>
</label>
<label class="magazine-editor__label">
<span class="magazine-editor__label-text"><code>a</code> coordinates (one per line: <code>kind:hex64pubkey:identifier</code>, long-form <code>30023</code>/<code>30024</code>, or NIP-19 <code>naddr1…</code> / <code>nostr:naddr1…</code> — naddrs are expanded before signing)</span>
<textarea class="magazine-editor__textarea magazine-editor__textarea--mono" data-magazine-hierarchy-editor-target="aLines" rows="8" spellcheck="false">{{ node.a_coordinates|join("\n") }}</textarea>
</label>
<input type="hidden" data-magazine-hierarchy-editor-target="dTag" value="{{ node.d_tag|e('html_attr') }}">
</fieldset>
{% endfor %}
</div>
<div class="magazine-editor__actions">
<button type="button" data-action="click->magazine-hierarchy-editor#publish">
Sign and publish all changed events
</button>
</div>
</div>
</div>
</div>
{% endblock %}

4
translations/messages.en.yaml

@ -15,6 +15,10 @@ text: @@ -15,6 +15,10 @@ text:
search: 'Search...'
searching: 'Searching...'
noResults: 'No results.'
login:
error_no_extension: 'No Nostr signing extension found. Install a Nostr browser extension (e.g. Alby, nos2x) and try again.'
error_cancelled: 'Signing was cancelled or failed. Try again when you are ready.'
error_failed: 'Login was rejected. Check that your extension is unlocked and try again.'
heading:
roles: 'Roles'
logout: 'Log out'

Loading…
Cancel
Save