Compare commits

...

10 Commits

Author SHA1 Message Date
Silberengel f969bc4a57 refactor caching/DB 6 days ago
Silberengel 7e6f172731 fix replacement of categories 6 days ago
Silberengel ba5332c0d1 fix build 6 days ago
Silberengel a7f35ff173 fix styling 6 days ago
Silberengel f6703e2f4b add event menus 7 days ago
Silberengel 8100706698 add monolog 7 days ago
Silberengel 8e12daf8bb replace articles 7 days ago
Silberengel 7143a816dd update categories 7 days ago
Silberengel a155bebd05 bug-fixes 7 days ago
Silberengel 46460e7828 fix responsiveness 7 days ago
  1. 2
      .env.dist
  2. 4
      Dockerfile
  3. 3
      README.md
  4. 9
      assets/controllers/article_comments_controller.js
  5. 6
      assets/controllers/progress_bar_controller.js
  6. 19
      assets/styles/app.css
  7. 91
      assets/styles/article.css
  8. 6
      assets/styles/event.css
  9. 382
      assets/styles/layout.css
  10. 6
      assets/styles/nostr-previews.css
  11. 3
      compose.hub.yaml
  12. 6
      compose.yaml
  13. 4
      composer.json
  14. 269
      composer.lock
  15. 1
      config/bundles.php
  16. 6
      config/packages/cache.yaml
  17. 72
      config/packages/monolog.yaml
  18. 18
      config/services.yaml
  19. 12
      config/unfold.yaml
  20. 3
      frankenphp/docker-entrypoint.sh
  21. 31
      migrations/Version20260424130000.php
  22. 16
      src/Command/PrewarmCommand.php
  23. 95
      src/Controller/ArticleController.php
  24. 5
      src/Controller/AuthorController.php
  25. 28
      src/Controller/EventController.php
  26. 3
      src/Controller/FeaturedAuthorsController.php
  27. 32
      src/Controller/HealthController.php
  28. 36
      src/Controller/SeoController.php
  29. 21
      src/Dto/NostrShareMenuContext.php
  30. 41
      src/Entity/Event.php
  31. 62
      src/Nostr/MagazineEventKeys.php
  32. 73
      src/Nostr/Nip19Addressable.php
  33. 5
      src/Repository/ArticleRepository.php
  34. 25
      src/Repository/EventRepository.php
  35. 2
      src/Service/ArticleCommentThreadLoader.php
  36. 312
      src/Service/CacheService.php
  37. 184
      src/Service/MagazineContentService.php
  38. 126
      src/Service/MagazineIndexStore.php
  39. 117
      src/Service/MagazineRefresher.php
  40. 177
      src/Service/Nip09DeletionApplier.php
  41. 1056
      src/Service/NostrClient.php
  42. 52
      src/Service/NostrPathHelper.php
  43. 402
      src/Service/NostrShareMenuBuilder.php
  44. 3
      src/Twig/Components/Molecules/CategoryLink.php
  45. 5
      src/Twig/Components/UserMenu.php
  46. 65
      src/Twig/MagazineJumbleExtension.php
  47. 40
      src/Twig/NostrPathExtension.php
  48. 63
      src/Twig/NostrShareMenuExtension.php
  49. 45
      src/Util/NostrEventTags.php
  50. 12
      symfony.lock
  51. 3
      templates/components/Footer.html.twig
  52. 7
      templates/components/Header.html.twig
  53. 7
      templates/components/Molecules/Card.html.twig
  54. 34
      templates/components/Molecules/NostrPreviewContent.html.twig
  55. 40
      templates/components/Molecules/NostrShareMenu.html.twig
  56. 2
      templates/components/Organisms/CardList.html.twig
  57. 18
      templates/components/Organisms/Comments.html.twig
  58. 4
      templates/components/Organisms/FeaturedList.html.twig
  59. 2
      templates/components/UserMenu.html.twig
  60. 2
      templates/event/index.html.twig
  61. 6
      templates/pages/article.html.twig
  62. 1
      templates/pages/author.html.twig
  63. 7
      templates/pages/category.html.twig
  64. 5
      templates/pages/featured_authors.html.twig
  65. 8
      templates/partial/author_profile_header.html.twig

2
.env.dist

@ -44,6 +44,8 @@ MYSQL_ROOT_PASSWORD=root_password
# After changing, recreate: `docker compose up -d --force-recreate cron` (dev) or # After changing, recreate: `docker compose up -d --force-recreate cron` (dev) or
# `docker compose -f compose.hub.yaml up -d --force-recreate prewarm` (hub). # `docker compose -f compose.hub.yaml up -d --force-recreate prewarm` (hub).
# PREWARM_FLAGS= # PREWARM_FLAGS=
# Comma-separated magazine category #d slugs to refresh first when app:prewarm runs out of time before all categories (see MagazineRefresher).
# MAGAZINE_PREWARM_PREFER_SLUGS=
# compose.hub.yaml: default host port is 9080. Use 80 only if nothing else binds it. Loopback-only example: # compose.hub.yaml: default host port is 9080. Use 80 only if nothing else binds it. Loopback-only example:
# HTTP_PUBLISH=127.0.0.1:9080 # HTTP_PUBLISH=127.0.0.1:9080
# HTTP_PUBLISH=80 # HTTP_PUBLISH=80

4
Dockerfile

@ -56,9 +56,9 @@ COPY --link frankenphp/Caddyfile /etc/caddy/Caddyfile
ENTRYPOINT ["docker-entrypoint"] ENTRYPOINT ["docker-entrypoint"]
# Hit the public HTTP server, not Caddy :2019 admin (not always available the same way in all setups). # App liveness: GET /health (no DB/Nostr; see HealthController)
HEALTHCHECK --interval=10s --timeout=5s --retries=6 --start-period=120s \ HEALTHCHECK --interval=10s --timeout=5s --retries=6 --start-period=120s \
CMD curl -fsS http://127.0.0.1/ -o /dev/null || exit 1 CMD curl -fsS http://127.0.0.1/health -o /dev/null || exit 1
CMD [ "frankenphp", "run", "--config", "/etc/caddy/Caddyfile" ] CMD [ "frankenphp", "run", "--config", "/etc/caddy/Caddyfile" ]
# Dev FrankenPHP image # Dev FrankenPHP image

3
README.md

@ -81,7 +81,7 @@ make prewarm
| `--metadata-batch` | `50` | Pubkeys per batched Nostr `REQ` | | `--metadata-batch` | `50` | Pubkeys per batched Nostr `REQ` |
| `--comments-max` | `10` | Newest **N** articles (by `createdAt` **DESC**); `0` = all (still bounded by budget) | | `--comments-max` | `10` | Newest **N** articles (by `createdAt` **DESC**); `0` = all (still bounded by budget) |
| `--comments-budget` | `600` | Max wall seconds for the whole comments phase (Nostr is slow; raise e.g. `1200` if you need more articles in one run) | | `--comments-budget` | `600` | Max wall seconds for the whole comments phase (Nostr is slow; raise e.g. `1200` if you need more articles in one run) |
| `--magazine-budget` | `30` | Max wall seconds for magazine refresh | | `--magazine-budget` | `90` | Max wall seconds for magazine root + per-category 30040 fetches (hard-capped at 600s in code). If you have many categories, a **low** budget can stop before the last slug is refreshed—**stale home/category pages** until the next run. Set `MAGAZINE_PREWARM_PREFER_SLUGS` (comma-separated category `#d` slugs) to fetch those first after the root. |
Prewarm clears the PHP **CLI** execution time limit for that run; relay work can be slow. Prewarm clears the PHP **CLI** execution time limit for that run; relay work can be slow.
@ -100,6 +100,7 @@ For a full **Nostr backfill** + one-shot prewarm, use **`make prewarm`** (or a h
| What | File | | What | File |
|------|------| |------|------|
| Site title, `npub`, `d_tag`, **relays** (`default_relay`, `article_relays`, `profile_relays`), theme | `config/unfold.yaml` (imported as Symfony parameters) | | Site title, `npub`, `d_tag`, **relays** (`default_relay`, `article_relays`, `profile_relays`), theme | `config/unfold.yaml` (imported as Symfony parameters) |
| `MAGAZINE_PREWARM_PREFER_SLUGS` | `.env` / `.env.local` — optional comma-separated category slugs to prioritize in `app:prewarm` magazine phase (after the root). Use when the relay time budget would otherwise skip your updated category. |
| `DATABASE_URL`, `APP_SECRET`, `HTTP_PORT`, `MYSQL_*`, optional **`PREWARM_FLAGS`** (for the Docker `cron` service) | `.env` / `.env.local` (see `.env.dist`) | | `DATABASE_URL`, `APP_SECRET`, `HTTP_PORT`, `MYSQL_*`, optional **`PREWARM_FLAGS`** (for the Docker `cron` service) | `.env` / `.env.local` (see `.env.dist`) |
| Service wiring (e.g. cache, `NostrClient` args) | `config/services.yaml` | | Service wiring (e.g. cache, `NostrClient` args) | `config/services.yaml` |

9
assets/controllers/article_comments_controller.js

@ -62,6 +62,10 @@ export default class extends Controller {
throw new Error(`HTTP ${res.status}`); throw new Error(`HTTP ${res.status}`);
} }
const html = await res.text(); const html = await res.text();
if (!this.hasContainerTarget) {
window.clearTimeout(timer);
return;
}
this.containerTarget.innerHTML = html; this.containerTarget.innerHTML = html;
const ms = Math.round(performance.now() - t0); const ms = Math.round(performance.now() - t0);
if (attempt > 1) { if (attempt > 1) {
@ -76,13 +80,18 @@ export default class extends Controller {
if (attempt < maxAttempts) { if (attempt < maxAttempts) {
const delay = 1_200 * 2 ** (attempt - 1); const delay = 1_200 * 2 ** (attempt - 1);
await new Promise((r) => setTimeout(r, delay)); await new Promise((r) => setTimeout(r, delay));
if (!this.hasContainerTarget) {
return;
}
continue; continue;
} }
const ms = Math.round(performance.now() - t0); const ms = Math.round(performance.now() - t0);
console.warn(`[article-comments] fragment failed after ${ms}ms`, this.urlValue, err); console.warn(`[article-comments] fragment failed after ${ms}ms`, this.urlValue, err);
if (this.hasContainerTarget) {
this.containerTarget.innerHTML = this.containerTarget.innerHTML =
'<p class="text-subtle">Comments could not be loaded.</p>'; '<p class="text-subtle">Comments could not be loaded.</p>';
} }
} }
} }
} }
}

6
assets/controllers/progress_bar_controller.js

@ -7,8 +7,10 @@ export default class extends Controller {
static targets = ['bar']; static targets = ['bar'];
connect() { connect() {
this.boundHandleInteraction = this.handleInteraction.bind(this); // Bind once per controller instance so reconnects match disconnect()'s
this.boundPageShow = this.onPageShow.bind(this); // removeEventListener; new .bind() references each connect() would leave stale listeners.
this.boundHandleInteraction ??= this.handleInteraction.bind(this);
this.boundPageShow ??= this.onPageShow.bind(this);
document.addEventListener('click', this.boundHandleInteraction); document.addEventListener('click', this.boundHandleInteraction);
document.addEventListener('touchstart', this.handleTouchStart); document.addEventListener('touchstart', this.handleTouchStart);
document.addEventListener('touchend', this.handleTouchEnd); document.addEventListener('touchend', this.handleTouchEnd);

19
assets/styles/app.css

@ -325,16 +325,21 @@ div:nth-child(odd) .featured-list {
.header__logo .brand { .header__logo .brand {
font-size: clamp(1rem, 4.2vw, 1.45rem); font-size: clamp(1rem, 4.2vw, 1.45rem);
gap: 0.35rem; gap: 0.35rem;
line-height: 1.2; /* Tight line-height + overflow:hidden on .brand__title clip ascenders; keep room for type. */
line-height: 1.35;
justify-content: flex-start; justify-content: flex-start;
text-align: left; text-align: left;
} }
.brand__title { .brand__title {
flex: 1; flex: 1;
min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
line-height: 1.35;
/* Padding inside the clipping box so Lobster/serif caps aren’t sheared at the top */
padding: 0.2em 0 0.12em;
} }
.header__logo-circle { .header__logo-circle {
@ -346,6 +351,10 @@ div:nth-child(odd) .featured-list {
flex-shrink: 0; flex-shrink: 0;
margin-left: 0; margin-left: 0;
} }
.header__end {
flex-shrink: 0;
}
} }
/* Fixed square + overflow clips to a true circle. Logo img is out-of-flow so /* Fixed square + overflow clips to a true circle. Logo img is out-of-flow so
@ -885,6 +894,14 @@ label.search {
gap: 0.5rem; gap: 0.5rem;
} }
.nostr-card-header__actions {
display: inline-flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.ui-badge { .ui-badge {
display: inline-block; display: inline-block;
padding: 0.2rem 0.55rem; padding: 0.2rem 0.55rem;

91
assets/styles/article.css

@ -34,6 +34,41 @@
border-bottom: 1px solid var(--color-text); border-bottom: 1px solid var(--color-text);
} }
.card-header--article {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.75rem;
flex-wrap: wrap;
/* .card-header { overflow: hidden } would clip the ⋯ dropdown; following siblings can paint on top. */
overflow: visible;
position: relative;
z-index: 5;
}
.card-header--article .card-title {
flex: 1 1 12rem;
min-width: 0;
margin: 0;
}
/* Sibling .category-body would paint over the ⋯ popover; lift the title card above the list. */
.category-page__header-card {
position: relative;
z-index: 6;
}
.card.comment .metadata.comment-card__head {
align-items: flex-start;
}
.card.comment .metadata__end {
display: flex;
align-items: center;
gap: 0.4rem;
flex-shrink: 0;
}
.byline { .byline {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -128,21 +163,49 @@ blockquote p {
gap: 0.35rem; gap: 0.35rem;
} }
/* Thread depth: light indent, max visual level 3 (deeper uses --depth-3) */ /* Tracebacks: same flex+gap as .comments so spacing isn’t lost to margin collapse or .card { margin } from app.css */
.comments .card.comment--depth-0 { .comments-quotes__list {
margin-left: 0; display: flex;
flex-direction: column;
gap: 0.4rem;
min-height: 0;
}
.comments-quotes__list > .card.comment--quote {
margin: 0; /* override app.css .card { margin-bottom: 50px } */
flex-shrink: 0;
}
.comments-quotes__list .card.comment--quote .card-footer.nostr-previews {
margin-top: 0.75rem;
} }
.comments .card.comment--depth-1 { /* Thread: no depth indent; one accent color for all replies; compact vertical rhythm */
margin-left: 0.28rem; .comments {
display: flex;
flex-direction: column;
gap: 0.4rem;
} }
.comments .card.comment--depth-2 { .comments .card.comment {
margin-left: 0.6rem; margin-left: 0;
margin-bottom: 0;
padding: 0.5rem 0.65rem 0.5rem 0.7rem;
border-radius: 6px;
border: 1px solid var(--color-border);
border-left: 3px solid var(--color-primary);
} }
.comments .card.comment--depth-0,
.comments .card.comment--depth-1,
.comments .card.comment--depth-2,
.comments .card.comment--depth-3 { .comments .card.comment--depth-3 {
margin-left: 0.95rem; margin-left: 0;
border-left-color: var(--color-primary);
}
.comments .card.comment .metadata {
margin-bottom: 0.4rem;
} }
.comment__reply-blurb { .comment__reply-blurb {
@ -185,13 +248,13 @@ blockquote p {
} }
.comment-reply { .comment-reply {
margin-top: 1rem; margin-top: 0.45rem;
padding-top: 1rem; padding-top: 0.45rem;
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
} }
.comment-reply--article { .comment-reply--article {
margin-bottom: 1.5rem; margin-bottom: 0.75rem;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 6px; border-radius: 6px;
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
@ -219,8 +282,8 @@ blockquote p {
} }
.comment-reply__toolbar--inline { .comment-reply__toolbar--inline {
margin-bottom: 0.25rem; margin-bottom: 0.15rem;
margin-top: 0.5rem; margin-top: 0.3rem;
justify-content: flex-end; justify-content: flex-end;
} }
@ -243,7 +306,7 @@ blockquote p {
} }
.comment-reply--nested { .comment-reply--nested {
margin-top: 0.5rem; margin-top: 0.3rem;
} }
.comment-reply__head { .comment-reply__head {

6
assets/styles/event.css

@ -88,6 +88,12 @@
} }
.event-page__meta { .event-page__meta {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem;
width: 100%;
color: var(--color-text-mid); color: var(--color-text-mid);
font-size: 0.95rem; font-size: 0.95rem;
} }

382
assets/styles/layout.css

@ -16,6 +16,7 @@
.layout { .layout {
max-width: 100%; max-width: 100%;
width: 1200px; width: 1200px;
min-width: 0; /* flex child of body: allow shrink so children don’t force page width */
margin: 0 auto; margin: 0 auto;
display: flex; display: flex;
flex-grow: 1; flex-grow: 1;
@ -49,18 +50,22 @@ nav a:hover {
text-decoration: none; text-decoration: none;
} }
header { /* Only the app chrome in Header.html.twig (#site-header). A bare `header` rule also
matched <header class="featured-authors__intro"> and fixed it under the real bar, hiding it. */
#site-header {
position: fixed; position: fixed;
width: 100vw; /* Use inset instead of 100vw: 100vw includes the vertical scrollbar and causes horizontal overflow on many viewports. */
top: 0;
left: 0; left: 0;
right: 0;
top: 0;
width: auto;
box-sizing: border-box; box-sizing: border-box;
} }
/* Desktop: breathing room under the browser chrome. Mobile gets inset via /* Desktop: breathing room under the browser chrome. Mobile gets inset via
.header__logo padding in the max-width block below. */ .header__logo padding in the max-width block below. */
@media (min-width: 1025px) { @media (min-width: 1025px) {
header { #site-header {
padding-top: max(0.65rem, env(safe-area-inset-top, 0px)); padding-top: max(0.65rem, env(safe-area-inset-top, 0px));
} }
} }
@ -72,6 +77,96 @@ header {
font-size: 26px; font-size: 26px;
} }
/* Trailing tools: Nostr ⋯ menu + hamburger (mobile) */
.header__end {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 0.4rem;
}
/* NIP-19 share menu (header) */
.nostr-share-menu {
position: relative;
list-style: none;
}
.nostr-share-menu__trigger {
display: inline-flex;
align-items: center;
gap: 0.2rem;
min-width: 2.25rem;
font-size: 0.9rem;
line-height: 1.2;
padding: 0.2rem 0.45rem;
list-style: none;
}
.nostr-share-menu__label {
font-size: 0.85rem;
font-weight: 600;
white-space: nowrap;
}
.nostr-share-menu__glyph {
font-size: 1.1rem;
line-height: 1;
opacity: 0.9;
}
.nostr-share-menu__trigger::-webkit-details-marker {
display: none;
}
.nostr-share-menu__list {
position: absolute;
z-index: 1002;
right: 0;
top: calc(100% + 4px);
margin: 0;
padding: 0.35rem 0;
min-width: 12rem;
list-style: none;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
.nostr-share-menu__item {
margin: 0;
padding: 0;
}
.nostr-share-menu__action {
display: block;
width: 100%;
text-align: left;
padding: 0.45rem 0.75rem;
font: inherit;
color: var(--color-text, inherit);
text-decoration: none;
background: none;
border: none;
cursor: pointer;
border-radius: 0;
}
.nostr-share-menu__action:hover,
.nostr-share-menu__action:focus-visible {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
}
a.nostr-share-menu__action {
color: var(--color-primary, inherit);
}
.nostr-share-menu--event .nostr-share-menu__trigger {
min-width: auto;
padding: 0.15rem 0.4rem;
font-size: 1rem;
}
.header__logo { .header__logo {
display: flex; display: flex;
width: 100%; width: 100%;
@ -133,7 +228,8 @@ header {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
padding: 0.4rem max(0.65rem, env(safe-area-inset-left)) 0.4rem max(0.65rem, env(safe-area-inset-right)); /* Top: safe area (notch) + room so the site title isn’t flush under the browser chrome */
padding: max(0.5rem, env(safe-area-inset-top, 0px)) max(0.65rem, env(safe-area-inset-left)) 0.45rem max(0.65rem, env(safe-area-inset-right));
} }
.header__brand { .header__brand {
@ -147,6 +243,7 @@ header {
display: none; display: none;
flex-direction: column; flex-direction: column;
padding-top: 10px; padding-top: 10px;
padding-bottom: max(1rem, env(safe-area-inset-bottom, 0px));
} }
.header__categories.active { .header__categories.active {
@ -162,6 +259,41 @@ header {
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
} }
/* Log in / account block below category links in the hamburger */
.header__mobile-account {
align-self: stretch;
text-align: left;
width: 100%;
max-width: 32rem;
margin: 0 auto;
padding: 0.75rem 0.25rem 0;
border-top: 1px solid var(--color-border);
}
}
/* Hide the duplicate hamburger user menu on wide screens (sidebar <nav> has the real menu). */
@media (min-width: 1025px) {
.header__mobile-account {
display: none;
}
/* Center the title; keep Nostr menu + hamburger on the right without shifting the brand. */
.header__logo {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
}
.header__brand {
grid-column: 2;
justify-self: center;
}
.header__end {
grid-column: 3;
justify-self: end;
}
} }
/* Main content */ /* Main content */
@ -192,6 +324,16 @@ main {
} }
} }
/* After .user-menu so this wins: hamburger copy stays in flow, not position:fixed. */
.user-menu.user-menu--inline {
position: static;
width: 100%;
min-width: 0;
max-width: none;
top: auto;
left: auto;
}
.user-nav { .user-nav {
padding: 10px; padding: 10px;
margin: 10px 0; margin: 10px 0;
@ -228,11 +370,15 @@ dt {
/* Responsive adjustments */ /* Responsive adjustments */
@media (max-width: 1024px) { @media (max-width: 1024px) {
nav, aside { /* Only the main column nav, not <nav> in the footer (Sitemap and feeds, etc.) */
.layout > nav,
aside {
display: none; /* Hide the sidebars on small screens */ display: none; /* Hide the sidebars on small screens */
} }
/* Fixed header is taller than 90px (safe-area + logo row + title padding). Match it or the first
main content (e.g. featured authors intro) sits under the bar and looks cut off at the top. */
main { main {
margin-top: 90px; margin-top: max(7.25rem, calc(4.8rem + env(safe-area-inset-top, 0px)));
width: 100%; width: 100%;
} }
} }
@ -244,6 +390,8 @@ footer {
padding: 1.25rem 1rem 1.5rem; padding: 1.25rem 1rem 1.5rem;
position: relative; position: relative;
width: 100%; width: 100%;
min-width: 0; /* flex child of body column: avoid min-content width wider than the viewport */
box-sizing: border-box;
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
} }
@ -255,6 +403,9 @@ footer {
align-items: stretch; align-items: stretch;
gap: 1.75rem; gap: 1.75rem;
text-align: left; text-align: left;
min-width: 0;
width: 100%;
box-sizing: border-box;
} }
.site-footer__syndication-title { .site-footer__syndication-title {
@ -271,8 +422,23 @@ footer {
max-width: 40rem; max-width: 40rem;
} }
/* Footer <nav> must not use the main-column nav rule (width: 21vw), or the list stays ~1/5 of the screen. */
.site-footer__nav { .site-footer__nav {
max-width: 44rem; width: 100%;
max-width: 100%;
min-width: 0;
flex-shrink: 1;
padding: 0;
overflow-y: visible;
}
.site-footer__nav li {
margin: 0; /* not nav li { margin: 0.5em 0 } */
}
.site-footer__syndication {
min-width: 0;
max-width: 100%;
} }
.site-footer__syndication-list { .site-footer__syndication-list {
@ -285,13 +451,19 @@ footer {
padding: 0; padding: 0;
font-size: 0.95rem; font-size: 0.95rem;
line-height: 1.5; line-height: 1.5;
min-width: 0;
max-width: 100%;
} }
/* Do not set min-width:0 on <li> with flex it lets items shrink to a hairline and
forces one-word-per-line wrapping. Use natural (content) size per item instead. */
.site-footer__syndication-list > li { .site-footer__syndication-list > li {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
gap: 0.4rem 0.45rem; gap: 0.4rem 0.45rem;
flex: 0 0 auto;
max-width: 100%;
} }
.site-footer__syndication-list > li + li::before { .site-footer__syndication-list > li + li::before {
@ -320,11 +492,89 @@ footer {
/* RSS + category feed links in one cell */ /* RSS + category feed links in one cell */
.site-footer__syndication-list__feeds { .site-footer__syndication-list__feeds {
display: inline-flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
gap: 0.4rem 0.45rem; gap: 0.4rem 0.45rem;
min-width: 0;
max-width: 100%; max-width: 100%;
/* Break only unbroken long words; avoid overflow-wrap:anywhere (splits at every symbol). */
word-break: break-word;
overflow-wrap: break-word;
}
/* Narrow / tablet: feeds row full width; tighter footer so it doesn’t dominate the viewport. */
@media (max-width: 1024px) {
footer {
padding: 0.65rem 0.75rem 0.75rem;
}
.site-footer {
gap: 0.65rem;
}
.site-footer__syndication-title {
font-size: 0.95rem;
margin: 0 0 0.15rem;
}
.site-footer__syndication-hint {
margin: 0 0 0.35rem;
font-size: 0.8rem;
line-height: 1.35;
}
.site-footer__syndication-list {
row-gap: 0.15rem;
font-size: 0.88rem;
line-height: 1.35;
}
.site-footer__syndication-list > li {
gap: 0.25rem 0.35rem;
}
.site-footer__syndication-list__feeds {
flex: 1 1 100%;
gap: 0.25rem 0.35rem;
}
.site-footer__main {
margin-top: 0;
}
.site-footer__legal {
margin: 0.35rem 0 0;
font-size: 0.85rem;
line-height: 1.35;
}
footer .footer-links {
margin: 0 0 0.3rem;
}
.footer-links .footer-link {
margin: 0.2rem 0;
line-height: 1.35;
font-size: 0.88rem;
}
}
/* Single-column footer: center both syndication and main. (900px+ uses side-by-side layout.) */
@media (max-width: 899px) {
.site-footer {
text-align: center;
}
.site-footer__syndication-hint {
margin-left: auto;
margin-right: auto;
}
.site-footer__syndication-list,
.site-footer__syndication-list__feeds {
justify-content: center;
}
} }
/* Dots between feed links (skip first <a> = "All articles"). */ /* Dots between feed links (skip first <a> = "All articles"). */
@ -349,6 +599,11 @@ footer {
text-align: center; text-align: center;
} }
.site-footer__jumble {
margin: 0 0 0.65rem;
font-size: 0.95rem;
}
.site-footer__legal { .site-footer__legal {
margin: 1rem 0 0; margin: 1rem 0 0;
font-size: 0.95rem; font-size: 0.95rem;
@ -389,28 +644,131 @@ footer .footer-links {
.featured-authors__intro { .featured-authors__intro {
margin-bottom: 2rem; margin-bottom: 2rem;
overflow: visible; /* do not clip heading ascenders */
} }
/* Override global h1 (3.2rem + tight line box); keep full glyphs visible */
.featured-authors__intro h1 { .featured-authors__intro h1 {
margin-top: 0; margin: 0 0 0.5rem;
font-size: clamp(1.35rem, 2.6vw, 2.05rem);
line-height: 1.28;
font-weight: 500;
font-family: var(--heading-font), serif;
color: var(--color-primary);
padding: 0.2em 0 0.05em;
overflow: visible;
} }
.featured-authors__card { .featured-authors__card {
margin-bottom: 2.5rem; margin-bottom: 2.5rem;
padding-bottom: 1.5rem; padding-bottom: 1.5rem;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
box-sizing: border-box;
width: 100%;
} }
.featured-authors__card:last-of-type { .featured-authors__card:last-of-type {
border-bottom: none; border-bottom: none;
} }
/* One shared content width: drop the 40rem cap so label/value grid lines line up with the card edges */
.featured-authors__card .author-profile.author-profile--featured {
max-width: none;
width: 100%;
margin-left: 0;
margin-right: 0;
box-sizing: border-box;
}
.author-profile--featured .author-profile__header-meta {
max-width: none;
width: 100%;
}
/* Same first-column width for section blocks and per-row fields so dividers don’t “jump” between cards */
.author-profile--featured .author-profile__section--label-value,
.author-profile--featured .author-profile__meta-line,
.author-profile--featured .author-profile__identity-row,
.author-profile--featured .author-profile__payment {
grid-template-columns: minmax(5.25rem, 8.5rem) minmax(0, 1fr);
align-items: start;
column-gap: 0.65rem;
}
.author-profile--featured .author-profile__title { .author-profile--featured .author-profile__title {
font-size: 1.5rem; font-size: 1.5rem;
} }
.featured-authors__more { /* Very narrow: single column so tiny two-column cells don’t look skewed */
margin: 0.75rem 0 0; @media (max-width: 30rem) {
.author-profile--featured .author-profile__section--label-value,
.author-profile--featured .author-profile__meta-line,
.author-profile--featured .author-profile__identity-row,
.author-profile--featured .author-profile__payment {
grid-template-columns: 1fr;
row-gap: 0.2rem;
}
.author-profile--featured .author-profile__meta-line,
.author-profile--featured .author-profile__identity-row,
.author-profile--featured .author-profile__payment {
margin: 0.5rem 0;
}
.author-profile--featured .author-profile__meta-value,
.author-profile--featured .author-profile__identity-link,
.author-profile--featured .author-profile__payment-addr {
white-space: normal;
word-break: break-word;
}
}
.featured-authors__actions {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
flex-shrink: 0;
align-items: center;
justify-content: center;
column-gap: 0.75rem;
margin: 1rem 0 0;
width: 100%;
box-sizing: border-box;
}
.featured-authors__actions .btn {
flex: 0 1 auto;
text-align: center;
}
/* Narrow: smaller page title + intro; flex gap avoids margin collapse with first author card. */
@media (max-width: 1024px) {
.featured-authors {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 1.75rem;
}
.featured-authors__intro {
margin-bottom: 0;
/* Contain the intro <p> margin so it doesn’t collapse with the first author block */
display: flow-root;
}
.featured-authors__card {
margin-bottom: 0;
}
.featured-authors__intro h1 {
/* Slightly smaller in the max-1024 layout; same visible box as the base h1 */
font-size: clamp(1.3rem, 4.2vw, 1.95rem);
}
.featured-authors__intro p {
font-size: 0.9rem;
line-height: 1.45;
}
} }
.footer-links a { .footer-links a {

6
assets/styles/nostr-previews.css

@ -85,6 +85,12 @@
gap: 0.35rem; gap: 0.35rem;
} }
.nostr-preview-card__menu {
display: flex;
justify-content: flex-end;
margin-bottom: 0.35rem;
}
.nostr-previews h6 { .nostr-previews h6 {
font-size: 0.9rem; font-size: 0.9rem;
margin-bottom: 1rem; margin-bottom: 1rem;

3
compose.hub.yaml

@ -41,8 +41,9 @@ services:
- "${HTTP_PUBLISH:-9080}:80/tcp" - "${HTTP_PUBLISH:-9080}:80/tcp"
# Caddy/FrankenPHP only listen after the entrypoint finishes DB wait + migrations — allow a slow # Caddy/FrankenPHP only listen after the entrypoint finishes DB wait + migrations — allow a slow
# first MySQL + migrate on a small host (avoids "unhealthy" + failed `up` for dependents). # first MySQL + migrate on a small host (avoids "unhealthy" + failed `up` for dependents).
# Liveness: GET /health (see HealthController), not /.
healthcheck: healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1/", "-o", "/dev/null"] test: ["CMD", "curl", "-fsS", "http://127.0.0.1/health", "-o", "/dev/null"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 10 retries: 10

6
compose.yaml

@ -4,10 +4,10 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
# Overrides Dockerfile HEALTHCHECK: verify Caddy/FrankenPHP serves the app (not Caddy :2019 admin, # Overrides Dockerfile HEALTHCHECK: lightweight app route (see HealthController), not / (magazine + relays).
# which is unreliable for “ready”). `docker compose up --wait` requires this to pass. # `docker compose up --wait` requires this to pass.
healthcheck: healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1/", "-o", "/dev/null"] test: ["CMD", "curl", "-fsS", "http://127.0.0.1/health", "-o", "/dev/null"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 6 retries: 6

4
composer.json

@ -33,9 +33,11 @@
"symfony/html-sanitizer": "7.1.*", "symfony/html-sanitizer": "7.1.*",
"symfony/http-foundation": "7.1.*", "symfony/http-foundation": "7.1.*",
"symfony/intl": "7.1.*", "symfony/intl": "7.1.*",
"symfony/monolog-bridge": "7.1.*",
"symfony/monolog-bundle": "^3.11",
"symfony/process": "7.1.*",
"symfony/property-access": "7.1.*", "symfony/property-access": "7.1.*",
"symfony/property-info": "7.1.*", "symfony/property-info": "7.1.*",
"symfony/process": "7.1.*",
"symfony/runtime": "7.1.*", "symfony/runtime": "7.1.*",
"symfony/security-bundle": "7.1.*", "symfony/security-bundle": "7.1.*",
"symfony/serializer": "7.1.*", "symfony/serializer": "7.1.*",

269
composer.lock generated

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "6801a409dd01157c8d3cde2df133da99", "content-hash": "de0f61141e3ff937c3c241e6b3315a88",
"packages": [ "packages": [
{ {
"name": "bitwasp/bech32", "name": "bitwasp/bech32",
@ -2341,6 +2341,109 @@
}, },
"time": "2022-09-29T08:45:17+00:00" "time": "2022-09-29T08:45:17+00:00"
}, },
{
"name": "monolog/monolog",
"version": "3.10.0",
"source": {
"type": "git",
"url": "https://github.com/Seldaek/monolog.git",
"reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0",
"reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0",
"shasum": ""
},
"require": {
"php": ">=8.1",
"psr/log": "^2.0 || ^3.0"
},
"provide": {
"psr/log-implementation": "3.0.0"
},
"require-dev": {
"aws/aws-sdk-php": "^3.0",
"doctrine/couchdb": "~1.0@dev",
"elasticsearch/elasticsearch": "^7 || ^8",
"ext-json": "*",
"graylog2/gelf-php": "^1.4.2 || ^2.0",
"guzzlehttp/guzzle": "^7.4.5",
"guzzlehttp/psr7": "^2.2",
"mongodb/mongodb": "^1.8 || ^2.0",
"php-amqplib/php-amqplib": "~2.4 || ^3",
"php-console/php-console": "^3.1.8",
"phpstan/phpstan": "^2",
"phpstan/phpstan-deprecation-rules": "^2",
"phpstan/phpstan-strict-rules": "^2",
"phpunit/phpunit": "^10.5.17 || ^11.0.7",
"predis/predis": "^1.1 || ^2",
"rollbar/rollbar": "^4.0",
"ruflin/elastica": "^7 || ^8",
"symfony/mailer": "^5.4 || ^6",
"symfony/mime": "^5.4 || ^6"
},
"suggest": {
"aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB",
"doctrine/couchdb": "Allow sending log messages to a CouchDB server",
"elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client",
"ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)",
"ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler",
"ext-mbstring": "Allow to work properly with unicode symbols",
"ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)",
"ext-openssl": "Required to send log messages using SSL",
"ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)",
"graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server",
"mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)",
"php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib",
"rollbar/rollbar": "Allow sending log messages to Rollbar",
"ruflin/elastica": "Allow sending log messages to an Elastic Search server"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Monolog\\": "src/Monolog"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "https://seld.be"
}
],
"description": "Sends your logs to files, sockets, inboxes, databases and various web services",
"homepage": "https://github.com/Seldaek/monolog",
"keywords": [
"log",
"logging",
"psr-3"
],
"support": {
"issues": "https://github.com/Seldaek/monolog/issues",
"source": "https://github.com/Seldaek/monolog/tree/3.10.0"
},
"funding": [
{
"url": "https://github.com/Seldaek",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/monolog/monolog",
"type": "tidelift"
}
],
"time": "2026-01-02T08:56:05+00:00"
},
{ {
"name": "nette/schema", "name": "nette/schema",
"version": "v1.3.2", "version": "v1.3.2",
@ -6040,6 +6143,164 @@
], ],
"time": "2024-11-08T15:46:42+00:00" "time": "2024-11-08T15:46:42+00:00"
}, },
{
"name": "symfony/monolog-bridge",
"version": "v7.1.6",
"source": {
"type": "git",
"url": "https://github.com/symfony/monolog-bridge.git",
"reference": "e1da878cf5f701df5f5c1799bdbf827acee5a76e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/e1da878cf5f701df5f5c1799bdbf827acee5a76e",
"reference": "e1da878cf5f701df5f5c1799bdbf827acee5a76e",
"shasum": ""
},
"require": {
"monolog/monolog": "^3",
"php": ">=8.2",
"symfony/http-kernel": "^6.4|^7.0",
"symfony/service-contracts": "^2.5|^3"
},
"conflict": {
"symfony/console": "<6.4",
"symfony/http-foundation": "<6.4",
"symfony/security-core": "<6.4"
},
"require-dev": {
"symfony/console": "^6.4|^7.0",
"symfony/http-client": "^6.4|^7.0",
"symfony/mailer": "^6.4|^7.0",
"symfony/messenger": "^6.4|^7.0",
"symfony/mime": "^6.4|^7.0",
"symfony/security-core": "^6.4|^7.0",
"symfony/var-dumper": "^6.4|^7.0"
},
"type": "symfony-bridge",
"autoload": {
"psr-4": {
"Symfony\\Bridge\\Monolog\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides integration for Monolog with various Symfony components",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/monolog-bridge/tree/v7.1.6"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-10-14T08:49:35+00:00"
},
{
"name": "symfony/monolog-bundle",
"version": "v3.11.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/monolog-bundle.git",
"reference": "d87468010570b2ec766152184918ee8d267c7411"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/d87468010570b2ec766152184918ee8d267c7411",
"reference": "d87468010570b2ec766152184918ee8d267c7411",
"shasum": ""
},
"require": {
"composer-runtime-api": "^2.0",
"monolog/monolog": "^1.25.1 || ^2.0 || ^3.0",
"php": ">=8.1",
"symfony/config": "^6.4 || ^7.0",
"symfony/dependency-injection": "^6.4 || ^7.0",
"symfony/deprecation-contracts": "^2.5 || ^3.0",
"symfony/http-kernel": "^6.4 || ^7.0",
"symfony/monolog-bridge": "^6.4 || ^7.0",
"symfony/polyfill-php84": "^1.30"
},
"require-dev": {
"symfony/console": "^6.4 || ^7.0",
"symfony/phpunit-bridge": "^7.3.3",
"symfony/yaml": "^6.4 || ^7.0"
},
"type": "symfony-bundle",
"autoload": {
"psr-4": {
"Symfony\\Bundle\\MonologBundle\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony MonologBundle",
"homepage": "https://symfony.com",
"keywords": [
"log",
"logging"
],
"support": {
"issues": "https://github.com/symfony/monolog-bundle/issues",
"source": "https://github.com/symfony/monolog-bundle/tree/v3.11.2"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-04-02T18:23:01+00:00"
},
{ {
"name": "symfony/options-resolver", "name": "symfony/options-resolver",
"version": "v7.1.9", "version": "v7.1.9",
@ -11311,7 +11572,7 @@
], ],
"aliases": [], "aliases": [],
"minimum-stability": "stable", "minimum-stability": "stable",
"stability-flags": [], "stability-flags": {},
"prefer-stable": true, "prefer-stable": true,
"prefer-lowest": false, "prefer-lowest": false,
"platform": { "platform": {
@ -11320,6 +11581,6 @@
"ext-iconv": "*", "ext-iconv": "*",
"ext-openssl": "*" "ext-openssl": "*"
}, },
"platform-dev": [], "platform-dev": {},
"plugin-api-version": "2.6.0" "plugin-api-version": "2.9.0"
} }

1
config/bundles.php

@ -13,4 +13,5 @@ return [
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['local' => true], Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['local' => true],
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
Symfony\UX\Icons\UXIconsBundle::class => ['all' => true], Symfony\UX\Icons\UXIconsBundle::class => ['all' => true],
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
]; ];

6
config/packages/cache.yaml

@ -10,3 +10,9 @@ framework:
pools: pools:
#my.dedicated.cache: null #my.dedicated.cache: null
subscriptions.cache: null subscriptions.cache: null
# Comment / reply / trackback UI only (not profile, index, or article body).
cache.replies:
adapter: cache.adapter.filesystem
# Unpublished editor preview payloads only.
cache.drafts:
adapter: cache.adapter.filesystem

72
config/packages/monolog.yaml

@ -0,0 +1,72 @@
# Dev: debug → dev.log only; info and above → dev.log + stderr.
# Prod: debug–notice → prod.log only; warning+ → prod.log + stderr. Deprecations: stderr (JSON) only.
# Log rotation: Monolog’s rotating_file rolls daily and keeps the last N files (caps growth; not a strict MB cap).
monolog:
channels:
- deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists
when@dev:
monolog:
handlers:
# Each member gets every record; level filters which are actually written.
main:
type: group
members: [file, docker]
channels: ["!event"]
file:
type: rotating_file
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
max_files: 14
docker:
type: stream
path: "php://stderr"
# Min level info: debug stays out of stderr (file only).
level: info
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine", "!console"]
when@test:
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [404, 405]
channels: ["!event"]
nested:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
when@prod:
monolog:
handlers:
# No fingers_crossed: we split explicitly between file (all) and stderr (warning+ only).
main:
type: group
members: [file, stderr]
channels: ["!deprecation", "!event"]
file:
type: rotating_file
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
max_files: 30
stderr:
type: stream
path: php://stderr
level: warning
formatter: monolog.formatter.json
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine"]
deprecation:
type: stream
channels: [deprecation]
path: php://stderr
formatter: monolog.formatter.json

18
config/services.yaml

@ -40,21 +40,23 @@ services:
$projectDir: '%kernel.project_dir%' $projectDir: '%kernel.project_dir%'
App\Service\ArticleCommentThreadLoader: App\Service\ArticleCommentThreadLoader:
arguments: arguments:
$appCachePool: '@cache.app' $appCachePool: '@cache.replies'
App\Twig\FooterLinksExtension: App\Twig\FooterLinksExtension:
arguments: arguments:
$footerLinksPath: '%footer_links%' $footerLinksPath: '%footer_links%'
tags: [ 'twig.extension' ] tags: [ 'twig.extension' ]
# Nostr index snapshots: distinct key prefix from other cache.app users. App\Twig\NostrShareMenuExtension:
App\Service\MagazineIndexStore: tags: [ 'twig.extension' ]
arguments: App\Twig\MagazineJumbleExtension:
$pool: '@cache.app' tags: [ 'twig.extension' ]
App\Service\MagazineRefresher: App\Service\MagazineRefresher:
arguments: arguments:
$appCache: '@cache.app' $appCache: '@cache.app'
App\Service\CacheService: $magazinePrewarmPreferSlugs: '%magazine_prewarm_prefer_slugs%'
arguments: $magazinePrewarmAlsoSlugs: '%magazine_prewarm_also_slugs%'
$appCache: '@cache.app' App\Controller\ArticleController:
bind:
$articlesCache: '@cache.drafts'
App\Service\Nip05VerificationService: App\Service\Nip05VerificationService:
arguments: arguments:
$appCache: '@cache.app' $appCache: '@cache.app'

12
config/unfold.yaml

@ -35,10 +35,18 @@ parameters:
nip05_domain: 'blog.imwald.eu' nip05_domain: 'blog.imwald.eu'
# Base URL for "Open in Jumble" on author profile (trailing slash optional; npub is appended as /{npub}). # Base URL for "Open in Jumble" on author profile (trailing slash optional; npub is appended as /{npub}).
jumble_profile_users_base: 'https://jumble.imwald.eu/users' jumble_profile_users_base: 'https://jumble.imwald.eu/users'
# Base for event threads: {base}/{nevent1...} (NIP-19 nevent, not raw hex id).
jumble_feed_notes_base: 'https://jumble.imwald.eu/feed/notes'
# Comma-separated category #d slugs to fetch first in app:prewarm after the root (see MagazineRefresher).
magazine_prewarm_prefer_slugs_empty: ''
magazine_prewarm_prefer_slugs: '%env(default:magazine_prewarm_prefer_slugs_empty:MAGAZINE_PREWARM_PREFER_SLUGS)%'
# Extra category #d slugs to 30040-fetch in prewarm right after prefer (before the rest of root’s a tags), so budget runs still hit new categories.
magazine_prewarm_also_slugs_empty: ''
magazine_prewarm_also_slugs: '%env(default:magazine_prewarm_also_slugs_empty:MAGAZINE_PREWARM_ALSO_SLUGS)%'
external_links: external_links:
- title: "Unfold" - title: "Unfold"
url: "https://github.com/decent-newsroom/unfold" url: "https://git.imwald.eu/silberengel/unfold/src/branch/imwald"
description: "Project source code on GitHub." description: "This site’s Unfold source (imwald branch)."
- title: "Decent Newsroom" - title: "Decent Newsroom"
url: "https://decentnewsroom.com/" url: "https://decentnewsroom.com/"
description: "Decentralized magazine platform." description: "Decentralized magazine platform."

3
frankenphp/docker-entrypoint.sh

@ -40,6 +40,9 @@ if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then
fi fi
# DATABASE_URL from Compose / k8s env, or from a local .env file (dev bind-mount). # DATABASE_URL from Compose / k8s env, or from a local .env file (dev bind-mount).
# Project `var/` is often gitignored; create dirs before setfacl so log/cache handlers can always run.
mkdir -p var/log var/cache
if [ -n "${DATABASE_URL:-}" ] || { [ -f .env ] && grep -q ^DATABASE_URL= .env; }; then if [ -n "${DATABASE_URL:-}" ] || { [ -f .env ] && grep -q ^DATABASE_URL= .env; }; then
echo 'Waiting for database to be ready...' echo 'Waiting for database to be ready...'
ATTEMPTS_LEFT_TO_REACH_DATABASE=60 ATTEMPTS_LEFT_TO_REACH_DATABASE=60

31
migrations/Version20260424130000.php

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Core Nostr events (30040 indices, kind-0 profiles) live in `event` with a stable {@see Event::getCoreRowKey()}.
*/
final class Version20260424130000 extends AbstractMigration
{
public function getDescription(): string
{
return 'event.core_row_key + event.storage_role for DB-backed magazine indices and profiles';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE event ADD core_row_key VARCHAR(255) DEFAULT NULL, ADD storage_role VARCHAR(32) DEFAULT NULL');
$this->addSql('CREATE UNIQUE INDEX UNIQ_3BAE0AA7F6F0AF27 ON event (core_row_key)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX UNIQ_3BAE0AA7F6F0AF27 ON event');
$this->addSql('ALTER TABLE event DROP core_row_key, DROP storage_role');
}
}

16
src/Command/PrewarmCommand.php

@ -65,7 +65,7 @@ final class PrewarmCommand extends Command
->addOption('deletion-since', null, InputOption::VALUE_REQUIRED, 'strtotime() window start for kind 5 fetch', '-2 month') ->addOption('deletion-since', null, InputOption::VALUE_REQUIRED, 'strtotime() window start for kind 5 fetch', '-2 month')
->addOption('no-metadata', null, InputOption::VALUE_NONE, 'Skip Nostr profile metadata cache') ->addOption('no-metadata', null, InputOption::VALUE_NONE, 'Skip Nostr profile metadata cache')
->addOption('no-comments', null, InputOption::VALUE_NONE, 'Skip comment thread cache') ->addOption('no-comments', null, InputOption::VALUE_NONE, 'Skip comment thread cache')
->addOption('magazine-budget', null, InputOption::VALUE_REQUIRED, 'Seconds wall time for magazine relay refresh', '30') ->addOption('magazine-budget', null, InputOption::VALUE_REQUIRED, 'Seconds wall time for the category 30040 phase only (root fetch is not counted; capped at 600s). If many slugs, raise this or set MAGAZINE_PREWARM_PREFER_SLUGS', '90')
->addOption('metadata-limit', null, InputOption::VALUE_REQUIRED, 'Max distinct author pubkeys to warm (0 = all)', '0') ->addOption('metadata-limit', null, InputOption::VALUE_REQUIRED, 'Max distinct author pubkeys to warm (0 = all)', '0')
->addOption('metadata-batch', null, InputOption::VALUE_REQUIRED, 'Kind-0 metadata: pubkeys per Nostr REQ (batched)', '50') ->addOption('metadata-batch', null, InputOption::VALUE_REQUIRED, 'Kind-0 metadata: pubkeys per Nostr REQ (batched)', '50')
->addOption('comments-max', null, InputOption::VALUE_REQUIRED, 'Newest N magazine category articles to warm comment cache for (0 = all, order: createdAt DESC; excludes generic /articles feed-only rows)', '10') ->addOption('comments-max', null, InputOption::VALUE_REQUIRED, 'Newest N magazine category articles to warm comment cache for (0 = all, order: createdAt DESC; excludes generic /articles feed-only rows)', '10')
@ -185,20 +185,20 @@ final class PrewarmCommand extends Command
} }
} }
$io->section('Long-form in DB (category `a` tags missing from MySQL)'); $io->section('Long-form in DB (category `a` tags — refresh from Nostr)');
try { try {
$n = $this->magazineContent->ingestMissingLongformForAllMagazineCategories(); $n = $this->magazineContent->ingestLongformForAllMagazineCategories();
if ($n === 0) { if ($n === 0) {
$io->note('No missing long-form rows for category `a` coordinates (or empty magazine store).'); $io->note('No category `a` coordinates in the magazine store (or empty category indices).');
} else { } else {
$io->writeln(sprintf('Fetched or attempted ingest for <info>%d</info> missing coordinate(s).', $n)); $io->writeln(sprintf('Fetched latest long-form for <info>%d</info> coordinate(s) (new rows + NIP-33 updates).', $n));
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->logger->error('app:prewarm longform ingest failed', ['e' => $e]); $this->logger->error('app:prewarm longform ingest failed', ['e' => $e]);
$io->warning('Long-form backfill failed: '.$e->getMessage()); $io->warning('Long-form backfill failed: '.$e->getMessage());
} }
// MagazineRefresher sets max_execution_time (e.g. 60 for budget 30); restore before metadata. // MagazineRefresher sets max_execution_time (budget + headroom); restore before metadata.
$this->disableCliExecutionTimeLimit(); $this->disableCliExecutionTimeLimit();
if (!$input->getOption('no-deletions')) { if (!$input->getOption('no-deletions')) {
@ -319,8 +319,8 @@ final class PrewarmCommand extends Command
$bar->start(); $bar->start();
try { try {
foreach (array_chunk($toWarm, $batchSize) as $chunk) { foreach (array_chunk($toWarm, $batchSize) as $chunk) {
$fetched = $this->nostrClient->fetchKind0MetadataForAuthors($chunk, $batchSize); $fetched = $this->nostrClient->fetchKind0WireEventsForAuthors($chunk, $batchSize);
$n += $this->cacheService->putPrewarmMetadataBatch($chunk, $fetched, $keys); $n += $this->cacheService->putPrewarmMetadataBatch($chunk, $fetched);
$bar->advance(\count($chunk)); $bar->advance(\count($chunk));
$p0 = (string) ($chunk[0] ?? ''); $p0 = (string) ($chunk[0] ?? '');
$bar->setMessage('Batch up to · '.substr($p0, 0, 8).'…'); $bar->setMessage('Batch up to · '.substr($p0, 0, 8).'…');

95
src/Controller/ArticleController.php

@ -272,7 +272,9 @@ class ArticleController extends AbstractController
$nostrClient->getLongFormFromNaddr($slug, $relays, $author, $kind); $nostrClient->getLongFormFromNaddr($slug, $relays, $author, $kind);
if ($slug) { if ($slug) {
return $this->redirectToRoute('article-slug', ['slug' => $slug]); $npub = (new Key())->convertPublicKeyToBech32((string) $author);
return $this->redirectToRoute('article', ['npub' => $npub, 'slug' => $slug], Response::HTTP_MOVED_PERMANENTLY);
} }
throw new \Exception('No article.'); throw new \Exception('No article.');
@ -283,59 +285,97 @@ class ArticleController extends AbstractController
*/ */
// Slug is the NIP-33 d-identifier and may contain "/"; default [^/]++ would break sitemap/URL generation. // Slug is the NIP-33 d-identifier and may contain "/"; default [^/]++ would break sitemap/URL generation.
#[Route( #[Route(
path: '/article/d/{slug}', path: '/p/{npub}/d/{slug}',
name: 'article-slug', name: 'article',
requirements: ['slug' => '.+'], requirements: ['npub' => '^npub1.*', 'slug' => '.+'],
options: ['utf8' => true], options: ['utf8' => true],
)] )]
public function article( public function article(
$slug, string $npub,
string $slug,
EntityManagerInterface $entityManager, EntityManagerInterface $entityManager,
CacheService $cacheService, CacheService $cacheService,
CacheItemPoolInterface $articlesCache,
Converter $converter, Converter $converter,
ArticleCommentThreadLoader $commentThreadLoader ArticleCommentThreadLoader $commentThreadLoader
): Response ): Response
{ {
$article = $this->loadLatestArticleBySlug($entityManager, $slug);
if ($article === null) {
throw $this->createNotFoundException('The article could not be found');
}
$key = new Key();
if ($key->convertToHex($npub) !== strtolower((string) $article->getPubkey())) {
throw $this->createNotFoundException('The article could not be found');
}
set_time_limit(300); // 5 minutes return $this->renderArticle(
ini_set('max_execution_time', '300'); $article,
$cacheService,
$converter,
$commentThreadLoader
);
}
$article = null; /**
// check if an item with same eventId already exists in the db * Legacy: /article/d/{slug} → 301 to /p/{npub}/d/{slug} (NIP-33 with author npub in path).
*/
#[Route(
path: '/article/d/{slug}',
name: 'article-legacy-redirect',
requirements: ['slug' => '.+'],
options: ['utf8' => true],
)]
public function articleLegacyRedirect(
string $slug,
EntityManagerInterface $entityManager,
): Response {
$article = $this->loadLatestArticleBySlug($entityManager, $slug);
if ($article === null) {
throw $this->createNotFoundException('The article could not be found');
}
$key = new Key();
$npub = $key->convertPublicKeyToBech32((string) $article->getPubkey());
return $this->redirectToRoute('article', ['npub' => $npub, 'slug' => $slug], Response::HTTP_MOVED_PERMANENTLY);
}
private function loadLatestArticleBySlug(EntityManagerInterface $entityManager, string $slug): ?Article
{
$repository = $entityManager->getRepository(Article::class); $repository = $entityManager->getRepository(Article::class);
$articles = $repository->findBy(['slug' => $slug]); $articles = $repository->findBy(['slug' => $slug]);
$revisions = count($articles); $revisions = \count($articles);
if ($revisions === 0) { if ($revisions === 0) {
throw $this->createNotFoundException('The article could not be found'); return null;
} }
if ($revisions > 1) { if ($revisions > 1) {
// sort articles by created at date
usort($articles, function ($a, $b) { usort($articles, function ($a, $b) {
return $b->getCreatedAt() <=> $a->getCreatedAt(); return $b->getCreatedAt() <=> $a->getCreatedAt();
}); });
// get the last article
$article = end($articles); return end($articles);
} else {
$article = $articles[0];
} }
$cacheKey = 'article_' . $article->getId(); return $articles[0];
$cacheItem = $articlesCache->getItem($cacheKey);
if (!$cacheItem->isHit()) {
$cacheItem->set($converter->convertToHtml($article->getContent()));
$articlesCache->save($cacheItem);
} }
private function renderArticle(
Article $article,
CacheService $cacheService,
Converter $converter,
ArticleCommentThreadLoader $commentThreadLoader
): Response {
set_time_limit(300); // 5 minutes
ini_set('max_execution_time', '300');
$html = $converter->convertToHtml($article->getContent());
$key = new Key(); $key = new Key();
$npub = $key->convertPublicKeyToBech32($article->getPubkey()); $npub = $key->convertPublicKeyToBech32($article->getPubkey());
$author = $cacheService->getMetadata($npub); $author = $cacheService->getMetadata($npub);
$kind = $article->getKind()?->value ?? 30023; $kind = $article->getKind()?->value ?? 30023;
$pubkey = (string) $article->getPubkey(); $pubkey = (string) $article->getPubkey();
$articleSlug = (string) ($article->getSlug() ?? $slug); $articleSlug = (string) $article->getSlug();
$coordinate = $kind.':'.$pubkey.':'.$articleSlug; $coordinate = $kind.':'.$pubkey.':'.$articleSlug;
$eid = $article->getEventId(); $eid = $article->getEventId();
$eid = ($eid !== null && $eid !== '' && self::isValidHexEventId($eid)) ? $eid : null; $eid = ($eid !== null && $eid !== '' && self::isValidHexEventId($eid)) ? $eid : null;
@ -358,7 +398,7 @@ class ArticleController extends AbstractController
'article' => $article, 'article' => $article,
'author' => $author, 'author' => $author,
'npub' => $npub, 'npub' => $npub,
'content' => $cacheItem->get(), 'content' => $html,
'comments_data' => $commentsData, 'comments_data' => $commentsData,
'comments_preloaded' => $commentsPreloaded, 'comments_preloaded' => $commentsPreloaded,
]); ]);
@ -373,7 +413,6 @@ class ArticleController extends AbstractController
Request $request, Request $request,
NostrClient $nostrClient, NostrClient $nostrClient,
CacheService $cacheService, CacheService $cacheService,
CacheItemPoolInterface $articlesCache
): Response { ): Response {
$data = $request->getContent(); $data = $request->getContent();
$descriptor = json_decode($data); $descriptor = json_decode($data);
@ -518,11 +557,13 @@ class ArticleController extends AbstractController
$article = $cacheItem->get(); $article = $cacheItem->get();
$content = $converter->convertToHtml($article->getContent()); $content = $converter->convertToHtml($article->getContent());
$previewNpub = (new Key())->convertPublicKeyToBech32($currentPubkey);
return $this->render('pages/article.html.twig', [ return $this->render('pages/article.html.twig', [
'article' => $article, 'article' => $article,
'content' => $content, 'content' => $content,
'author' => $user->getMetadata(), 'author' => $user->getMetadata(),
'npub' => $previewNpub,
'comments_preloaded' => false, 'comments_preloaded' => false,
]); ]);
} }

5
src/Controller/AuthorController.php

@ -75,10 +75,6 @@ class AuthorController extends AbstractController
} }
$extraPayto = $profilePaymentLinks->collectPaytoUrisFromNipA3Kind10133Events($kind10133); $extraPayto = $profilePaymentLinks->collectPaytoUrisFromNipA3Kind10133Events($kind10133);
$jumbleBase = (string) $this->getParameter('jumble_profile_users_base');
$jumbleBase = rtrim($jumbleBase, '/');
$jumbleProfileHref = $jumbleBase !== '' ? $jumbleBase.'/'.$npub : null;
$profileNip05 = $profileIdentityLinks->buildNip05($author, $kind0Tags); $profileNip05 = $profileIdentityLinks->buildNip05($author, $kind0Tags);
$fa = $featuredAuthorRepository->findOneByPubkeyHex($pubkey); $fa = $featuredAuthorRepository->findOneByPubkeyHex($pubkey);
if ($fa !== null && $fa->isListed()) { if ($fa !== null && $fa->isListed()) {
@ -96,7 +92,6 @@ class AuthorController extends AbstractController
'profile_websites' => $profileIdentityLinks->buildWebsites($author, $kind0Tags), 'profile_websites' => $profileIdentityLinks->buildWebsites($author, $kind0Tags),
'profile_nip05' => $profileNip05, 'profile_nip05' => $profileNip05,
'profile_payment_links' => $profilePaymentLinks->buildPaymentRows($author, $kind0Tags, $extraPayto), 'profile_payment_links' => $profilePaymentLinks->buildPaymentRows($author, $kind0Tags, $extraPayto),
'jumble_profile_href' => $jumbleProfileHref,
]); ]);
} }

28
src/Controller/EventController.php

@ -6,6 +6,7 @@ namespace App\Controller;
use App\Service\NostrClient; use App\Service\NostrClient;
use App\Service\NostrLinkParser; use App\Service\NostrLinkParser;
use App\Service\NostrShareMenuBuilder;
use App\Service\CacheService; use App\Service\CacheService;
use Exception; use Exception;
use nostriphant\NIP19\Bech32; use nostriphant\NIP19\Bech32;
@ -13,6 +14,7 @@ use nostriphant\NIP19\Data;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use swentel\nostr\Key\Key; use swentel\nostr\Key\Key;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@ -23,8 +25,15 @@ class EventController extends AbstractController
* @throws Exception * @throws Exception
*/ */
#[Route('/e/{nevent}', name: 'nevent', requirements: ['nevent' => '^nevent1.*'])] #[Route('/e/{nevent}', name: 'nevent', requirements: ['nevent' => '^nevent1.*'])]
public function index($nevent, NostrClient $nostrClient, CacheService $cacheService, NostrLinkParser $nostrLinkParser, LoggerInterface $logger): Response public function index(
{ $nevent,
Request $request,
NostrClient $nostrClient,
CacheService $cacheService,
NostrLinkParser $nostrLinkParser,
NostrShareMenuBuilder $nostrShareMenuBuilder,
LoggerInterface $logger,
): Response {
$logger->info('Accessing event page', ['nevent' => $nevent]); $logger->info('Accessing event page', ['nevent' => $nevent]);
try { try {
@ -37,11 +46,15 @@ class EventController extends AbstractController
$data = $decoded->data; $data = $decoded->data;
$logger->info('Event data', ['data' => json_encode($data)]); $logger->info('Event data', ['data' => json_encode($data)]);
$relays = [];
// Sort which event type this is using $data->type // Sort which event type this is using $data->type
switch ($decoded->type) { switch ($decoded->type) {
case 'note': case 'note':
// Handle note (regular event) // Handle note (regular event)
$relays = $data->relays ?? []; $relays = $data->relays ?? [];
if (!\is_array($relays)) {
$relays = [];
}
$event = $nostrClient->getEventById($data->identifier, $relays); $event = $nostrClient->getEventById($data->identifier, $relays);
break; break;
@ -53,16 +66,23 @@ class EventController extends AbstractController
case 'nevent': case 'nevent':
// Handle nevent identifier (event with additional metadata) // Handle nevent identifier (event with additional metadata)
$relays = $data->relays ?? []; $relays = $data->relays ?? [];
if (!\is_array($relays)) {
$relays = [];
}
$event = $nostrClient->getEventById($data->id, $relays); $event = $nostrClient->getEventById($data->id, $relays);
break; break;
case 'naddr': case 'naddr':
// Handle naddr (parameterized replaceable event) // Handle naddr (parameterized replaceable event)
$relays = $data->relays ?? [];
if (!\is_array($relays)) {
$relays = [];
}
$decodedData = [ $decodedData = [
'kind' => $data->kind, 'kind' => $data->kind,
'pubkey' => $data->pubkey, 'pubkey' => $data->pubkey,
'identifier' => $data->identifier, 'identifier' => $data->identifier,
'relays' => $data->relays ?? [] 'relays' => $relays,
]; ];
$event = $nostrClient->getEventByNaddr($decodedData); $event = $nostrClient->getEventByNaddr($decodedData);
break; break;
@ -77,6 +97,8 @@ class EventController extends AbstractController
throw new NotFoundHttpException('Event not found'); throw new NotFoundHttpException('Event not found');
} }
$nostrShareMenuBuilder->applyWireEventToRequest($request, $event, $relays);
// Parse event content for Nostr links // Parse event content for Nostr links
$nostrLinks = []; $nostrLinks = [];
if (isset($event->content)) { if (isset($event->content)) {

3
src/Controller/FeaturedAuthorsController.php

@ -30,7 +30,6 @@ final class FeaturedAuthorsController extends AbstractController
ParameterBagInterface $params, ParameterBagInterface $params,
): Response { ): Response {
$domain = trim((string) $params->get('nip05_domain')); $domain = trim((string) $params->get('nip05_domain'));
$jumbleBase = rtrim((string) $params->get('jumble_profile_users_base'), '/');
$keys = new Key(); $keys = new Key();
$authors = []; $authors = [];
foreach ($featuredAuthorRepository->findAllListedOrderByLocalPart() as $fa) { foreach ($featuredAuthorRepository->findAllListedOrderByLocalPart() as $fa) {
@ -38,7 +37,6 @@ final class FeaturedAuthorsController extends AbstractController
$bundle = $cacheService->getMetadataBundle($npub); $bundle = $cacheService->getMetadataBundle($npub);
$author = $bundle['content']; $author = $bundle['content'];
$kind0Tags = $bundle['kind0_tags']; $kind0Tags = $bundle['kind0_tags'];
$jumbleProfileHref = $jumbleBase !== '' ? $jumbleBase.'/'.$npub : null;
$kind10133 = []; $kind10133 = [];
try { try {
$kind10133 = $nostrClient->getKind10133PaymentTargetEventsForNpub($npub, 20); $kind10133 = $nostrClient->getKind10133PaymentTargetEventsForNpub($npub, 20);
@ -50,7 +48,6 @@ final class FeaturedAuthorsController extends AbstractController
'npub' => $npub, 'npub' => $npub,
'profile_websites' => $profileIdentityLinks->buildWebsites($author, $kind0Tags), 'profile_websites' => $profileIdentityLinks->buildWebsites($author, $kind0Tags),
'profile_payment_links' => $profilePaymentLinks->buildPaymentRows($author, $kind0Tags, $extraPayto), 'profile_payment_links' => $profilePaymentLinks->buildPaymentRows($author, $kind0Tags, $extraPayto),
'jumble_profile_href' => $jumbleProfileHref,
]; ];
} }

32
src/Controller/HealthController.php

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/**
* Liveness: no DB or Nostr work. Used by Docker / load balancers; do not use for deep dependency checks.
*/
final class HealthController
{
public const string BODY = "ok\n";
#[Route('/health', name: 'health', methods: ['GET', 'HEAD'])]
public function __invoke(Request $request): Response
{
$headers = [
'Content-Type' => 'text/plain; charset=UTF-8',
'Cache-Control' => 'no-store',
];
if ($request->isMethod('HEAD')) {
return new Response('', Response::HTTP_OK, $headers);
}
return new Response(self::BODY, Response::HTTP_OK, $headers);
}
}

36
src/Controller/SeoController.php

@ -10,6 +10,7 @@ use App\Repository\ArticleRepository;
use App\Repository\FeaturedAuthorRepository; use App\Repository\FeaturedAuthorRepository;
use App\Service\MagazineContentService; use App\Service\MagazineContentService;
use App\Service\MagazineIndexStore; use App\Service\MagazineIndexStore;
use App\Service\NostrPathHelper;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
@ -31,6 +32,7 @@ final class SeoController extends AbstractController
private readonly MagazineIndexStore $magazineIndexStore, private readonly MagazineIndexStore $magazineIndexStore,
private readonly ParameterBagInterface $params, private readonly ParameterBagInterface $params,
private readonly FeaturedAuthorRepository $featuredAuthorRepository, private readonly FeaturedAuthorRepository $featuredAuthorRepository,
private readonly NostrPathHelper $nostrPathHelper,
) { ) {
} }
@ -57,8 +59,12 @@ final class SeoController extends AbstractController
$articles = $this->articleRepository->findPublishedForSyndication(8000); $articles = $this->articleRepository->findPublishedForSyndication(8000);
$bySlug = $this->dedupeArticlesByLatestRevision($articles); $bySlug = $this->dedupeArticlesByLatestRevision($articles);
foreach ($bySlug as $article) { foreach ($bySlug as $article) {
$loc = $this->nostrPathHelper->articleAbsoluteUrl($article);
if ($loc === '') {
continue;
}
$urls[] = [ $urls[] = [
'loc' => $this->absoluteUrlForRoute('article-slug', ['slug' => (string) $article->getSlug()]), 'loc' => $loc,
'lastmod' => $this->articleLastMod($article), 'lastmod' => $this->articleLastMod($article),
]; ];
} }
@ -277,7 +283,10 @@ final class SeoController extends AbstractController
if ($slug === '') { if ($slug === '') {
return ''; return '';
} }
$permalink = $this->absoluteUrlForRoute('article-slug', ['slug' => $slug]); $permalink = $this->nostrPathHelper->articleAbsoluteUrl($article);
if ($permalink === '') {
return '';
}
$title = (string) ($article->getTitle() ?? 'Untitled'); $title = (string) ($article->getTitle() ?? 'Untitled');
$tArticle = $this->articleLastMod($article); $tArticle = $this->articleLastMod($article);
$sum = (string) ($article->getSummary() ?? ''); $sum = (string) ($article->getSummary() ?? '');
@ -285,11 +294,11 @@ final class SeoController extends AbstractController
$plain = preg_replace('/\s+/', ' ', (string) $article->getContent()) ?? ''; $plain = preg_replace('/\s+/', ' ', (string) $article->getContent()) ?? '';
$sum = (string) mb_substr($plain, 0, 500); $sum = (string) mb_substr($plain, 0, 500);
} }
$eId = (string) ($article->getEventId() ?? ''); // One stable Atom <id> per row. Nostr eventId can repeat (revisions, duplicates); readers
if ($eId === '') { // merge on <id> and would only show a single entry if ids collided.
$eId = (string) ($article->getId() ?? 'item'); $dbId = $article->getId();
} $entryId = 'urn:web:'.$this->urlHostId($request)
$entryId = 'urn:web:'.$this->urlHostId($request).":article:{$eId}"; .':db-article:'.($dbId !== null && $dbId !== '' ? (string) $dbId : \spl_object_id($article));
$pub = $article->getPublishedAt() ?? $article->getCreatedAt() ?? $tArticle; $pub = $article->getPublishedAt() ?? $article->getCreatedAt() ?? $tArticle;
$out = "\n <entry>"; $out = "\n <entry>";
@ -367,12 +376,21 @@ final class SeoController extends AbstractController
private function xmlText(string $s): string private function xmlText(string $s): string
{ {
return htmlspecialchars($s, \ENT_XML1 | \ENT_QUOTES, 'UTF-8'); return htmlspecialchars($this->stripInvalidXml1Chars($s), \ENT_XML1 | \ENT_QUOTES, 'UTF-8');
} }
private function xmlAttr(string $s): string private function xmlAttr(string $s): string
{ {
return htmlspecialchars($s, \ENT_XML1 | \ENT_QUOTES, 'UTF-8'); return htmlspecialchars($this->stripInvalidXml1Chars($s), \ENT_XML1 | \ENT_QUOTES, 'UTF-8');
}
/**
* XML 1.0 disallows C0 control chars other than tab, CR, LF; they can make feeds appear truncated
* after the first entry that used only “clean” text.
*/
private function stripInvalidXml1Chars(string $s): string
{
return preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $s) ?? $s;
} }
private function xmlResponse(string $body): Response private function xmlResponse(string $body): Response

21
src/Dto/NostrShareMenuContext.php

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Dto;
/**
* Nostr "⋯" share menu: copy npub; copy naddr and/or nevent (Jumble /feed/notes/… uses the naddr when present, else nevent).
* For NIP-33 replaceable events, both can be set: naddr is the coordinate, nevent is the specific revision.
*/
final class NostrShareMenuContext
{
public function __construct(
/** NIP-19 npub. Null only in rare fallbacks. */
public ?string $npub,
public ?string $neventBech32,
public ?string $naddrBech32,
public string $jumbleHref,
) {
}
}

41
src/Entity/Event.php

@ -2,15 +2,27 @@
namespace App\Entity; namespace App\Entity;
use App\Repository\EventRepository;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
/** /**
* Nostr events * Nostr events stored in MySQL (kind-0 profiles, 30040 indices, kind-3 relay lists, etc.).
* Ephemeral reply/comment UI data must not use this table.
*/ */
#[ORM\Entity] #[ORM\Entity(repositoryClass: EventRepository::class)]
class Event class Event
{ {
public const STORAGE_MAGAZINE_ROOT = 'magazine_root';
public const STORAGE_MAGAZINE_CATEGORY = 'magazine_category';
public const STORAGE_PROFILE_KIND0 = 'profile';
public const STORAGE_RELAY_LIST_10002 = 'relay_list';
public const STORAGE_PAYTO_10133 = 'payto_10133';
#[ORM\Id] #[ORM\Id]
#[ORM\Column(length: 225)] #[ORM\Column(length: 225)]
private string $id; private string $id;
@ -29,6 +41,12 @@ class Event
#[ORM\Column(length: 255)] #[ORM\Column(length: 255)]
private string $sig = ''; private string $sig = '';
#[ORM\Column(length: 255, unique: true, nullable: true)]
private ?string $coreRowKey = null;
#[ORM\Column(length: 32, nullable: true)]
private ?string $storageRole = null;
public function getId(): string public function getId(): string
{ {
return $this->id; return $this->id;
@ -111,6 +129,25 @@ class Event
$this->sig = $sig; $this->sig = $sig;
} }
public function getCoreRowKey(): ?string
{
return $this->coreRowKey;
}
public function setCoreRowKey(?string $coreRowKey): void
{
$this->coreRowKey = $coreRowKey;
}
public function getStorageRole(): ?string
{
return $this->storageRole;
}
public function setStorageRole(?string $storageRole): void
{
$this->storageRole = $storageRole;
}
public function getTitle(): ?string public function getTitle(): ?string
{ {

62
src/Nostr/MagazineEventKeys.php

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Nostr;
use swentel\nostr\Key\Key;
/**
* Stable keys for {@see Event} rows: magazine root/category indices and kind-0 profiles in MySQL.
*/
final class MagazineEventKeys
{
public static function magazineRoot(string $npub, string $rootDTag): string
{
$hex = self::npubToHex($npub);
if ($hex === '') {
return '';
}
return 'mr:'.$hex.':'.trim($rootDTag, " \0\x0B\t\n\r");
}
public static function magazineCategory(string $categoryDTag): string
{
return 'mc:'.trim($categoryDTag, " \0\x0B\t\n\r");
}
public static function profileKind0(string $authorPubkeyHex64): string
{
return 'pr:'.strtolower($authorPubkeyHex64);
}
public static function relayList10002(string $authorPubkeyHex64): string
{
return 'k10002:'.strtolower($authorPubkeyHex64);
}
/**
* NIP-33 + NIP-A3: kind 10133, pubkey hex, d-tag from the address.
*/
public static function payto10133(string $authorPubkeyHex64, string $dTag): string
{
$d = trim($dTag, " \0\x0B\t\n\r");
return 'k10133:'.strtolower($authorPubkeyHex64).':'.$d;
}
private static function npubToHex(string $npub): string
{
if (64 === \strlen($npub) && ctype_xdigit($npub)) {
return strtolower($npub);
}
try {
$h = (new Key())->convertToHex($npub);
} catch (\Throwable) {
$h = '';
}
return (\is_string($h) && 64 === \strlen($h) && ctype_xdigit($h)) ? strtolower($h) : '';
}
}

73
src/Nostr/Nip19Addressable.php

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Nostr;
use App\Entity\Event;
use nostriphant\NIP19\Bech32;
/**
* NIP-33 / NIP-19 helpers: naddr for parameterized replaceable events (kind:pubkey:d).
*/
final class Nip19Addressable
{
/**
* NIP-33 replaceable kinds (30000–39999) use a `d` tag; encode as naddr, not nevent, for clients.
*/
public static function isParameterizedReplaceableKind(int $kind): bool
{
return $kind >= 30_000 && $kind < 40_000;
}
/**
* @param array<int, mixed> $tagRows
*/
public static function dTagFromTagRows(array $tagRows): ?string
{
foreach ($tagRows as $row) {
if (!\is_array($row) && !\is_object($row)) {
continue;
}
if (\is_object($row)) {
$row = (array) $row;
}
$row = array_values($row);
if ($row === []) {
continue;
}
if (strtolower((string) ($row[0] ?? '')) === 'd' && isset($row[1])) {
$d = (string) $row[1];
if ($d !== '') {
return $d;
}
}
}
return null;
}
public static function dTagFromEventEntity(Event $e): ?string
{
return self::dTagFromTagRows($e->getTags());
}
public static function naddrBech32(
int $kind,
string $pubkeyHex,
string $dIdentifier,
array $relays = [],
): string {
$pubkeyHex = strtolower($pubkeyHex);
if (64 !== \strlen($pubkeyHex) || !ctype_xdigit($pubkeyHex)) {
throw new \InvalidArgumentException('Invalid pubkey hex for naddr.');
}
return (string) Bech32::naddr(
kind: $kind,
pubkey: $pubkeyHex,
identifier: $dIdentifier,
relays: $relays,
);
}
}

5
src/Repository/ArticleRepository.php

@ -110,11 +110,12 @@ class ArticleRepository extends ServiceEntityRepository
$qb = $this->createQueryBuilder('a'); $qb = $this->createQueryBuilder('a');
$orX = $qb->expr()->orX(); $orX = $qb->expr()->orX();
foreach ($pairs as $i => $p) { foreach ($pairs as $i => $p) {
$pkQ = strtolower((string) $p['pubkey']);
$orX->add($qb->expr()->andX( $orX->add($qb->expr()->andX(
$qb->expr()->eq('a.pubkey', ':pk'.$i), $qb->expr()->eq('a.pubkey', ':pk'.$i),
$qb->expr()->eq('a.slug', ':sl'.$i) $qb->expr()->eq('a.slug', ':sl'.$i)
)); ));
$qb->setParameter('pk'.$i, $p['pubkey']); $qb->setParameter('pk'.$i, $pkQ);
$qb->setParameter('sl'.$i, $p['slug']); $qb->setParameter('sl'.$i, $p['slug']);
} }
$qb->where($orX); $qb->where($orX);
@ -123,7 +124,7 @@ class ArticleRepository extends ServiceEntityRepository
$rows = $qb->getQuery()->getResult(); $rows = $qb->getQuery()->getResult();
$out = []; $out = [];
foreach ($rows as $a) { foreach ($rows as $a) {
$pk = (string) $a->getPubkey(); $pk = strtolower((string) $a->getPubkey());
$sl = trim((string) $a->getSlug()); $sl = trim((string) $a->getSlug());
if ($sl !== '') { if ($sl !== '') {
$out[$pk."\0".$sl] = $a; $out[$pk."\0".$sl] = $a;

25
src/Repository/EventRepository.php

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Event;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Event>
*/
class EventRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Event::class);
}
public function findOneByCoreRowKey(string $key): ?Event
{
return $this->findOneBy(['coreRowKey' => $key]);
}
}

2
src/Service/ArticleCommentThreadLoader.php

@ -309,7 +309,7 @@ final readonly class ArticleCommentThreadLoader
$pRaw = isset($parent->content) ? (string) $parent->content : ''; $pRaw = isset($parent->content) ? (string) $parent->content : '';
$preview = $this->parentEventTextPreviewForBlurb($pRaw); $preview = $this->parentEventTextPreviewForBlurb($pRaw);
if ($preview !== '') { if ($preview !== '') {
$blurb = '> *'.'Replying to thread'.'* — '."\n> ".$preview; $blurb = '> *'.'Reply to'.'* — '."\n> ".$preview;
} }
} }
} }

312
src/Service/CacheService.php

@ -1,98 +1,117 @@
<?php <?php
declare(strict_types=1);
namespace App\Service; namespace App\Service;
use Psr\Cache\CacheItemPoolInterface; use App\Entity\Event;
use Psr\Cache\InvalidArgumentException; use App\Nostr\MagazineEventKeys;
use App\Repository\EventRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use swentel\nostr\Key\Key; use swentel\nostr\Key\Key;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
readonly class CacheService readonly class CacheService
{ {
public function __construct( public function __construct(
private NostrClient $nostrClient, private NostrClient $nostrClient,
private CacheInterface $cache, private EntityManagerInterface $entityManager,
private EventRepository $eventRepository,
private LoggerInterface $logger, private LoggerInterface $logger,
private CacheItemPoolInterface $appCache,
) { ) {
} }
/**
* @param string $npub
* @return \stdClass
*/
public function getMetadata(string $npub): \stdClass public function getMetadata(string $npub): \stdClass
{ {
return $this->getMetadataBundle($npub)['content']; return $this->getMetadataBundle($npub)['content'];
} }
/** /**
* Kind-0 content JSON, tags (for payto/website/nip05), and any relay round trip once per cache item.
*
* @return array{content: \stdClass, kind0_tags: list<list<string>>} * @return array{content: \stdClass, kind0_tags: list<list<string>>}
*/ */
public function getMetadataBundle(string $npub): array public function getMetadataBundle(string $npub): array
{ {
// One key per author: do not split on Nostr.Land / aggr (see comment thread cache). Otherwise $authorHex = $this->npubToAuthorHex64($npub);
// prewarm and anonymous hits do not match logged-in readers → cold Nostr on every article view. if ($authorHex === null) {
$cacheKey = '0_'.$npub; return $this->placeholderMetadataBundle($npub);
try { }
$cached = $this->cache->get($cacheKey, function (ItemInterface $item) use ($npub) { $row = $this->eventRepository->findOneByCoreRowKey(MagazineEventKeys::profileKind0($authorHex));
$item->expiresAfter(3600); // 1 hour, adjust as needed if ($row !== null) {
return $this->bundleFromKind0EventRow($row, $npub);
}
try { try {
$ev = $this->nostrClient->getNpubMetadata($npub); $ev = $this->nostrClient->getNpubMetadata($npub);
$tags = self::normalizeEventTagsList($ev->tags ?? null); if (!\is_object($ev)) {
try { return $this->placeholderMetadataBundle($npub);
$data = \json_decode((string) $ev->content, false, 512, \JSON_THROW_ON_ERROR);
} catch (\JsonException) {
$data = new \stdClass();
} }
if (!\is_object($data)) { $this->replaceByCoreKey(
$data = new \stdClass(); MagazineEventKeys::profileKind0($authorHex),
Event::STORAGE_PROFILE_KIND0,
$ev
);
$tags = self::normalizeEventTagsList($ev->tags ?? null);
$content = $this->decodeKind0ContentObject($ev);
if ($this->isPlaceholderContent($content, $npub)) {
$content = $this->namePlaceholderNpubObject($npub);
} }
return [ return ['content' => $content, 'kind0_tags' => $tags];
'content' => $data,
'kind0_tags' => $tags,
];
} catch (\Exception $e) { } catch (\Exception $e) {
throw new MetadataRetrievalException('Failed to retrieve metadata', 0, $e);
}
});
if (\is_array($cached) && isset($cached['content']) && $cached['content'] instanceof \stdClass) {
return [
'content' => $cached['content'],
'kind0_tags' => \is_array($cached['kind0_tags'] ?? null) ? $cached['kind0_tags'] : [],
];
}
// Legacy: cache stored only the decoded content object
if ($cached instanceof \stdClass) {
return ['content' => $cached, 'kind0_tags' => []];
}
} catch (\Exception|InvalidArgumentException $e) {
$root = $e->getPrevious() ?? $e;
$this->logger->warning('Profile metadata fetch failed; using npub placeholder.', [ $this->logger->warning('Profile metadata fetch failed; using npub placeholder.', [
'npub' => $npub, 'npub' => $npub,
'exception' => $root, 'exception' => $e->getPrevious() ?? $e,
]); ]);
$content = new \stdClass(); }
$content->name = substr($npub, 0, 8) . '…' . substr($npub, -4);
return [ return $this->placeholderMetadataBundle($npub);
'content' => $content,
'kind0_tags' => [],
];
} }
$content = new \stdClass(); /**
$content->name = substr($npub, 0, 8) . '…' . substr($npub, -4); * Prewarm: batch upsert of kind-0 profile rows in {@see Event}.
*
* @param list<string> $authorPubkeyHex
* @param array<string, object> $wireByLowerHex from {@see NostrClient::fetchKind0WireEventsForAuthors} (keys are lowercase 64-hex)
*/
public function putPrewarmMetadataBatch(array $authorPubkeyHex, array $wireByLowerHex): int
{
$n = 0;
foreach ($authorPubkeyHex as $hex) {
if (64 !== \strlen($hex) || !ctype_xdigit($hex)) {
continue;
}
$h = strtolower($hex);
if (!isset($wireByLowerHex[$h]) || !\is_object($wireByLowerHex[$h])) {
continue;
}
$this->replaceByCoreKey(
MagazineEventKeys::profileKind0($h),
Event::STORAGE_PROFILE_KIND0,
$wireByLowerHex[$h]
);
++$n;
}
return [ return $n;
'content' => $content, }
'kind0_tags' => [],
]; public function getRelays($npub)
{
$authorHex = $this->npubToAuthorHex64($npub);
if ($authorHex === null) {
return [];
}
$key = MagazineEventKeys::relayList10002($authorHex);
$row = $this->eventRepository->findOneByCoreRowKey($key);
if ($row !== null) {
return self::relayWssListFromNip65Tags($row->getTags());
}
$wire = $this->nostrClient->getNpubRelayList10002Wire($npub);
if ($wire === null) {
return [];
}
$this->replaceByCoreKey($key, Event::STORAGE_RELAY_LIST_10002, $wire);
return NostrClient::relayWssListFromNip65Object($wire);
} }
/** /**
@ -126,75 +145,162 @@ readonly class CacheService
return $out; return $out;
} }
/** private function npubToAuthorHex64(string $npub): ?string
* @param list<string> $authorPubkeyHex
* @param array<string, \stdClass> $metadataByHex from {@see NostrClient::fetchKind0MetadataForAuthors}
*/
public function putPrewarmMetadataBatch(array $authorPubkeyHex, array $metadataByHex, Key $key): int
{ {
$n = 0; if (64 === \strlen($npub) && ctype_xdigit($npub)) {
foreach ($authorPubkeyHex as $hex) { return strtolower($npub);
if (strlen($hex) !== 64) {
continue;
} }
$npub = $key->convertPublicKeyToBech32($hex); if (str_starts_with($npub, 'npub1')) {
if (isset($metadataByHex[$hex]) && $metadataByHex[$hex] instanceof \stdClass) { try {
$this->putProfileInCache($npub, $metadataByHex[$hex]); $h = (new Key())->convertToHex($npub);
} else { } catch (\Throwable) {
$this->putProfilePlaceholderInCache($npub); $h = '';
}
if (64 === \strlen((string) $h) && ctype_xdigit((string) $h)) {
return strtolower($h);
} }
++$n;
} }
return $n; return null;
} }
public function getRelays($npub) private function replaceByCoreKey(string $coreKey, string $storageRole, object $rawWire): void
{ {
$cacheKey = '3_' . $npub; $entity = $this->wireToEventEntity($rawWire);
try { if ($entity === null) {
return $this->cache->get($cacheKey, function (ItemInterface $item) use ($npub) { return;
$item->expiresAfter(3600); // 1 hour
try {
return $this->nostrClient->getNpubRelays($npub);
} catch (\Exception $e) {
$this->logger->error('Error getting relays.', ['exception' => $e]);
return [];
} }
}); $entity->setCoreRowKey($coreKey);
} catch (InvalidArgumentException $e) { $entity->setStorageRole($storageRole);
$this->logger->error('Error getting relay data.', ['exception' => $e]); if ($entity->getEventId() === null) {
return []; $entity->setEventId($entity->getId());
} }
$prev = $this->eventRepository->findOneByCoreRowKey($coreKey);
if ($prev !== null && $prev->getId() === $entity->getId()) {
$prev->setKind($entity->getKind());
$prev->setPubkey($entity->getPubkey());
$prev->setContent($entity->getContent());
$prev->setCreatedAt($entity->getCreatedAt());
$prev->setTags($entity->getTags());
$prev->setSig($entity->getSig());
$prev->setCoreRowKey($coreKey);
$prev->setStorageRole($storageRole);
if ($entity->getEventId() !== null) {
$prev->setEventId($entity->getEventId());
} }
$this->entityManager->flush();
private function putProfileInCache(string $npub, \stdClass $content): void return;
}
if ($prev !== null) {
$this->entityManager->remove($prev);
$this->entityManager->flush();
}
$this->entityManager->persist($entity);
$this->entityManager->flush();
}
private function wireToEventEntity(object $raw): ?Event
{ {
try { try {
$item = $this->appCache->getItem('0_'.$npub); $data = json_decode(json_encode($raw, \JSON_THROW_ON_ERROR), true, 512, \JSON_THROW_ON_ERROR);
$item->set($content); } catch (\JsonException) {
$item->expiresAfter(3600); return null;
$this->appCache->save($item); }
} catch (InvalidArgumentException $e) { if (!\is_array($data)) {
$this->logger->error('putProfileInCache', ['npub' => $npub, 'exception' => $e]); return null;
}
$id = (string) ($data['id'] ?? '');
if (64 !== \strlen($id) || !ctype_xdigit($id)) {
return null;
} }
$e = new Event();
$e->setId(strtolower($id));
$e->setEventId(strtolower($id));
$e->setKind((int) ($data['kind'] ?? 0));
$e->setPubkey(strtolower((string) ($data['pubkey'] ?? '')));
$e->setContent((string) ($data['content'] ?? ''));
$e->setCreatedAt((int) ($data['created_at'] ?? 0));
$tags = $data['tags'] ?? [];
$e->setTags(\is_array($tags) ? $tags : []);
$e->setSig((string) ($data['sig'] ?? ''));
return $e;
} }
private function putProfilePlaceholderInCache(string $npub): void private function bundleFromKind0EventRow(Event $row, string $npub): array
{
$content = $this->decodeKind0ContentString($row->getContent());
if (!\is_object($content) || $this->isPlaceholderContent($content, $npub)) {
$content = $this->namePlaceholderNpubObject($npub);
}
return [
'content' => $content,
'kind0_tags' => self::normalizeEventTagsList($row->getTags()),
];
}
private function decodeKind0ContentObject(object $ev): \stdClass
{
return $this->decodeKind0ContentString((string) ($ev->content ?? ''));
}
private function decodeKind0ContentString(string $raw): \stdClass
{ {
try { try {
$item = $this->appCache->getItem('0_'.$npub); $data = \json_decode($raw, false, 512, \JSON_THROW_ON_ERROR);
if ($item->isHit()) { } catch (\JsonException) {
// Prewarm miss: keep an earlier good (or any) value — do not downgrade to placeholder. return new \stdClass();
return; }
if (!\is_object($data)) {
return new \stdClass();
} }
} catch (InvalidArgumentException $e) {
$this->logger->error('putProfilePlaceholderInCache', ['npub' => $npub, 'exception' => $e]);
return; return $data;
} }
private function isPlaceholderContent(\stdClass $content, string $npub): bool
{
$n = (string) ($content->name ?? '');
return $n === substr($npub, 0, 8).'…'.substr($npub, -4);
}
private function namePlaceholderNpubObject(string $npub): \stdClass
{
$c = new \stdClass(); $c = new \stdClass();
$c->name = substr($npub, 0, 8).'…'.substr($npub, -4); $c->name = substr($npub, 0, 8).'…'.substr($npub, -4);
$this->putProfileInCache($npub, $c);
return $c;
}
private function placeholderMetadataBundle(string $npub): array
{
return [
'content' => $this->namePlaceholderNpubObject($npub),
'kind0_tags' => [],
];
}
/**
* @param list<list<string>>|array $tags
* @return list<string>
*/
private static function relayWssListFromNip65Tags(array $tags): array
{
$relays = [];
foreach ($tags as $tag) {
if (!\is_array($tag) || !isset($tag[0], $tag[1])) {
continue;
}
if ((string) $tag[0] === 'r') {
$relays[] = (string) $tag[1];
}
}
return array_filter(array_unique($relays), static function (string $relay) {
return str_starts_with($relay, 'wss:') && !str_contains($relay, 'localhost');
});
} }
} }

184
src/Service/MagazineContentService.php

@ -8,12 +8,13 @@ use App\Entity\Article;
use App\Entity\Event; use App\Entity\Event;
use App\Enum\EventStatusEnum; use App\Enum\EventStatusEnum;
use App\Repository\ArticleRepository; use App\Repository\ArticleRepository;
use App\Util\NostrEventTags;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\RequestStack;
/** /**
* Magazine index for templates. Reads {@see MagazineIndexStore} only on HTTP; relay refresh and DB * Magazine index for templates. The store is filled by `app:prewarm` (cron) / CLI; missing 30040
* backfill for category long-form are done by `app:prewarm` (cron) / CLI. * snapshots can be loaded once per request from relays (see ensure* methods).
*/ */
final class MagazineContentService final class MagazineContentService
{ {
@ -65,6 +66,10 @@ final class MagazineContentService
$npub = (string) $this->params->get('npub'); $npub = (string) $this->params->get('npub');
$dTag = (string) $this->params->get('d_tag'); $dTag = (string) $this->params->get('d_tag');
$mag = $this->store->getRoot($npub, $dTag); $mag = $this->store->getRoot($npub, $dTag);
if ($mag === null) {
$this->ensureRoot30040FromRelays($npub, $dTag);
$mag = $this->store->getRoot($npub, $dTag);
}
return $this->categoryATagsFromMag($mag); return $this->categoryATagsFromMag($mag);
} }
@ -78,11 +83,19 @@ final class MagazineContentService
return []; return [];
} }
$tags = $mag->getTags(); $tags = $mag->getTags();
$cats = array_filter($tags, static function (mixed $tag): bool { $cats = [];
return \is_array($tag) && ($tag[0] ?? null) === 'a'; foreach ($tags as $tag) {
}); if (!NostrEventTags::tagNameMatches($tag, 'a')) {
continue;
}
$seq = NostrEventTags::rowToStringList($tag);
if ($seq === null || !isset($seq[1]) || (string) $seq[1] === '') {
continue;
}
$cats[] = ['a', (string) $seq[1]];
}
return array_values($cats); return $cats;
} }
/** /**
@ -127,10 +140,14 @@ final class MagazineContentService
continue; continue;
} }
foreach ($catIndex->getTags() as $tag) { foreach ($catIndex->getTags() as $tag) {
if (!\is_array($tag) || ($tag[0] ?? null) !== 'a' || !isset($tag[1])) { if (!NostrEventTags::tagNameMatches($tag, 'a')) {
continue;
}
$seq = NostrEventTags::rowToStringList($tag);
if ($seq === null || !isset($seq[1])) {
continue; continue;
} }
$parts = explode(':', (string) $tag[1], 3); $parts = explode(':', (string) $seq[1], 3);
if (\count($parts) < 2) { if (\count($parts) < 2) {
continue; continue;
} }
@ -157,13 +174,18 @@ final class MagazineContentService
if ($slug === '') { if ($slug === '') {
return ''; return '';
} }
$this->warmCategoryIndexIfMissing($slug);
$catIndex = $this->store->getCategory($slug); $catIndex = $this->store->getCategory($slug);
if ($catIndex === null) { if ($catIndex === null) {
return $slug; return $slug;
} }
foreach ($catIndex->getTags() as $tag) { foreach ($catIndex->getTags() as $tag) {
if (($tag[0] ?? null) === 'title' && isset($tag[1])) { if (!NostrEventTags::tagNameMatches($tag, 'title')) {
return (string) $tag[1]; continue;
}
$seq = NostrEventTags::rowToStringList($tag);
if ($seq !== null && isset($seq[1])) {
return (string) $seq[1];
} }
} }
@ -172,26 +194,32 @@ final class MagazineContentService
/** /**
* Category listing from the persisted 30040 index and DB only. Does not call relays. * Category listing from the persisted 30040 index and DB only. Does not call relays.
* Missing `Article` rows (not yet in MySQL) appear until `app:prewarm` backfills. * Rows come from MySQL only; run `app:prewarm` to sync new `a` tags and replaceable revisions.
* *
* @return array{list: list<Article>, category: array{title: string, summary: string}} * @return array{list: list<Article>, category: array{title: string, summary: string}}
*/ */
public function getCategoryPageData(string $slug): array public function getCategoryPageData(string $slug): array
{ {
$this->warmCategoryIndexIfMissing($slug);
$catIndex = $this->store->getCategory($slug); $catIndex = $this->store->getCategory($slug);
$list = []; $list = [];
$coordinates = []; $coordinates = [];
$category = []; $category = [];
if ($catIndex) { if ($catIndex) {
foreach ($catIndex->getTags() as $tag) { foreach ($catIndex->getTags() as $tag) {
if ($tag[0] === 'title') { $seq = NostrEventTags::rowToStringList($tag);
$category['title'] = (string) $tag[1]; if ($seq === null) {
continue;
} }
if ($tag[0] === 'summary') { $name = strtolower($seq[0] ?? '');
$category['summary'] = (string) $tag[1]; if ($name === 'title' && isset($seq[1])) {
$category['title'] = (string) $seq[1];
} }
if ($tag[0] === 'a') { if ($name === 'summary' && isset($seq[1])) {
$coordinates[] = $tag[1]; $category['summary'] = (string) $seq[1];
}
if ($name === 'a' && isset($seq[1])) {
$coordinates[] = (string) $seq[1];
} }
} }
} }
@ -208,7 +236,7 @@ final class MagazineContentService
continue; continue;
} }
$pairs[] = [ $pairs[] = [
'pubkey' => (string) $parts[1], 'pubkey' => strtolower((string) $parts[1]),
'slug' => $slugPart, 'slug' => $slugPart,
]; ];
} }
@ -218,7 +246,7 @@ final class MagazineContentService
if (\count($parts) < 3) { if (\count($parts) < 3) {
continue; continue;
} }
$k = (string) $parts[1]."\0".trim((string) $parts[2]); $k = strtolower((string) $parts[1])."\0".trim((string) $parts[2]);
if (isset($byAddress[$k])) { if (isset($byAddress[$k])) {
$list[] = $byAddress[$k]; $list[] = $byAddress[$k];
} }
@ -235,19 +263,20 @@ final class MagazineContentService
} }
/** /**
* For every category in the root index, fetch Nostr long-form for `a` tags missing in MySQL. * For every category in the store, fetch the latest Nostr long-form for each `a` tag so new
* Nostr I/O; intended for {@see PrewarmCommand} / cron only. * posts are ingested and NIP-33 replaceable updates refresh existing MySQL rows. Nostr I/O;
* intended for {@see PrewarmCommand} / cron only.
*/ */
public function ingestMissingLongformForAllMagazineCategories(): int public function ingestLongformForAllMagazineCategories(): int
{ {
$n = 0; $n = 0;
foreach ($this->getCategorySlugsFromStore() as $catSlug) { foreach ($this->getCategorySlugsFromStore() as $catSlug) {
$missing = $this->findMissingLongformCoordinatesForCategory($catSlug); $all = $this->findAllLongformCoordinatesForCategory($catSlug);
if ($missing === []) { if ($all === []) {
continue; continue;
} }
$this->nostrClient->ingestMissingLongformForCategoryCoordinates($missing); $this->nostrClient->ingestLongformForCategoryCoordinates($all);
$n += \count($missing); $n += \count($all);
} }
return $n; return $n;
@ -256,53 +285,30 @@ final class MagazineContentService
/** /**
* @return list<string> Nostr coordinates kind:pubkey:identifier * @return list<string> Nostr coordinates kind:pubkey:identifier
*/ */
private function findMissingLongformCoordinatesForCategory(string $slug): array private function findAllLongformCoordinatesForCategory(string $slug): array
{ {
$catIndex = $this->store->getCategory($slug); $catIndex = $this->store->getCategory($slug);
if ($catIndex === null) { if ($catIndex === null) {
return []; return [];
} }
$coordinates = []; $out = [];
foreach ($catIndex->getTags() as $tag) { foreach ($catIndex->getTags() as $tag) {
if (($tag[0] ?? null) === 'a' && isset($tag[1])) { if (!NostrEventTags::tagNameMatches($tag, 'a')) {
$coordinates[] = (string) $tag[1];
}
}
if ($coordinates === []) {
return [];
}
$pairs = [];
foreach ($coordinates as $coordinate) {
$parts = explode(':', (string) $coordinate, 3);
if (\count($parts) < 3) {
continue; continue;
} }
$slugPart = trim((string) $parts[2]); $seq = NostrEventTags::rowToStringList($tag);
if ($slugPart === '') { if ($seq === null || !isset($seq[1]) || (string) $seq[1] === '') {
continue; continue;
} }
$pairs[] = [ $coordinate = (string) $seq[1];
'pubkey' => (string) $parts[1], $parts = explode(':', $coordinate, 3);
'slug' => $slugPart, if (\count($parts) < 3 || trim((string) $parts[2]) === '') {
];
}
if ($pairs === []) {
return [];
}
$byAddress = $this->articleRepository->findByAuthorAndSlugIndexed($pairs);
$missing = [];
foreach ($coordinates as $coordinate) {
$parts = explode(':', (string) $coordinate, 3);
if (\count($parts) < 3) {
continue; continue;
} }
$k = (string) $parts[1]."\0".trim((string) $parts[2]); $out[] = $coordinate;
if (!isset($byAddress[$k])) {
$missing[] = (string) $coordinate;
}
} }
return $missing; return $out;
} }
/** /**
@ -358,4 +364,64 @@ final class MagazineContentService
return $list; return $list;
} }
/**
* Ensures the category 30040 is in the store for this HTTP request (one relay pass per slug).
* Safe to call from e.g. {@see \App\Twig\Components\Molecules\CategoryLink} before reading titles.
*/
public function warmCategoryIndexIfMissing(string $slug): void
{
if ($this->store->getCategory($slug) !== null) {
return;
}
$this->ensureCategory30040FromRelays($slug);
}
private function ensureRoot30040FromRelays(string $npub, string $dTag): void
{
$r = $this->requestStack->getCurrentRequest();
if ($r !== null && $r->attributes->get('_magazine_root_ensured')) {
return;
}
try {
$e = $this->nostrClient->getMagazineIndex($npub, $dTag);
if ($e !== null) {
$this->store->putRoot($npub, $dTag, $e);
}
} catch (\Throwable) {
}
if ($r !== null) {
$r->attributes->set('_magazine_root_ensured', true);
}
}
private function ensureCategory30040FromRelays(string $slug): void
{
if (trim($slug) === '') {
return;
}
if ($this->store->getCategory($slug) !== null) {
return;
}
$r = $this->requestStack->getCurrentRequest();
if ($r !== null) {
$tried = $r->attributes->get('_magazine_category_fetch_tried', []);
if (!\is_array($tried)) {
$tried = [];
}
if (\in_array($slug, $tried, true)) {
return;
}
$tried[] = $slug;
$r->attributes->set('_magazine_category_fetch_tried', $tried);
}
$npub = (string) $this->params->get('npub');
try {
$e = $this->nostrClient->getMagazineIndex($npub, $slug);
if ($e !== null) {
$this->store->putCategory($slug, $e);
}
} catch (\Throwable) {
}
}
} }

126
src/Service/MagazineIndexStore.php

@ -5,34 +5,33 @@ declare(strict_types=1);
namespace App\Service; namespace App\Service;
use App\Entity\Event; use App\Entity\Event;
use Psr\Cache\CacheItemPoolInterface; use App\Nostr\MagazineEventKeys;
use Psr\Cache\InvalidArgumentException; use App\Repository\EventRepository;
use Doctrine\ORM\EntityManagerInterface;
/** /**
* Read/write persisted magazine Nostr index events (kinds 30040) without callback-based relay I/O * Magazine Nostr index events (kind 30040) in MySQL {@see Event}. Updated by {@see MagazineRefresher}
* on the request path. Updated by {@see MagazineRefresher} (via `app:prewarm` / cron, or explicit CLI use). * (`app:prewarm` / cron).
*/ */
final class MagazineIndexStore final class MagazineIndexStore
{ {
private const ROOT_PREFIX = 'mroot_v1_';
private const CAT_PREFIX = 'mcat_v1_';
/** 30 days — we refresh on page load, TTL is a safety cap if sync stops working. */
private const PERSIST_TTL = 2_592_000;
public function __construct( public function __construct(
private readonly CacheItemPoolInterface $pool, private readonly EntityManagerInterface $entityManager,
private readonly EventRepository $eventRepository,
) { ) {
} }
public function getRoot(string $npub, string $dTag): ?Event public function getRoot(string $npub, string $dTag): ?Event
{ {
$item = $this->pool->getItem($this->rootKey($npub, $dTag)); if ($dTag === '') {
if (!$item->isHit()) { return null;
}
$key = MagazineEventKeys::magazineRoot($npub, $dTag);
if ($key === '') {
return null; return null;
} }
return $this->unwrap($item->get()); return $this->eventRepository->findOneByCoreRowKey($key);
} }
public function getCategory(string $slug): ?Event public function getCategory(string $slug): ?Event
@ -40,85 +39,86 @@ final class MagazineIndexStore
if ($slug === '') { if ($slug === '') {
return null; return null;
} }
$item = $this->pool->getItem($this->categoryKey($slug)); $key = MagazineEventKeys::magazineCategory($slug);
if (!$item->isHit()) {
return null;
}
return $this->unwrap($item->get()); return $this->eventRepository->findOneByCoreRowKey($key);
} }
/**
* @throws InvalidArgumentException
*/
public function putRoot(string $npub, string $dTag, Event $event): void public function putRoot(string $npub, string $dTag, Event $event): void
{ {
$item = $this->pool->getItem($this->rootKey($npub, $dTag)); if ($dTag === '') {
$item->set(serialize($event)); return;
$item->expiresAfter(self::PERSIST_TTL); }
$this->pool->save($item); $key = MagazineEventKeys::magazineRoot($npub, $dTag);
if ($key === '') {
return;
}
$this->replaceByCoreKey($key, Event::STORAGE_MAGAZINE_ROOT, $event);
} }
/**
* @throws InvalidArgumentException
*/
public function putCategory(string $slug, Event $event): void public function putCategory(string $slug, Event $event): void
{ {
if ($slug === '') { if ($slug === '') {
return; return;
} }
$item = $this->pool->getItem($this->categoryKey($slug)); $key = MagazineEventKeys::magazineCategory($slug);
$item->set(serialize($event)); $this->replaceByCoreKey($key, Event::STORAGE_MAGAZINE_CATEGORY, $event);
$item->expiresAfter(self::PERSIST_TTL);
$this->pool->save($item);
} }
/**
* Remove a cached category index (NIP-09 / local invalidation).
*
* @throws InvalidArgumentException
*/
public function deleteCategory(string $slug): void public function deleteCategory(string $slug): void
{ {
if ($slug === '') { if ($slug === '') {
return; return;
} }
$this->pool->deleteItem($this->categoryKey($slug)); $key = MagazineEventKeys::magazineCategory($slug);
$this->removeByCoreKey($key);
} }
/**
* Remove the cached root magazine index for this npub + d_tag.
*
* @throws InvalidArgumentException
*/
public function deleteRoot(string $npub, string $dTag): void public function deleteRoot(string $npub, string $dTag): void
{ {
$this->pool->deleteItem($this->rootKey($npub, $dTag)); if ($dTag === '') {
return;
} }
$key = MagazineEventKeys::magazineRoot($npub, $dTag);
private function rootKey(string $npub, string $dTag): string $this->removeByCoreKey($key);
{
return self::ROOT_PREFIX.hash('sha256', $npub."\0".$dTag);
} }
/** private function replaceByCoreKey(string $coreKey, string $role, Event $incoming): void
* Category `d` / slug strings may contain colons (NIP-33 `a` segments); PSR-6 keys must not use `{}()/\@:`.
*/
private function categoryKey(string $slug): string
{ {
return self::CAT_PREFIX.hash('sha256', $slug); $prev = $this->eventRepository->findOneByCoreRowKey($coreKey);
} if ($prev !== null && $prev->getId() === $incoming->getId()) {
$prev->setKind($incoming->getKind());
$prev->setPubkey($incoming->getPubkey());
$prev->setContent($incoming->getContent());
$prev->setCreatedAt($incoming->getCreatedAt());
$prev->setTags($incoming->getTags());
$prev->setSig($incoming->getSig());
$prev->setCoreRowKey($coreKey);
$prev->setStorageRole($role);
if ($incoming->getEventId() !== null) {
$prev->setEventId($incoming->getEventId());
}
$this->entityManager->flush();
private function unwrap(mixed $value): ?Event return;
{
if (!\is_string($value) || $value === '') {
return null;
} }
$e = unserialize($value, ['allowed_classes' => [Event::class]]); if ($prev !== null) {
if (!$e instanceof Event) { $this->entityManager->remove($prev);
return null; $this->entityManager->flush();
}
$incoming->setCoreRowKey($coreKey);
$incoming->setStorageRole($role);
$this->entityManager->persist($incoming);
$this->entityManager->flush();
} }
return $e; private function removeByCoreKey(string $coreKey): void
{
$e = $this->eventRepository->findOneByCoreRowKey($coreKey);
if ($e === null) {
return;
}
$this->entityManager->remove($e);
$this->entityManager->flush();
} }
} }

117
src/Service/MagazineRefresher.php

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Service; namespace App\Service;
use App\Entity\Event; use App\Entity\Event;
use App\Util\NostrEventTags;
use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
@ -24,12 +25,27 @@ final class MagazineRefresher
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
private readonly CacheItemPoolInterface $appCache, private readonly CacheItemPoolInterface $appCache,
private readonly FeaturedAuthorSync $featuredAuthorSync, private readonly FeaturedAuthorSync $featuredAuthorSync,
/**
* Comma-separated category #d slugs (from the root index `a` tags) to fetch first after the root
* when the magazine relay phase is time-bounded; see MAGAZINE_PREWARM_PREFER_SLUGS in .env.
*/
private readonly string $magazinePrewarmPreferSlugs = '',
/**
* Comma-separated category #d slugs to always run a 30040 fetch for in prewarm, after the
* slugs from the live root (e.g. politics while the cached root has not yet listed that `a` tag).
*/
private readonly string $magazinePrewarmAlsoSlugs = '',
) { ) {
} }
/** /**
* Fetches the root index then each category index until $budgetSeconds elapses. $preferSlugs * Fetches the root 30040, then each category 30040. The soft wall-time budget applies to the
* are requested first (e.g. current /cat route) so they are less likely to miss the budget. * **category phase only** (after the root is stored). The root fetch is not counted against that
* window—otherwise a slow root can consume the entire default budget and no category would be
* refreshed (stale per-category cache while the root looks current).
*
* $preferSlugs are requested first (e.g. current /cat route) so they are less likely to miss
* the category budget if the slug list is long.
* *
* @param (callable(string, array<string, int|string|bool|null>): void)|null $onProgress * @param (callable(string, array<string, int|string|bool|null>): void)|null $onProgress
* Phases: `before_root`, `after_root` (total_steps, step, slug_count, slugs: list<string>), * Phases: `before_root`, `after_root` (total_steps, step, slug_count, slugs: list<string>),
@ -37,19 +53,24 @@ final class MagazineRefresher
*/ */
public function refreshFromRelays(int $budgetSeconds = 8, array $preferSlugs = [], ?callable $onProgress = null): void public function refreshFromRelays(int $budgetSeconds = 8, array $preferSlugs = [], ?callable $onProgress = null): void
{ {
$budgetSeconds = max(1, min(30, $budgetSeconds)); // Allow large budgets (PrewarmCommand --magazine-budget). Hard cap only to avoid runaway PHP time.
$deadline = microtime(true) + $budgetSeconds; $budgetSeconds = max(1, min(600, $budgetSeconds));
$npub = (string) $this->params->get('npub'); $npub = (string) $this->params->get('npub');
$dTag = (string) $this->params->get('d_tag'); $dTag = (string) $this->params->get('d_tag');
$preferFromEnv = $this->parseCommaSeparatedSlugs($this->magazinePrewarmPreferSlugs);
// Do not set max_execution_time to the *remaining* soft budget: PHP resets the timer, so // Allow enough PHP wall time for a slow root fetch plus the full category-phase budget.
// after a 6s root fetch, "2s left" would become a 2s hard cap for the *next* relay I/O $this->applyExecutionTimeCap(2 * $budgetSeconds);
// (e.g. slow TLS) and can fatal. Cap once with headroom; the $deadline loop limits work.
$this->applyExecutionTimeCap($budgetSeconds);
$defaultRelay = (string) $this->params->get('default_relay'); $defaultRelay = (string) $this->params->get('default_relay');
$relayLabel = (string) (parse_url($defaultRelay, \PHP_URL_HOST) ?: $defaultRelay); $relayLabel = (string) (parse_url($defaultRelay, \PHP_URL_HOST) ?: $defaultRelay);
if ($preferFromEnv !== []) {
$this->logger->info('MagazineRefresher: prefer slugs (env) merged into fetch order', [
'prefer' => $preferFromEnv,
]);
}
$onProgress?->__invoke('before_root', []); $onProgress?->__invoke('before_root', []);
$root = $this->nostrClient->getMagazineIndex($npub, $dTag); $root = $this->nostrClient->getMagazineIndex($npub, $dTag);
if ($root === null) { if ($root === null) {
@ -67,7 +88,20 @@ final class MagazineRefresher
$this->store->putRoot($npub, $dTag, $root); $this->store->putRoot($npub, $dTag, $root);
$slugs = $this->orderedCategorySlugs($this->categorySlugsFromRoot($root), $preferSlugs); $deadline = microtime(true) + $budgetSeconds;
$mergedPrefer = $this->mergePreferSlugsInOrder($preferSlugs, $preferFromEnv);
$alsoFromEnv = $this->parseCommaSeparatedSlugs($this->magazinePrewarmAlsoSlugs);
if ($alsoFromEnv !== []) {
$this->logger->info('MagazineRefresher: also slugs (env) merged into 30040 fetch list', [
'also' => $alsoFromEnv,
]);
}
$slugs = $this->orderedCategorySlugs(
$this->categorySlugsFromRoot($root),
$mergedPrefer,
$alsoFromEnv
);
$totalSteps = 1 + \count($slugs); $totalSteps = 1 + \count($slugs);
$onProgress?->__invoke('after_root', [ $onProgress?->__invoke('after_root', [
'total_steps' => $totalSteps, 'total_steps' => $totalSteps,
@ -152,14 +186,18 @@ final class MagazineRefresher
{ {
$slugs = []; $slugs = [];
foreach ($root->getTags() as $tag) { foreach ($root->getTags() as $tag) {
if (($tag[0] ?? null) !== 'a' || !isset($tag[1])) { if (!NostrEventTags::tagNameMatches($tag, 'a')) {
continue;
}
$seq = NostrEventTags::rowToStringList($tag);
if ($seq === null || !isset($seq[1]) || (string) $seq[1] === '') {
continue; continue;
} }
$parts = explode(':', (string) $tag[1], 3); $parts = explode(':', (string) $seq[1], 3);
if (\count($parts) < 3) { if (\count($parts) < 3) {
continue; continue;
} }
$s = trim((string) end($parts)); $s = trim((string) $parts[2]);
if ($s !== '' && !\in_array($s, $slugs, true)) { if ($s !== '' && !\in_array($s, $slugs, true)) {
$slugs[] = $s; $slugs[] = $s;
} }
@ -169,16 +207,29 @@ final class MagazineRefresher
} }
/** /**
* Order: prefer (incl. MAGAZINE_PREWARM_PREFER_SLUGS), then MAGAZINE_PREWARM_ALSO_SLUGS, then
* each remaining category from the live root 30040. "Also" runs before the root tail so a
* time-bounded prewarm still fetches e.g. a new politics category 30040 even if the slug list
* from the root is long and the soft budget would stop before the former end of the list.
*
* @param list<string> $allFromRoot * @param list<string> $allFromRoot
* @param list<string> $prefer * @param list<string> $prefer
* @param list<string> $also
*
* @return list<string> * @return list<string>
*/ */
private function orderedCategorySlugs(array $allFromRoot, array $prefer): array private function orderedCategorySlugs(array $allFromRoot, array $prefer, array $also): array
{ {
$prefer = array_values(array_filter($prefer, static function (string $s): bool { $prefer = array_values(array_filter($prefer, static function (string $s): bool {
return $s !== ''; return $s !== '';
})); }));
$out = $prefer; $out = $prefer;
foreach ($also as $s) {
$s = trim($s);
if ($s !== '' && !\in_array($s, $out, true)) {
$out[] = $s;
}
}
foreach ($allFromRoot as $s) { foreach ($allFromRoot as $s) {
if (!\in_array($s, $out, true)) { if (!\in_array($s, $out, true)) {
$out[] = $s; $out[] = $s;
@ -205,8 +256,46 @@ final class MagazineRefresher
*/ */
private function applyExecutionTimeCap(int $budgetSeconds): void private function applyExecutionTimeCap(int $budgetSeconds): void
{ {
$sec = max(30, min(120, $budgetSeconds + 30)); $sec = max(30, min(700, $budgetSeconds + 30));
@set_time_limit($sec); @set_time_limit($sec);
@ini_set('max_execution_time', (string) $sec); @ini_set('max_execution_time', (string) $sec);
} }
/**
* @return list<string>
*/
private function parseCommaSeparatedSlugs(string $raw): array
{
if (trim($raw) === '') {
return [];
}
$out = [];
foreach (explode(',', $raw) as $part) {
$s = trim($part);
if ($s !== '' && !\in_array($s, $out, true)) {
$out[] = $s;
}
}
return $out;
}
/**
* @param list<string> $fromCaller e.g. current /cat route (first)
* @param list<string> $fromEnv MAGAZINE_PREWARM_PREFER_SLUGS (next)
*
* @return list<string>
*/
private function mergePreferSlugsInOrder(array $fromCaller, array $fromEnv): array
{
$out = [];
foreach (array_merge($fromCaller, $fromEnv) as $s) {
$s = trim((string) $s);
if ($s !== '' && !\in_array($s, $out, true)) {
$out[] = $s;
}
}
return $out;
}
} }

177
src/Service/Nip09DeletionApplier.php

@ -6,7 +6,9 @@ namespace App\Service;
use App\Entity\Event as MagazineNostrEvent; use App\Entity\Event as MagazineNostrEvent;
use App\Enum\KindsEnum; use App\Enum\KindsEnum;
use App\Nostr\MagazineEventKeys;
use App\Repository\ArticleRepository; use App\Repository\ArticleRepository;
use App\Repository\EventRepository;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use swentel\nostr\Key\Key; use swentel\nostr\Key\Key;
@ -15,14 +17,13 @@ use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
/** /**
* Applies NIP-09 (kind 5) deletion requests to: * Applies NIP-09 (kind 5) deletion requests to:
* - MySQL: long-form articles ({@see KindsEnum::LONGFORM} 30023, {@see KindsEnum::LONGFORM_DRAFT} 30024) * - MySQL: long-form articles ({@see KindsEnum::LONGFORM} 30023, {@see KindsEnum::LONGFORM_DRAFT} 30024)
* - Magazine cache: publication indices ({@see KindsEnum::PUBLICATION_INDEX} 30040) in {@see MagazineIndexStore} * - MySQL {@see Event} rows: kind 30040 magazine indices (root + category), kind 0 profile, 10002 relay list, 10133 payto
* *
* Both are handled for `e` tags (with `k` when present) and for NIP-33 `a` tags. * Handled for `e` tags (with `k` when present) and for NIP-33 `a` tags.
* *
* Relays are not authoritative; we only remove data we can validate (same pubkey as deletion request). * Relays are not authoritative; we only remove data we can validate (same pubkey as deletion request).
* For cached 30040 category indices (keyed by `d` only), we require the stored event’s author * For category 30040 rows (keyed by `d` only), we require the stored event’s author to match the
* to match the deletion — not just an `a` tag whose own pubkey matches, so colliding `d` values * deletion author so colliding `d` values across authors cannot wipe another author’s index.
* across authors cannot wipe another author’s cache entry.
*/ */
final class Nip09DeletionApplier final class Nip09DeletionApplier
{ {
@ -30,6 +31,7 @@ final class Nip09DeletionApplier
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
private readonly ArticleRepository $articleRepository, private readonly ArticleRepository $articleRepository,
private readonly MagazineIndexStore $magazineIndexStore, private readonly MagazineIndexStore $magazineIndexStore,
private readonly EventRepository $eventRepository,
private readonly ParameterBagInterface $params, private readonly ParameterBagInterface $params,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
) { ) {
@ -73,9 +75,11 @@ final class Nip09DeletionApplier
KindsEnum::LONGFORM->value, KindsEnum::LONGFORM->value,
KindsEnum::LONGFORM_DRAFT->value, KindsEnum::LONGFORM_DRAFT->value,
KindsEnum::PUBLICATION_INDEX->value, KindsEnum::PUBLICATION_INDEX->value,
KindsEnum::METADATA->value,
KindsEnum::RELAY_LIST->value,
KindsEnum::PAYMENT_TARGETS->value,
1, // NIP-09 may include kind 1; we do not store notes, but must not treat k as “unknown” 1, // NIP-09 may include kind 1; we do not store notes, but must not treat k as “unknown”
], true)) { ], true)) {
// Other kinds: we do not mirror in this app; skip.
continue; continue;
} }
if ($declared === 1) { if ($declared === 1) {
@ -86,7 +90,9 @@ final class Nip09DeletionApplier
++$articlesPendingFlush; ++$articlesPendingFlush;
continue; continue;
} }
// No DB row: try kind 30040 magazine index by event id; also 30023/24 if not mirrored in DB. if ($this->tryRemoveCoreEventRowByEventId($eId, $deletionPubkey, $declared)) {
continue;
}
if ($declared === null || \in_array($declared, [ if ($declared === null || \in_array($declared, [
KindsEnum::LONGFORM->value, KindsEnum::LONGFORM->value,
KindsEnum::LONGFORM_DRAFT->value, KindsEnum::LONGFORM_DRAFT->value,
@ -110,13 +116,11 @@ final class Nip09DeletionApplier
} }
} }
if ($articlesPendingFlush > 0) {
try { try {
$this->entityManager->flush(); $this->entityManager->flush();
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->logger->error('Nip09DeletionApplier: flush failed', ['exception' => $e]); $this->logger->error('Nip09DeletionApplier: flush failed', ['exception' => $e]);
} }
}
return [ return [
'articles_removed' => $articlesRemoved, 'articles_removed' => $articlesRemoved,
@ -125,53 +129,86 @@ final class Nip09DeletionApplier
]; ];
} }
/** 0 = none, 1 = root cache, 2 = category cache */ /**
* Kind 0 / 10002 / 10133 rows in {@see Event} (profile, relay list, payto), by Nostr event id.
*/
private function tryRemoveCoreEventRowByEventId(string $eventId, string $deletionPubkey, ?int $declared): bool
{
$eid = strtolower($eventId);
$e = $this->eventRepository->find($eid);
if ($e === null) {
return false;
}
if (!$this->pubkeyEquals($e->getPubkey(), $deletionPubkey)) {
return false;
}
$k = (int) $e->getKind();
if ($declared !== null && $declared !== $k) {
return false;
}
if (!\in_array($k, [
KindsEnum::METADATA->value,
KindsEnum::RELAY_LIST->value,
KindsEnum::PAYMENT_TARGETS->value,
], true)) {
return false;
}
if ($k === KindsEnum::METADATA->value) {
if ($e->getStorageRole() !== null && $e->getStorageRole() !== MagazineNostrEvent::STORAGE_PROFILE_KIND0) {
return false;
}
} elseif ($k === KindsEnum::RELAY_LIST->value) {
if ($e->getStorageRole() !== null && $e->getStorageRole() !== MagazineNostrEvent::STORAGE_RELAY_LIST_10002) {
return false;
}
} elseif ($k === KindsEnum::PAYMENT_TARGETS->value) {
if ($e->getStorageRole() !== null && $e->getStorageRole() !== MagazineNostrEvent::STORAGE_PAYTO_10133) {
return false;
}
}
$this->entityManager->remove($e);
$this->logger->notice('NIP-09: removed core event row', [
'event_id' => $eid,
'kind' => $k,
]);
return true;
}
/** 0 = none, 1 = root row, 2 = category row */
private function tryRemoveMagazine30040ByEventId(string $eventId, string $deletionPubkey): int private function tryRemoveMagazine30040ByEventId(string $eventId, string $deletionPubkey): int
{ {
$npub = (string) $this->params->get('npub'); $eid = strtolower($eventId);
$dTag = (string) $this->params->get('d_tag'); $e = $this->eventRepository->find($eid);
if ($npub === '' || $dTag === '') { if ($e === null) {
return 0;
}
if ((int) $e->getKind() !== KindsEnum::PUBLICATION_INDEX->value) {
return 0; return 0;
} }
$root = $this->magazineIndexStore->getRoot($npub, $dTag); if (!$this->pubkeyEquals($e->getPubkey(), $deletionPubkey)) {
if ($root === null) {
return 0; return 0;
} }
if ($this->eventIdMatches($root, $eventId) && $this->pubkeyEquals($root->getPubkey(), $deletionPubkey)) { if ($e->getStorageRole() === MagazineNostrEvent::STORAGE_MAGAZINE_ROOT) {
$this->magazineIndexStore->deleteRoot($npub, $dTag); $this->entityManager->remove($e);
$this->logger->notice('NIP-09: removed cached magazine root index', [ $this->logger->notice('NIP-09: removed magazine root index (event table)', [
'event_id' => $eventId, 'event_id' => $eid,
]); ]);
return 1; return 1;
} }
foreach ($this->categorySlugsFromRoot($root) as $slug) { if ($e->getStorageRole() === MagazineNostrEvent::STORAGE_MAGAZINE_CATEGORY) {
$cat = $this->magazineIndexStore->getCategory($slug); $this->entityManager->remove($e);
if ($cat === null) { $this->logger->notice('NIP-09: removed magazine category index (event table)', [
continue; 'event_id' => $eid,
}
if ($this->eventIdMatches($cat, $eventId) && $this->pubkeyEquals($cat->getPubkey(), $deletionPubkey)) {
$this->magazineIndexStore->deleteCategory($slug);
$this->logger->notice('NIP-09: removed cached magazine category index', [
'event_id' => $eventId,
'slug' => $slug,
]); ]);
return 2; return 2;
} }
}
return 0; return 0;
} }
private function eventIdMatches(MagazineNostrEvent $e, string $eventId): bool
{
$a = strtolower($e->getId());
$b = strtolower($eventId);
return $a === $b;
}
private function pubkeyEquals(string $a, string $b): bool private function pubkeyEquals(string $a, string $b): bool
{ {
if (64 !== \strlen($a) || 64 !== \strlen($b)) { if (64 !== \strlen($a) || 64 !== \strlen($b)) {
@ -181,29 +218,6 @@ final class Nip09DeletionApplier
return strtolower($a) === strtolower($b); return strtolower($a) === strtolower($b);
} }
/**
* @return list<string>
*/
private function categorySlugsFromRoot(MagazineNostrEvent $root): array
{
$slugs = [];
foreach ($root->getTags() as $tag) {
if (($tag[0] ?? null) !== 'a' || !isset($tag[1])) {
continue;
}
$parts = explode(':', (string) $tag[1], 3);
if (\count($parts) < 3) {
continue;
}
$s = trim((string) end($parts));
if ($s !== '' && !\in_array($s, $slugs, true)) {
$slugs[] = $s;
}
}
return $slugs;
}
/** /**
* @param array<string, true> $seenArticleIds * @param array<string, true> $seenArticleIds
*/ */
@ -261,11 +275,50 @@ final class Nip09DeletionApplier
$kind = (int) $parts[0]; $kind = (int) $parts[0];
$pk = (string) $parts[1]; $pk = (string) $parts[1];
$d = trim((string) $parts[2]); $d = trim((string) $parts[2]);
if ($d === '' || !$this->pubkeyEquals($pk, $deletionPubkey)) { if (!$this->pubkeyEquals($pk, $deletionPubkey)) {
return $out;
}
if ($kind === KindsEnum::METADATA->value) {
if ($d !== '' && $d !== '0') {
return $out;
}
$row = $this->eventRepository->findOneByCoreRowKey(MagazineEventKeys::profileKind0(strtolower($pk)));
if ($row !== null && (int) $row->getKind() === KindsEnum::METADATA->value) {
$this->entityManager->remove($row);
$this->logger->notice('NIP-09: removed profile row (a tag)', ['address' => $addr]);
}
return $out;
}
if ($kind === KindsEnum::RELAY_LIST->value) {
$row = $this->eventRepository->findOneByCoreRowKey(MagazineEventKeys::relayList10002(strtolower($pk)));
if ($row !== null && (int) $row->getKind() === KindsEnum::RELAY_LIST->value) {
$this->entityManager->remove($row);
$this->logger->notice('NIP-09: removed relay list row (a tag)', ['address' => $addr]);
}
return $out;
}
if ($kind === KindsEnum::PAYMENT_TARGETS->value) {
if ($d === '') {
return $out;
}
$row = $this->eventRepository->findOneByCoreRowKey(MagazineEventKeys::payto10133(strtolower($pk), $d));
if ($row !== null && (int) $row->getKind() === KindsEnum::PAYMENT_TARGETS->value) {
$this->entityManager->remove($row);
$this->logger->notice('NIP-09: removed payto 10133 row (a tag)', ['address' => $addr]);
}
return $out; return $out;
} }
if ($kind === KindsEnum::LONGFORM->value || $kind === KindsEnum::LONGFORM_DRAFT->value) { if ($kind === KindsEnum::LONGFORM->value || $kind === KindsEnum::LONGFORM_DRAFT->value) {
if ($d === '') {
return $out;
}
$article = $this->articleRepository->findOneBy(['pubkey' => $pk, 'slug' => $d]); $article = $this->articleRepository->findOneBy(['pubkey' => $pk, 'slug' => $d]);
if ($article !== null) { if ($article !== null) {
$eid = (string) ($article->getEventId() ?? ''); $eid = (string) ($article->getEventId() ?? '');

1056
src/Service/NostrClient.php

File diff suppressed because it is too large Load Diff

52
src/Service/NostrPathHelper.php

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Article;
use swentel\nostr\Key\Key;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* Canonical /p/{npub}/d/{slug} links for long-form and helpers for templates.
*/
final class NostrPathHelper
{
public function __construct(
private readonly UrlGeneratorInterface $router,
) {
}
public function npubFromPubkeyHex(string $pubkeyHex): string
{
return (new Key())->convertPublicKeyToBech32($pubkeyHex);
}
public function articlePath(Article $article): string
{
$slug = (string) ($article->getSlug() ?? '');
if ($slug === '' || $article->getPubkey() === null) {
return '';
}
$npub = $this->npubFromPubkeyHex((string) $article->getPubkey());
return $this->router->generate('article', [
'npub' => $npub,
'slug' => $slug,
]);
}
public function articleAbsoluteUrl(Article $article): string
{
$slug = (string) ($article->getSlug() ?? '');
if ($slug === '' || $article->getPubkey() === null) {
return '';
}
return $this->router->generate('article', [
'npub' => $this->npubFromPubkeyHex((string) $article->getPubkey()),
'slug' => $slug,
], UrlGeneratorInterface::ABSOLUTE_URL);
}
}

402
src/Service/NostrShareMenuBuilder.php

@ -0,0 +1,402 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Dto\NostrShareMenuContext;
use App\Entity\Article;
use App\Entity\Event;
use App\Nostr\Nip19Addressable;
use App\Repository\ArticleRepository;
use nostriphant\NIP19\Bech32;
use swentel\nostr\Key\Key;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
/**
* Resolves the header Nostr share menu (npub; naddr for addressables, else nevent; Jumble /feed/notes/…).
*/
final class NostrShareMenuBuilder
{
public const string ATTR_NPUB = 'nostr_share_npub';
public const string ATTR_NEVENT_BECH32 = 'nostr_share_nevent_bech32';
public const string ATTR_NADDR_BECH32 = 'nostr_share_naddr_bech32';
/**
* NIP-19 + Jumble href for a wire event (replies, quotes, previews, /e/ page).
*/
public function shareContextFromWireEvent(object $event, array $relayHints = []): ?NostrShareMenuContext
{
$pubkeyHex = strtolower((string) ($event->pubkey ?? ''));
if (64 !== \strlen($pubkeyHex) || !ctype_xdigit($pubkeyHex)) {
return null;
}
$key = new Key();
$npub = $key->convertPublicKeyToBech32($pubkeyHex);
$kind = (int) ($event->kind ?? 0);
$d = self::dTagFromWireEvent($event);
$eventIdHex = strtolower((string) ($event->id ?? ''));
if (Nip19Addressable::isParameterizedReplaceableKind($kind) && $d !== null) {
$naddr = Nip19Addressable::naddrBech32($kind, $pubkeyHex, $d, $relayHints);
$neventForRev = (64 === \strlen($eventIdHex) && ctype_xdigit($eventIdHex))
? (string) Bech32::nevent(
id: $eventIdHex,
relays: $relayHints,
author: $pubkeyHex,
kind: $kind,
)
: null;
return new NostrShareMenuContext(
$npub,
$neventForRev,
$naddr,
$this->feedJumble($naddr),
);
}
if (64 === \strlen($eventIdHex) && ctype_xdigit($eventIdHex)) {
$rebuilt = (string) Bech32::nevent(
id: $eventIdHex,
relays: $relayHints,
author: $pubkeyHex,
kind: $kind,
);
return new NostrShareMenuContext(
$npub,
$rebuilt,
null,
$this->feedJumble($rebuilt),
);
}
return new NostrShareMenuContext(
$npub,
null,
null,
$this->profileJumbleUrl($npub),
);
}
public function shareContextForArticle(Article $article): NostrShareMenuContext
{
return $this->fromArticle($article);
}
public function applyWireEventToRequest(Request $request, object $event, array $relayHints = []): void
{
$ctx = $this->shareContextFromWireEvent($event, $relayHints);
if (null === $ctx || null === $ctx->npub) {
return;
}
$request->attributes->set(self::ATTR_NPUB, $ctx->npub);
if ($ctx->naddrBech32 !== null && $ctx->naddrBech32 !== '') {
$request->attributes->set(self::ATTR_NADDR_BECH32, $ctx->naddrBech32);
}
if ($ctx->neventBech32 !== null && $ctx->neventBech32 !== '') {
$request->attributes->set(self::ATTR_NEVENT_BECH32, $ctx->neventBech32);
}
}
/**
* @param list<mixed>|\ArrayObject<int, mixed> $event->tags
*/
private static function dTagFromWireEvent(object $event): ?string
{
if (!isset($event->tags)) {
return null;
}
$rows = $event->tags;
if ($rows instanceof \ArrayObject) {
$rows = $rows->getArrayCopy();
}
if (!\is_array($rows)) {
return null;
}
$norm = array_values(
array_map(
static function ($r) {
if (!\is_array($r) && !\is_object($r)) {
return $r;
}
if (\is_object($r)) {
$r = (array) $r;
}
return $r;
},
$rows
)
);
return Nip19Addressable::dTagFromTagRows($norm);
}
public function __construct(
private readonly MagazineIndexStore $magazineIndexStore,
private readonly ArticleRepository $articleRepository,
#[Autowire('%npub%')]
private readonly string $siteNpub,
#[Autowire('%d_tag%')]
private readonly string $rootDTag,
#[Autowire('%jumble_profile_users_base%')]
private readonly string $jumbleProfileUsersBase,
#[Autowire('%jumble_feed_notes_base%')]
private readonly string $jumbleFeedNotesBase,
) {
}
private function nostrKey(): Key
{
return new Key();
}
/**
* Context for the header Nostr menu. Always returns a context on real HTTP requests (never null).
* Templates that do not include the header never call this; no need to suppress on XHR / fragments.
*/
public function buildForRequest(Request $request): NostrShareMenuContext
{
$route = (string) $request->attributes->get('_route', '');
if ('' === $route) {
return $this->siteWithRootMenu();
}
return match ($route) {
'home' => $this->siteWithRootMenu(),
'article' => $this->forArticleNpubD(
(string) $request->attributes->get('npub', ''),
(string) $request->attributes->get('slug', ''),
),
'author-profile' => $this->forAuthorProfile($request->attributes->get('npub', '')),
'nevent' => $this->forNevent($request, (string) $request->attributes->get('nevent', '')),
'magazine-category' => $this->forCategory($request->attributes->get('slug', '')),
'articles', 'featured_authors', 'search', 'article-preview', 'article-preview-event', 'editor-create', 'editor-edit' => $this->siteWithRootMenu(),
default => $this->siteWithRootMenu(),
};
}
private function forArticleNpubD(string $npub, string $slug): NostrShareMenuContext
{
if ($npub === '' || $slug === '' || !str_starts_with($npub, 'npub1')) {
return $this->siteWithRootMenu();
}
$list = $this->articleRepository->findBy(['slug' => $slug], ['createdAt' => 'DESC'], 1);
$article = $list[0] ?? null;
if ($article === null) {
return $this->siteWithRootMenu();
}
if ($this->nostrKey()->convertToHex($npub) !== strtolower((string) $article->getPubkey())) {
return $this->siteWithRootMenu();
}
return $this->fromArticle($article);
}
private function fromArticle(Article $article): NostrShareMenuContext
{
$npub = $this->nostrKey()->convertPublicKeyToBech32((string) $article->getPubkey());
$kind = (int) ($article->getKind()?->value ?? 30023);
$d = (string) ($article->getSlug() ?? '');
if ($d === '') {
return new NostrShareMenuContext(
$npub,
null,
null,
$this->profileJumbleUrl($npub),
);
}
$pk = strtolower((string) $article->getPubkey());
$naddr = Nip19Addressable::naddrBech32($kind, $pk, $d, []);
$eid = strtolower((string) ($article->getEventId() ?? ''));
$nevent = (64 === \strlen($eid) && ctype_xdigit($eid))
? (string) Bech32::nevent(
id: $eid,
relays: [],
author: $pk,
kind: $kind,
)
: null;
return new NostrShareMenuContext(
$npub,
$nevent,
$naddr,
$this->feedJumble($naddr),
);
}
private function forAuthorProfile(mixed $npubParam): NostrShareMenuContext
{
$npub = (string) $npubParam;
if ($npub === '' || !str_starts_with($npub, 'npub1')) {
return $this->siteWithRootMenu();
}
return new NostrShareMenuContext(
$npub,
null,
null,
$this->profileJumbleUrl($npub),
);
}
private function forNevent(Request $request, string $neventFromRoute): NostrShareMenuContext
{
if ($request->attributes->has(self::ATTR_NPUB)
&& ($request->attributes->has(self::ATTR_NADDR_BECH32) || $request->attributes->has(self::ATTR_NEVENT_BECH32))) {
$np = (string) $request->attributes->get(self::ATTR_NPUB);
$naddrRaw = $request->attributes->get(self::ATTR_NADDR_BECH32);
$naddr = \is_string($naddrRaw) && $naddrRaw !== '' ? $naddrRaw : null;
$neventRaw = $request->attributes->get(self::ATTR_NEVENT_BECH32);
$nb = \is_string($neventRaw) && $neventRaw !== '' ? $neventRaw : null;
if (null !== $naddr || null !== $nb) {
$jumble = $this->feedJumble($naddr ?? $nb);
return new NostrShareMenuContext(
$np,
$nb,
$naddr,
$jumble,
);
}
}
$nevent = $neventFromRoute;
if ($nevent === '' || !str_starts_with($nevent, 'nevent1')) {
return $this->siteWithRootMenu();
}
try {
$decoded = new Bech32($nevent);
} catch (\Throwable) {
return $this->siteWithRootMenu();
}
if ($decoded->type !== 'nevent' || !isset($decoded->data->id)) {
return $this->siteWithRootMenu();
}
$eventId = strtolower((string) $decoded->data->id);
if (64 !== \strlen($eventId) || !ctype_xdigit($eventId)) {
return $this->siteWithRootMenu();
}
$authorHex = $decoded->data->author ?? null;
if (\is_string($authorHex) && 64 === \strlen($authorHex) && ctype_xdigit($authorHex)) {
$authorHex = strtolower($authorHex);
} else {
$authorHex = null;
}
$kind = isset($decoded->data->kind) ? (int) $decoded->data->kind : 1;
$relays = $decoded->data->relays ?? [];
$relays = \is_array($relays) ? $relays : [];
if ($authorHex !== null) {
$rebuilt = (string) Bech32::nevent(
id: $eventId,
relays: $relays,
author: $authorHex,
kind: $kind,
);
return new NostrShareMenuContext(
$this->nostrKey()->convertPublicKeyToBech32($authorHex),
$rebuilt,
null,
$this->feedJumble($rebuilt),
);
}
return new NostrShareMenuContext(
null,
$nevent,
null,
$this->feedJumble($nevent),
);
}
private function forCategory(string $slug): NostrShareMenuContext
{
if ($slug === '') {
return $this->siteWithRootMenu();
}
$cat = $this->magazineIndexStore->getCategory($slug);
if ($cat === null) {
return $this->siteWithRootMenu();
}
return $this->fromNostrEvent($cat) ?? $this->siteWithRootMenu();
}
private function fromNostrEvent(Event $e): ?NostrShareMenuContext
{
$id = strtolower($e->getId());
if (64 !== \strlen($id) || !ctype_xdigit($id)) {
return null;
}
$pk = strtolower($e->getPubkey());
if (64 !== \strlen($pk) || !ctype_xdigit($pk)) {
return null;
}
$kind = (int) $e->getKind();
$d = Nip19Addressable::dTagFromEventEntity($e);
$npub = $this->nostrKey()->convertPublicKeyToBech32($pk);
if (Nip19Addressable::isParameterizedReplaceableKind($kind) && $d !== null) {
$naddr = Nip19Addressable::naddrBech32($kind, $pk, $d, []);
$neventForRev = (string) Bech32::nevent(
id: $id,
relays: [],
author: $pk,
kind: $kind,
);
return new NostrShareMenuContext(
$npub,
$neventForRev,
$naddr,
$this->feedJumble($naddr),
);
}
$nevent = (string) Bech32::nevent(
id: $id,
relays: [],
author: $pk,
kind: $kind,
);
return new NostrShareMenuContext(
$npub,
$nevent,
null,
$this->feedJumble($nevent),
);
}
private function siteWithRootMenu(): NostrShareMenuContext
{
$root = $this->magazineIndexStore->getRoot($this->siteNpub, $this->rootDTag);
if (null === $fromRoot = $root ? $this->fromNostrEvent($root) : null) {
return new NostrShareMenuContext(
$this->siteNpub,
null,
null,
$this->profileJumbleUrl($this->siteNpub),
);
}
return $fromRoot;
}
private function profileJumbleUrl(string $npub): string
{
$b = rtrim($this->jumbleProfileUsersBase, '/');
return $b === '' ? '#' : $b.'/'.$npub;
}
private function feedJumble(string $naddrOrNeventOrNoteBech32): string
{
$b = rtrim($this->jumbleFeedNotesBase, '/');
return $b === '' ? $naddrOrNeventOrNoteBech32 : $b.'/'.$naddrOrNeventOrNoteBech32;
}
}

3
src/Twig/Components/Molecules/CategoryLink.php

@ -2,6 +2,7 @@
namespace App\Twig\Components\Molecules; namespace App\Twig\Components\Molecules;
use App\Service\MagazineContentService;
use App\Service\MagazineIndexStore; use App\Service\MagazineIndexStore;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
@ -14,6 +15,7 @@ final class CategoryLink
public function __construct( public function __construct(
private readonly MagazineIndexStore $store, private readonly MagazineIndexStore $store,
private readonly MagazineContentService $magazineContent,
) { ) {
} }
@ -29,6 +31,7 @@ final class CategoryLink
} }
$this->title = $this->slug; $this->title = $this->slug;
$this->magazineContent->warmCategoryIndexIfMissing($this->slug);
$cat = $this->store->getCategory($this->slug); $cat = $this->store->getCategory($this->slug);
if (!\is_object($cat) || !\method_exists($cat, 'getTags')) { if (!\is_object($cat) || !\method_exists($cat, 'getTags')) {
return; return;

5
src/Twig/Components/UserMenu.php

@ -3,10 +3,15 @@
namespace App\Twig\Components; namespace App\Twig\Components;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait; use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent] #[AsLiveComponent]
class UserMenu class UserMenu
{ {
use DefaultActionTrait; use DefaultActionTrait;
/** When true, render for the mobile header menu (not fixed in the left column). */
#[LiveProp]
public bool $inline = false;
} }

65
src/Twig/MagazineJumbleExtension.php

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Twig;
use App\Enum\KindsEnum;
use App\Nostr\Nip19Addressable;
use swentel\nostr\Key\Key;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
/**
* Footer “View this magazine on Jumble”: Jumble /feed/notes/{naddr} for the site root kind 30040 index.
*/
final class MagazineJumbleExtension extends AbstractExtension
{
public function __construct(
#[Autowire('%npub%')]
private readonly string $siteNpub,
#[Autowire('%d_tag%')]
private readonly string $rootMagazineDTag,
#[Autowire('%jumble_feed_notes_base%')]
private readonly string $jumbleFeedNotesBase,
) {
}
public function getFunctions(): array
{
return [
new TwigFunction('magazine_on_jumble_url', $this->magazineOnJumbleUrl(...)),
];
}
public function magazineOnJumbleUrl(): string
{
$key = new Key();
try {
$pubkeyHex = $key->convertToHex($this->siteNpub);
} catch (\Throwable) {
return '#';
}
if (64 !== \strlen($pubkeyHex) || !ctype_xdigit($pubkeyHex)) {
return '#';
}
$d = \trim($this->rootMagazineDTag);
if ($d === '') {
return '#';
}
try {
$naddr = Nip19Addressable::naddrBech32(
KindsEnum::PUBLICATION_INDEX->value,
strtolower($pubkeyHex),
$d,
[],
);
} catch (\Throwable) {
return '#';
}
$b = \rtrim($this->jumbleFeedNotesBase, '/');
return $b === '' ? $naddr : $b.'/'.$naddr;
}
}

40
src/Twig/NostrPathExtension.php

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Twig;
use App\Entity\Article;
use App\Service\NostrPathHelper;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
final class NostrPathExtension extends AbstractExtension
{
public function __construct(
private readonly NostrPathHelper $nostrPathHelper,
) {
}
public function getFunctions(): array
{
return [
new TwigFunction('npub_from_hex', $this->npubFromHex(...)),
new TwigFunction('article_path', $this->articlePath(...)),
];
}
public function npubFromHex(string $pubkeyHex): string
{
if (64 !== \strlen($pubkeyHex) || !ctype_xdigit($pubkeyHex)) {
return '';
}
return $this->nostrPathHelper->npubFromPubkeyHex($pubkeyHex);
}
public function articlePath(Article $article): string
{
return $this->nostrPathHelper->articlePath($article);
}
}

63
src/Twig/NostrShareMenuExtension.php

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Twig;
use App\Dto\NostrShareMenuContext;
use App\Entity\Article;
use App\Service\NostrShareMenuBuilder;
use Symfony\Component\HttpFoundation\RequestStack;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
final class NostrShareMenuExtension extends AbstractExtension
{
public function __construct(
private readonly NostrShareMenuBuilder $builder,
private readonly RequestStack $requestStack,
) {
}
public function getFunctions(): array
{
return [
new TwigFunction('nostr_share_menu', [$this, 'getOrBuildContext']),
new TwigFunction('nostr_event_share', [$this, 'getEventShareContext']),
];
}
public function getOrBuildContext(): ?NostrShareMenuContext
{
$request = $this->requestStack->getCurrentRequest();
if ($request === null) {
return null;
}
return $this->builder->buildForRequest($request);
}
/**
* Share menu for a specific event: {@see Article} row, wire object (comment / quote / preview), etc.
*
* @param mixed $data Article entity or wire-like object (id, pubkey, kind, tags)
*/
public function getEventShareContext(mixed $data): ?NostrShareMenuContext
{
if ($data instanceof Article) {
return $this->builder->shareContextForArticle($data);
}
if ($data === null) {
return null;
}
if (\is_array($data)) {
$json = json_encode($data);
$data = \is_string($json) ? json_decode($json) : null;
}
if (!\is_object($data)) {
return null;
}
return $this->builder->shareContextFromWireEvent($data, []);
}
}

45
src/Util/NostrEventTags.php

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Util;
/**
* Tag rows from Nostr events may be JSON arrays, associative arrays, or object-shaped. Normalize
* to a name-first list of string values (see NIP-01 tag structure).
*/
final class NostrEventTags
{
/**
* @return list<string>|null
*/
public static function rowToStringList(mixed $row): ?array
{
if ($row === null) {
return null;
}
if (\is_object($row)) {
$row = get_object_vars($row);
}
if (!\is_array($row) || $row === []) {
return null;
}
return array_values(
array_map(
static fn (mixed $v): string => (string) $v,
$row
)
);
}
public static function tagNameMatches(mixed $row, string $name): bool
{
$seq = self::rowToStringList($row);
if ($seq === null || $seq === []) {
return false;
}
return strtolower($seq[0] ?? '') === strtolower($name);
}
}

12
symfony.lock

@ -116,6 +116,18 @@
"ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f" "ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
} }
}, },
"symfony/monolog-bundle": {
"version": "3.11",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.7",
"ref": "1b9efb10c54cb51c713a9391c9300ff8bceda459"
},
"files": [
"config/packages/monolog.yaml"
]
},
"symfony/phpunit-bridge": { "symfony/phpunit-bridge": {
"version": "7.2", "version": "7.2",
"recipe": { "recipe": {

3
templates/components/Footer.html.twig

@ -24,6 +24,9 @@
</nav> </nav>
</div> </div>
<div class="site-footer__main"> <div class="site-footer__main">
<p class="site-footer__jumble">
<a class="site-footer__link" href="{{ magazine_on_jumble_url()|e('html_attr') }}" target="_blank" rel="nofollow noopener noreferrer">View this magazine on Jumble</a>
</p>
<div class="footer-links"> <div class="footer-links">
{% for link in footer_links %} {% for link in footer_links %}
<div class="footer-link"> <div class="footer-link">

7
templates/components/Header.html.twig

@ -1,4 +1,4 @@
<header class="header" data-controller="menu" {{ attributes }}> <header id="site-header" class="header" data-controller="menu" {{ attributes }}>
<div class="header__logo"> <div class="header__logo">
<a href="/" class="header__brand"> <a href="/" class="header__brand">
<h1 class="brand"> <h1 class="brand">
@ -8,8 +8,10 @@
<span class="brand__title">{{ website_name }}</span> <span class="brand__title">{{ website_name }}</span>
</h1> </h1>
</a> </a>
<div class="header__end">
<button class="hamburger btn btn-secondary" data-action="click->menu#toggle" aria-label="Menu">&#9776;</button> <button class="hamburger btn btn-secondary" data-action="click->menu#toggle" aria-label="Menu">&#9776;</button>
</div> </div>
</div>
<div class="header__categories" data-menu-target="menu"> <div class="header__categories" data-menu-target="menu">
<ul> <ul>
{% for category in cats %} {% for category in cats %}
@ -21,6 +23,9 @@
</li> </li>
{% endif %} {% endif %}
</ul> </ul>
<div class="header__mobile-account">
<twig:UserMenu :inline="true" />
</div>
</div> </div>
<div data-controller="progress-bar"> <div data-controller="progress-bar">
<div id="progress-bar" data-progress-bar-target="bar"></div> <div id="progress-bar" data-progress-bar-target="bar"></div>

7
templates/components/Molecules/Card.html.twig

@ -1,4 +1,5 @@
{% if article is defined %} {% if article is defined %}
{% set card_title = article.title|default('')|trim %}
<div class="card"> <div class="card">
<div class="metadata"> <div class="metadata">
{% if category %} {% if category %}
@ -10,15 +11,15 @@
<small>{{ article.createdAt|date('F j Y') }}</small> <small>{{ article.createdAt|date('F j Y') }}</small>
{% endif %} {% endif %}
</div> </div>
<a href="{{ path('article-slug', {slug: article.slug}) }}"> <a href="{{ (article.pubkey and npub_from_hex(article.pubkey) != '') ? path('article', { npub: npub_from_hex(article.pubkey), slug: article.slug }) : path('article-legacy-redirect', { slug: article.slug }) }}">
<div class="card-header"> <div class="card-header">
{% if category %}<small class="text-uppercase">{{ category }}</small>{% endif %} {% if category %}<small class="text-uppercase">{{ category }}</small>{% endif %}
{% if article.image %} {% if article.image %}
<img src="{{ article.image }}" alt="Cover image for {{ article.title }}" onerror="this.style.display='none';" > <img src="{{ article.image }}" alt="Cover image for {{ card_title != '' ? card_title : (article.slug|default('')) }}" onerror="this.style.display='none';" >
{% endif %} {% endif %}
</div> </div>
<div class="card-body"> <div class="card-body">
<h2 class="card-title">{{ article.title }}</h2> <h2 class="card-title">{% if card_title != '' %}{{ card_title }}{% else %}{{ article.slug|default('')|replace({'-': ' '})|title }}{% endif %}</h2>
{% if article.summary %} {% if article.summary %}
<p class="lede"> <p class="lede">
{{ article.summary }} {{ article.summary }}

34
templates/components/Molecules/NostrPreviewContent.html.twig

@ -1,5 +1,11 @@
{% if preview.type == 'naddr' %} {% if preview.type == 'naddr' %}
<div class="card nostr-address-preview"> <div class="card nostr-address-preview">
{% set _na_share = nostr_event_share(preview) %}
{% if _na_share %}
<div class="nostr-preview-card__menu">
{% include 'components/Molecules/NostrShareMenu.html.twig' with { share: _na_share, event_menu: true } only %}
</div>
{% endif %}
{% for tag in preview.tags %} {% for tag in preview.tags %}
{% if tag[0] == 'title' %} {% if tag[0] == 'title' %}
<div class="card-header"> <div class="card-header">
@ -10,11 +16,6 @@
<p class="card-text">{{ tag[1] }}</p> <p class="card-text">{{ tag[1] }}</p>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if preview.id is defined and preview.id %}
<div class="card-footer nostr-preview-card__meta text-subtle">
<a href="https://jumble.imwald.eu/feed/notes/{{ preview.id }}" class="nostr-jumble-outlink" target="_blank" rel="noopener noreferrer">View event</a>
</div>
{% endif %}
</div> </div>
{% elseif preview.type == 'nevent' %} {% elseif preview.type == 'nevent' %}
{% if preview.kind == 9802 %} {% if preview.kind == 9802 %}
@ -23,7 +24,11 @@
<div> <div>
<twig:Molecules:UserFromNpub ident="{{ preview.pubkey }}" /> <twig:Molecules:UserFromNpub ident="{{ preview.pubkey }}" />
</div> </div>
<div class="nostr-card-header__actions">
<span class="ui-badge ui-badge--brand">Highlight</span> <span class="ui-badge ui-badge--brand">Highlight</span>
{% set _hi_share = nostr_event_share(preview) %}
{% if _hi_share %}{% include 'components/Molecules/NostrShareMenu.html.twig' with { share: _hi_share, event_menu: true } only %}{% endif %}
</div>
</div> </div>
<div class="card-body"> <div class="card-body">
<p>{{ preview.content }}</p> <p>{{ preview.content }}</p>
@ -42,11 +47,6 @@
</blockquote> </blockquote>
{% endif %} {% endif %}
</div> </div>
{% if preview.id is defined and preview.id %}
<div class="card-footer nostr-preview-card__meta text-subtle">
<a href="https://jumble.imwald.eu/feed/notes/{{ preview.id }}" class="nostr-jumble-outlink" target="_blank" rel="noopener noreferrer">View event</a>
</div>
{% endif %}
</div> </div>
{% else %} {% else %}
{% set is_longform = preview.kind == 30023 or preview.kind == 30024 %} {% set is_longform = preview.kind == 30023 or preview.kind == 30024 %}
@ -66,11 +66,15 @@
<div> <div>
<twig:Molecules:UserFromNpub ident="{{ preview.pubkey }}" /> <twig:Molecules:UserFromNpub ident="{{ preview.pubkey }}" />
</div> </div>
<div class="nostr-card-header__actions">
{% if is_longform %} {% if is_longform %}
<span class="ui-badge ui-badge--secondary">Article</span> <span class="ui-badge ui-badge--secondary">Article</span>
{% else %} {% else %}
<span class="ui-badge ui-badge--primary">Note</span> <span class="ui-badge ui-badge--primary">Note</span>
{% endif %} {% endif %}
{% set _evp_share = nostr_event_share(preview) %}
{% if _evp_share %}{% include 'components/Molecules/NostrShareMenu.html.twig' with { share: _evp_share, event_menu: true } only %}{% endif %}
</div>
</div> </div>
<div class="card-body"> <div class="card-body">
{% if is_longform %} {% if is_longform %}
@ -88,15 +92,17 @@
</div> </div>
<div class="card-footer nostr-preview-card__meta text-subtle"> <div class="card-footer nostr-preview-card__meta text-subtle">
<small>{{ preview.created_at is defined ? preview.created_at|date('F j Y') : '' }}</small> <small>{{ preview.created_at is defined ? preview.created_at|date('F j Y') : '' }}</small>
{% if preview.id is defined and preview.id %}
<span class="nostr-preview-card__sep">·</span>
<a href="https://jumble.imwald.eu/feed/notes/{{ preview.id }}" class="nostr-jumble-outlink" target="_blank" rel="noopener noreferrer">View event</a>
{% endif %}
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% elseif preview.type == 'nprofile' %} {% elseif preview.type == 'nprofile' %}
<div class="card nostr-profile-preview"> <div class="card nostr-profile-preview">
{% set _pr_share = nostr_event_share(preview) %}
{% if _pr_share %}
<div class="nostr-preview-card__menu">
{% include 'components/Molecules/NostrShareMenu.html.twig' with { share: _pr_share, event_menu: true } only %}
</div>
{% endif %}
<div class="card-body nostr-profile-preview__body"> <div class="card-body nostr-profile-preview__body">
<h5 class="card-title">{{ preview.display_name ?: preview.name }} </h5> <h5 class="card-title">{{ preview.display_name ?: preview.name }} </h5>
<small class="text-subtle">@{{ preview.npub|shortenNpub }}</small> <small class="text-subtle">@{{ preview.npub|shortenNpub }}</small>

40
templates/components/Molecules/NostrShareMenu.html.twig

@ -0,0 +1,40 @@
{% if share is not defined %}
{% set share = nostr_share_menu() %}
{% endif %}
{% if share is not null %}
<details class="nostr-share-menu{{ event_menu|default(false) ? ' nostr-share-menu--event' : '' }}">
<summary class="nostr-share-menu__trigger btn btn-secondary btn-sm" title="Nostr options" aria-label="Nostr options">
{% if event_menu|default(false) %}
<span class="nostr-share-menu__glyph" aria-hidden="true">⋯</span>
{% else %}
<span class="nostr-share-menu__label">Nostr</span><span class="nostr-share-menu__glyph" aria-hidden="true">⋯</span>
{% endif %}
</summary>
<ul class="nostr-share-menu__list" role="menu">
{% if share.npub is not null and share.npub is not same as('') %}
<li class="nostr-share-menu__item" role="none"
data-controller="copy-text"
data-copy-text-text-value="{{ share.npub|e('html_attr') }}">
<button type="button" class="nostr-share-menu__action" data-action="click->copy-text#copy" data-copy-text-target="button" role="menuitem">Copy npub</button>
</li>
{% endif %}
{% if share.naddrBech32 is not null and share.naddrBech32 is not same as('') %}
<li class="nostr-share-menu__item" role="none"
data-controller="copy-text"
data-copy-text-text-value="{{ share.naddrBech32|e('html_attr') }}">
<button type="button" class="nostr-share-menu__action" data-action="click->copy-text#copy" data-copy-text-target="button" role="menuitem">Copy naddr</button>
</li>
{% endif %}
{% if share.neventBech32 is not null and share.neventBech32 is not same as('') %}
<li class="nostr-share-menu__item" role="none"
data-controller="copy-text"
data-copy-text-text-value="{{ share.neventBech32|e('html_attr') }}">
<button type="button" class="nostr-share-menu__action" data-action="click->copy-text#copy" data-copy-text-target="button" role="menuitem">Copy nevent</button>
</li>
{% endif %}
<li class="nostr-share-menu__item" role="none">
<a class="nostr-share-menu__action" role="menuitem" href="{{ share.jumbleHref|e('html_attr') }}" target="_blank" rel="nofollow noopener noreferrer">View on Jumble</a>
</li>
</ul>
</details>
{% endif %}

2
templates/components/Organisms/CardList.html.twig

@ -1,7 +1,7 @@
<div {{ attributes }}> <div {{ attributes }}>
{% set is_author_profile = is_author_profile|default(false) %} {% set is_author_profile = is_author_profile|default(false) %}
{% for item in list %} {% for item in list %}
{% if item.slug is not empty and item.title is not empty %} {% if item.slug is not empty %}
<twig:Molecules:Card :article="item" :is_author_profile="is_author_profile"></twig:Molecules:Card> <twig:Molecules:Card :article="item" :is_author_profile="is_author_profile"></twig:Molecules:Card>
{% endif %} {% endif %}
{% endfor %} {% endfor %}

18
templates/components/Organisms/Comments.html.twig

@ -66,7 +66,7 @@
{% set cdepth = item.unfold_depth|default(0) %} {% set cdepth = item.unfold_depth|default(0) %}
{% set is_nip18_repost = item.kind is defined and (item.kind == 6 or item.kind == 16) %} {% set is_nip18_repost = item.kind is defined and (item.kind == 6 or item.kind == 16) %}
<div class="card comment comment--depth-{{ cdepth }}"{% if cid != '' %} data-event-id="{{ cid|e('html_attr') }}"{% endif %}> <div class="card comment comment--depth-{{ cdepth }}"{% if cid != '' %} data-event-id="{{ cid|e('html_attr') }}"{% endif %}>
<div class="metadata"> <div class="metadata comment-card__head">
<p> <p>
{% if item.kind is defined and item.kind == 1 %} {% if item.kind is defined and item.kind == 1 %}
<span class="ui-badge ui-badge--neutral" title="Legacy text-note reply (pre–NIP-22)">kind 1</span> <span class="ui-badge ui-badge--neutral" title="Legacy text-note reply (pre–NIP-22)">kind 1</span>
@ -77,7 +77,11 @@
{% endif %} {% endif %}
{% if cpk != '' %}<twig:Molecules:UserFromNpub ident="{{ cpk }}" />{% else %}<span class="text-subtle">Unknown</span>{% endif %} {% if cpk != '' %}<twig:Molecules:UserFromNpub ident="{{ cpk }}" />{% else %}<span class="text-subtle">Unknown</span>{% endif %}
</p> </p>
<div class="metadata__end">
<small>{% if cts is not null and cts != '' %}{{ cts|date('F j Y') }}{% endif %}</small> <small>{% if cts is not null and cts != '' %}{{ cts|date('F j Y') }}{% endif %}</small>
{% set _ev_share = nostr_event_share(item) %}
{% if _ev_share %}{% include 'components/Molecules/NostrShareMenu.html.twig' with { share: _ev_share, event_menu: true } only %}{% endif %}
</div>
</div> </div>
{% if not is_nip18_repost and item.unfold_reply_blurb|default('')|trim != '' %} {% if not is_nip18_repost and item.unfold_reply_blurb|default('')|trim != '' %}
<div class="comment__reply-blurb" role="note" aria-label="Reply context"> <div class="comment__reply-blurb" role="note" aria-label="Reply context">
@ -165,13 +169,14 @@
<div class="comments-quotes"> <div class="comments-quotes">
<h3 class="comments-quotes__title">Quotes and references</h3> <h3 class="comments-quotes__title">Quotes and references</h3>
<p class="text-subtle comments-quotes__lede">Other notes that cite this article in a <code>q</code> tag (NIP-18) or reference its address in <code>a</code> / <code>A</code> (e.g. generic reposts, highlights).</p> <p class="text-subtle comments-quotes__lede">Other notes that cite this article in a <code>q</code> tag (NIP-18) or reference its address in <code>a</code> / <code>A</code> (e.g. generic reposts, highlights).</p>
<div class="comments-quotes__list">
{% for item in quotes %} {% for item in quotes %}
{% set cid = item.id|default('') %} {% set cid = item.id|default('') %}
{% set cpk = item.pubkey|default('') %} {% set cpk = item.pubkey|default('') %}
{% set cts = item.created_at|default(null) %} {% set cts = item.created_at|default(null) %}
{% set q_repost = item.kind is defined and (item.kind == 6 or item.kind == 16) %} {% set q_repost = item.kind is defined and (item.kind == 6 or item.kind == 16) %}
<div class="card comment comment--quote"> <div class="card comment comment--quote">
<div class="metadata"> <div class="metadata comment-card__head">
<p> <p>
{% if q_repost %} {% if q_repost %}
<span class="ui-badge ui-badge--neutral" title="NIP-18 repost (body omitted)">repost (kind {{ item.kind }})</span> <span class="ui-badge ui-badge--neutral" title="NIP-18 repost (body omitted)">repost (kind {{ item.kind }})</span>
@ -180,13 +185,13 @@
{% endif %} {% endif %}
{% if cpk != '' %}<twig:Molecules:UserFromNpub ident="{{ cpk }}" />{% else %}<span class="text-subtle">Unknown</span>{% endif %} {% if cpk != '' %}<twig:Molecules:UserFromNpub ident="{{ cpk }}" />{% else %}<span class="text-subtle">Unknown</span>{% endif %}
</p> </p>
<div class="metadata__end">
<small> <small>
{% if cts is not null and cts != '' %}{{ cts|date('F j Y') }}{% endif %} {% if cts is not null and cts != '' %}{{ cts|date('F j Y') }}{% endif %}
{% if cid != '' %}
<span class="comments-quotes__sep">·</span>
<a href="https://jumble.imwald.eu/feed/notes/{{ cid }}" class="nostr-jumble-outlink" target="_blank" rel="noopener noreferrer">View event</a>
{% endif %}
</small> </small>
{% set _qv_share = nostr_event_share(item) %}
{% if _qv_share %}{% include 'components/Molecules/NostrShareMenu.html.twig' with { share: _qv_share, event_menu: true } only %}{% endif %}
</div>
</div> </div>
<div class="card-body"> <div class="card-body">
{% if q_repost %} {% if q_repost %}
@ -209,4 +214,5 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
</div>
{% endif %} {% endif %}

4
templates/components/Organisms/FeaturedList.html.twig

@ -7,7 +7,7 @@
<div> <div>
{% set feature = list[0] %} {% set feature = list[0] %}
<div class="card"> <div class="card">
<a href="{{ path('article-slug', {slug: feature.slug}) }}"> <a href="{{ (feature.pubkey and npub_from_hex(feature.pubkey) != '') ? path('article', { npub: npub_from_hex(feature.pubkey), slug: feature.slug }) : path('article-legacy-redirect', { slug: feature.slug }) }}">
<div class="card-header"> <div class="card-header">
{% if feature.image %} {% if feature.image %}
<img src="{{ feature.image }}" alt="Cover image for {{ feature.title }}"> <img src="{{ feature.image }}" alt="Cover image for {{ feature.title }}">
@ -26,7 +26,7 @@
{% for item in list %} {% for item in list %}
{% if item != feature %} {% if item != feature %}
<div class="card"> <div class="card">
<a href="{{ path('article-slug', {slug: item.slug}) }}"> <a href="{{ (item.pubkey and npub_from_hex(item.pubkey) != '') ? path('article', { npub: npub_from_hex(item.pubkey), slug: item.slug }) : path('article-legacy-redirect', { slug: item.slug }) }}">
<div class="card-body"> <div class="card-body">
<h2 class="card-title">{{ item.title }}</h2> <h2 class="card-title">{{ item.title }}</h2>
<p class="lede truncate"> <p class="lede truncate">

2
templates/components/UserMenu.html.twig

@ -1,4 +1,4 @@
<div class="user-menu" {{ attributes.defaults(stimulus_controller('login')) }}> <div class="user-menu{{ inline ? ' user-menu--inline' : '' }}" {{ attributes.defaults(stimulus_controller('login')) }}>
{% if app.user %} {% if app.user %}
<div class="notice info"> <div class="notice info">
<twig:Molecules:UserFromNpub ident="{{ app.user.npub }}" /> <twig:Molecules:UserFromNpub ident="{{ app.user.npub }}" />

2
templates/event/index.html.twig

@ -29,6 +29,8 @@
{% endif %} {% endif %}
<div class="event-page__meta"> <div class="event-page__meta">
<span class="event-date">{{ event.created_at|date('F j, Y - H:i') }}</span> <span class="event-date">{{ event.created_at|date('F j, Y - H:i') }}</span>
{% set _ep_share = nostr_event_share(event) %}
{% if _ep_share %}{% include 'components/Molecules/NostrShareMenu.html.twig' with { share: _ep_share, event_menu: true } only %}{% endif %}
</div> </div>
</div> </div>
<div class="event-page__content"> <div class="event-page__content">

6
templates/pages/article.html.twig

@ -21,7 +21,7 @@
{% set _og_default_dims = false %} {% set _og_default_dims = false %}
{% endif %} {% endif %}
{% set _desc = article.summary|default('')|striptags|u.truncate(159, '…') %} {% set _desc = article.summary|default('')|striptags|u.truncate(159, '…') %}
{% set _canonical = url('article-slug', {slug: article.slug}) %} {% set _canonical = url('article', { npub: npub|default(npub_from_hex(article.pubkey)), slug: article.slug }) %}
{% set _author_name = '' %} {% set _author_name = '' %}
{% if author is defined and author %} {% if author is defined and author %}
{% set _author_name = attribute(author, 'name')|default(attribute(author, 'display_name')|default('')) %} {% set _author_name = attribute(author, 'name')|default(attribute(author, 'display_name')|default('')) %}
@ -68,8 +68,10 @@
{% endif %} {% endif %}
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header card-header--article">
<h1 class="card-title">{{ article.title }}</h1> <h1 class="card-title">{{ article.title }}</h1>
{% set _art_share = nostr_event_share(article) %}
{% if _art_share %}{% include 'components/Molecules/NostrShareMenu.html.twig' with { share: _art_share, event_menu: true } only %}{% endif %}
</div> </div>
{% if author %} {% if author %}
<div class="byline"> <div class="byline">

1
templates/pages/author.html.twig

@ -9,7 +9,6 @@
profile_websites: profile_websites, profile_websites: profile_websites,
profile_nip05: profile_nip05, profile_nip05: profile_nip05,
profile_payment_links: profile_payment_links, profile_payment_links: profile_payment_links,
jumble_profile_href: jumble_profile_href,
} only %} } only %}
<hr class="author-profile__divider" /> <hr class="author-profile__divider" />

7
templates/pages/category.html.twig

@ -29,6 +29,13 @@
{% endblock %} {% endblock %}
{% block body %} {% block body %}
<div class="card category-page__header-card">
<div class="card-header card-header--article">
<h1 class="card-title">{{ (category.title|default('')|trim) != '' ? category.title : 'Category' }}</h1>
{% set _cat_share = nostr_share_menu() %}
{% if _cat_share %}{% include 'components/Molecules/NostrShareMenu.html.twig' with { share: _cat_share, event_menu: true } only %}{% endif %}
</div>
</div>
<div class="category-body"> <div class="category-body">
<twig:Organisms:CardList :list="list" class="article-list" /> <twig:Organisms:CardList :list="list" class="article-list" />
</div> </div>

5
templates/pages/featured_authors.html.twig

@ -26,12 +26,11 @@
profile_nip05: [], profile_nip05: [],
profile_websites: row.profile_websites, profile_websites: row.profile_websites,
profile_payment_links: row.profile_payment_links, profile_payment_links: row.profile_payment_links,
jumble_profile_href: row.jumble_profile_href,
} only %} } only %}
</div> </div>
<p class="featured-authors__more"> <div class="featured-authors__actions">
<a class="btn btn-secondary" href="{{ path('author-profile', { npub: row.npub }) }}">Full profile</a> <a class="btn btn-secondary" href="{{ path('author-profile', { npub: row.npub }) }}">Full profile</a>
</p> </div>
</article> </article>
{% else %} {% else %}
<p class="text-subtle">No featured authors are listed yet. They appear when authors are added to magazine category indices and synced.</p> <p class="text-subtle">No featured authors are listed yet. They appear when authors are added to magazine category indices and synced.</p>

8
templates/partial/author_profile_header.html.twig

@ -1,4 +1,4 @@
{# Shared author “header” + about (no article list). Expects: author, npub, profile_*, jumble_profile_href; show_nip05: true on full /p/ profile only #} {# Shared author “header” + about (no article list). Expects: author, npub, profile_*; show_nip05: true on full /p/ profile only #}
{% set author_pic = null %} {% set author_pic = null %}
{% if author.picture is defined and author.picture %} {% if author.picture is defined and author.picture %}
{% set author_pic = author.picture %} {% set author_pic = author.picture %}
@ -77,9 +77,3 @@
{{ author.about|markdown_to_html|mentionify|linkify }} {{ author.about|markdown_to_html|mentionify|linkify }}
{% endif %} {% endif %}
</div> </div>
{% if jumble_profile_href is not null and jumble_profile_href != '' %}
<p class="author-profile__jumble">
<a class="btn btn-secondary" href="{{ jumble_profile_href|e('html_attr') }}" target="_blank" rel="nofollow noopener noreferrer">View on Jumble</a>
</p>
{% endif %}

Loading…
Cancel
Save