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 6 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. 13
      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. 97
      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. 328
      src/Service/CacheService.php
  37. 184
      src/Service/MagazineContentService.php
  38. 126
      src/Service/MagazineIndexStore.php
  39. 117
      src/Service/MagazineRefresher.php
  40. 191
      src/Service/Nip09DeletionApplier.php
  41. 1068
      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. 9
      templates/components/Header.html.twig
  53. 7
      templates/components/Molecules/Card.html.twig
  54. 46
      templates/components/Molecules/NostrPreviewContent.html.twig
  55. 40
      templates/components/Molecules/NostrShareMenu.html.twig
  56. 2
      templates/components/Organisms/CardList.html.twig
  57. 26
      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 @@ -44,6 +44,8 @@ MYSQL_ROOT_PASSWORD=root_password
# After changing, recreate: `docker compose up -d --force-recreate cron` (dev) or
# `docker compose -f compose.hub.yaml up -d --force-recreate prewarm` (hub).
# 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:
# HTTP_PUBLISH=127.0.0.1:9080
# HTTP_PUBLISH=80

4
Dockerfile

@ -56,9 +56,9 @@ COPY --link frankenphp/Caddyfile /etc/caddy/Caddyfile @@ -56,9 +56,9 @@ COPY --link frankenphp/Caddyfile /etc/caddy/Caddyfile
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 \
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" ]
# Dev FrankenPHP image

3
README.md

@ -81,7 +81,7 @@ make prewarm @@ -81,7 +81,7 @@ make prewarm
| `--metadata-batch` | `50` | Pubkeys per batched Nostr `REQ` |
| `--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) |
| `--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.
@ -100,6 +100,7 @@ For a full **Nostr backfill** + one-shot prewarm, use **`make prewarm`** (or a h @@ -100,6 +100,7 @@ For a full **Nostr backfill** + one-shot prewarm, use **`make prewarm`** (or a h
| What | File |
|------|------|
| 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`) |
| Service wiring (e.g. cache, `NostrClient` args) | `config/services.yaml` |

13
assets/controllers/article_comments_controller.js

@ -62,6 +62,10 @@ export default class extends Controller { @@ -62,6 +62,10 @@ export default class extends Controller {
throw new Error(`HTTP ${res.status}`);
}
const html = await res.text();
if (!this.hasContainerTarget) {
window.clearTimeout(timer);
return;
}
this.containerTarget.innerHTML = html;
const ms = Math.round(performance.now() - t0);
if (attempt > 1) {
@ -76,12 +80,17 @@ export default class extends Controller { @@ -76,12 +80,17 @@ export default class extends Controller {
if (attempt < maxAttempts) {
const delay = 1_200 * 2 ** (attempt - 1);
await new Promise((r) => setTimeout(r, delay));
if (!this.hasContainerTarget) {
return;
}
continue;
}
const ms = Math.round(performance.now() - t0);
console.warn(`[article-comments] fragment failed after ${ms}ms`, this.urlValue, err);
this.containerTarget.innerHTML =
'<p class="text-subtle">Comments could not be loaded.</p>';
if (this.hasContainerTarget) {
this.containerTarget.innerHTML =
'<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 { @@ -7,8 +7,10 @@ export default class extends Controller {
static targets = ['bar'];
connect() {
this.boundHandleInteraction = this.handleInteraction.bind(this);
this.boundPageShow = this.onPageShow.bind(this);
// Bind once per controller instance so reconnects match disconnect()'s
// 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('touchstart', this.handleTouchStart);
document.addEventListener('touchend', this.handleTouchEnd);

19
assets/styles/app.css

@ -325,16 +325,21 @@ div:nth-child(odd) .featured-list { @@ -325,16 +325,21 @@ div:nth-child(odd) .featured-list {
.header__logo .brand {
font-size: clamp(1rem, 4.2vw, 1.45rem);
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;
text-align: left;
}
.brand__title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
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 {
@ -346,6 +351,10 @@ div:nth-child(odd) .featured-list { @@ -346,6 +351,10 @@ div:nth-child(odd) .featured-list {
flex-shrink: 0;
margin-left: 0;
}
.header__end {
flex-shrink: 0;
}
}
/* Fixed square + overflow clips to a true circle. Logo img is out-of-flow so
@ -885,6 +894,14 @@ label.search { @@ -885,6 +894,14 @@ label.search {
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 {
display: inline-block;
padding: 0.2rem 0.55rem;

91
assets/styles/article.css

@ -34,6 +34,41 @@ @@ -34,6 +34,41 @@
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 {
display: flex;
justify-content: space-between;
@ -128,21 +163,49 @@ blockquote p { @@ -128,21 +163,49 @@ blockquote p {
gap: 0.35rem;
}
/* Thread depth: light indent, max visual level 3 (deeper uses --depth-3) */
.comments .card.comment--depth-0 {
margin-left: 0;
/* Tracebacks: same flex+gap as .comments so spacing isn’t lost to margin collapse or .card { margin } from app.css */
.comments-quotes__list {
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 {
margin-left: 0.28rem;
/* Thread: no depth indent; one accent color for all replies; compact vertical rhythm */
.comments {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.comments .card.comment--depth-2 {
margin-left: 0.6rem;
.comments .card.comment {
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 {
margin-left: 0.95rem;
margin-left: 0;
border-left-color: var(--color-primary);
}
.comments .card.comment .metadata {
margin-bottom: 0.4rem;
}
.comment__reply-blurb {
@ -185,13 +248,13 @@ blockquote p { @@ -185,13 +248,13 @@ blockquote p {
}
.comment-reply {
margin-top: 1rem;
padding-top: 1rem;
margin-top: 0.45rem;
padding-top: 0.45rem;
border-top: 1px solid var(--color-border);
}
.comment-reply--article {
margin-bottom: 1.5rem;
margin-bottom: 0.75rem;
border: 1px solid var(--color-border);
border-radius: 6px;
border-top: 1px solid var(--color-border);
@ -219,8 +282,8 @@ blockquote p { @@ -219,8 +282,8 @@ blockquote p {
}
.comment-reply__toolbar--inline {
margin-bottom: 0.25rem;
margin-top: 0.5rem;
margin-bottom: 0.15rem;
margin-top: 0.3rem;
justify-content: flex-end;
}
@ -243,7 +306,7 @@ blockquote p { @@ -243,7 +306,7 @@ blockquote p {
}
.comment-reply--nested {
margin-top: 0.5rem;
margin-top: 0.3rem;
}
.comment-reply__head {

6
assets/styles/event.css

@ -88,6 +88,12 @@ @@ -88,6 +88,12 @@
}
.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);
font-size: 0.95rem;
}

382
assets/styles/layout.css

@ -16,6 +16,7 @@ @@ -16,6 +16,7 @@
.layout {
max-width: 100%;
width: 1200px;
min-width: 0; /* flex child of body: allow shrink so children don’t force page width */
margin: 0 auto;
display: flex;
flex-grow: 1;
@ -49,18 +50,22 @@ nav a:hover { @@ -49,18 +50,22 @@ nav a:hover {
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;
width: 100vw;
top: 0;
/* Use inset instead of 100vw: 100vw includes the vertical scrollbar and causes horizontal overflow on many viewports. */
left: 0;
right: 0;
top: 0;
width: auto;
box-sizing: border-box;
}
/* Desktop: breathing room under the browser chrome. Mobile gets inset via
.header__logo padding in the max-width block below. */
@media (min-width: 1025px) {
header {
#site-header {
padding-top: max(0.65rem, env(safe-area-inset-top, 0px));
}
}
@ -72,6 +77,96 @@ header { @@ -72,6 +77,96 @@ header {
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 {
display: flex;
width: 100%;
@ -133,7 +228,8 @@ header { @@ -133,7 +228,8 @@ header {
justify-content: space-between;
align-items: center;
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 {
@ -147,6 +243,7 @@ header { @@ -147,6 +243,7 @@ header {
display: none;
flex-direction: column;
padding-top: 10px;
padding-bottom: max(1rem, env(safe-area-inset-bottom, 0px));
}
.header__categories.active {
@ -162,6 +259,41 @@ header { @@ -162,6 +259,41 @@ header {
flex-direction: column;
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 */
@ -192,6 +324,16 @@ main { @@ -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 {
padding: 10px;
margin: 10px 0;
@ -228,11 +370,15 @@ dt { @@ -228,11 +370,15 @@ dt {
/* Responsive adjustments */
@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 */
}
/* 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 {
margin-top: 90px;
margin-top: max(7.25rem, calc(4.8rem + env(safe-area-inset-top, 0px)));
width: 100%;
}
}
@ -244,6 +390,8 @@ footer { @@ -244,6 +390,8 @@ footer {
padding: 1.25rem 1rem 1.5rem;
position: relative;
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);
}
@ -255,6 +403,9 @@ footer { @@ -255,6 +403,9 @@ footer {
align-items: stretch;
gap: 1.75rem;
text-align: left;
min-width: 0;
width: 100%;
box-sizing: border-box;
}
.site-footer__syndication-title {
@ -271,8 +422,23 @@ footer { @@ -271,8 +422,23 @@ footer {
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 {
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 {
@ -285,13 +451,19 @@ footer { @@ -285,13 +451,19 @@ footer {
padding: 0;
font-size: 0.95rem;
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 {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem 0.45rem;
flex: 0 0 auto;
max-width: 100%;
}
.site-footer__syndication-list > li + li::before {
@ -320,11 +492,89 @@ footer { @@ -320,11 +492,89 @@ footer {
/* RSS + category feed links in one cell */
.site-footer__syndication-list__feeds {
display: inline-flex;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem 0.45rem;
min-width: 0;
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"). */
@ -349,6 +599,11 @@ footer { @@ -349,6 +599,11 @@ footer {
text-align: center;
}
.site-footer__jumble {
margin: 0 0 0.65rem;
font-size: 0.95rem;
}
.site-footer__legal {
margin: 1rem 0 0;
font-size: 0.95rem;
@ -389,28 +644,131 @@ footer .footer-links { @@ -389,28 +644,131 @@ footer .footer-links {
.featured-authors__intro {
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 {
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 {
margin-bottom: 2.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--color-border);
box-sizing: border-box;
width: 100%;
}
.featured-authors__card:last-of-type {
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 {
font-size: 1.5rem;
}
.featured-authors__more {
margin: 0.75rem 0 0;
/* Very narrow: single column so tiny two-column cells don’t look skewed */
@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 {

6
assets/styles/nostr-previews.css

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

3
compose.hub.yaml

@ -41,8 +41,9 @@ services: @@ -41,8 +41,9 @@ services:
- "${HTTP_PUBLISH:-9080}:80/tcp"
# 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).
# Liveness: GET /health (see HealthController), not /.
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
timeout: 5s
retries: 10

6
compose.yaml

@ -4,10 +4,10 @@ services: @@ -4,10 +4,10 @@ services:
build:
context: .
dockerfile: Dockerfile
# Overrides Dockerfile HEALTHCHECK: verify Caddy/FrankenPHP serves the app (not Caddy :2019 admin,
# which is unreliable for “ready”). `docker compose up --wait` requires this to pass.
# Overrides Dockerfile HEALTHCHECK: lightweight app route (see HealthController), not / (magazine + relays).
# `docker compose up --wait` requires this to pass.
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
timeout: 5s
retries: 6

4
composer.json

@ -33,9 +33,11 @@ @@ -33,9 +33,11 @@
"symfony/html-sanitizer": "7.1.*",
"symfony/http-foundation": "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-info": "7.1.*",
"symfony/process": "7.1.*",
"symfony/runtime": "7.1.*",
"symfony/security-bundle": "7.1.*",
"symfony/serializer": "7.1.*",

269
composer.lock generated

@ -4,7 +4,7 @@ @@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "6801a409dd01157c8d3cde2df133da99",
"content-hash": "de0f61141e3ff937c3c241e6b3315a88",
"packages": [
{
"name": "bitwasp/bech32",
@ -2341,6 +2341,109 @@ @@ -2341,6 +2341,109 @@
},
"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",
"version": "v1.3.2",
@ -6040,6 +6143,164 @@ @@ -6040,6 +6143,164 @@
],
"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",
"version": "v7.1.9",
@ -11311,7 +11572,7 @@ @@ -11311,7 +11572,7 @@
],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
"stability-flags": {},
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
@ -11320,6 +11581,6 @@ @@ -11320,6 +11581,6 @@
"ext-iconv": "*",
"ext-openssl": "*"
},
"platform-dev": [],
"plugin-api-version": "2.6.0"
"platform-dev": {},
"plugin-api-version": "2.9.0"
}

1
config/bundles.php

@ -13,4 +13,5 @@ return [ @@ -13,4 +13,5 @@ return [
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['local' => true],
Symfony\Bundle\SecurityBundle\SecurityBundle::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: @@ -10,3 +10,9 @@ framework:
pools:
#my.dedicated.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 @@ @@ -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: @@ -40,21 +40,23 @@ services:
$projectDir: '%kernel.project_dir%'
App\Service\ArticleCommentThreadLoader:
arguments:
$appCachePool: '@cache.app'
$appCachePool: '@cache.replies'
App\Twig\FooterLinksExtension:
arguments:
$footerLinksPath: '%footer_links%'
tags: [ 'twig.extension' ]
# Nostr index snapshots: distinct key prefix from other cache.app users.
App\Service\MagazineIndexStore:
arguments:
$pool: '@cache.app'
App\Twig\NostrShareMenuExtension:
tags: [ 'twig.extension' ]
App\Twig\MagazineJumbleExtension:
tags: [ 'twig.extension' ]
App\Service\MagazineRefresher:
arguments:
$appCache: '@cache.app'
App\Service\CacheService:
arguments:
$appCache: '@cache.app'
$magazinePrewarmPreferSlugs: '%magazine_prewarm_prefer_slugs%'
$magazinePrewarmAlsoSlugs: '%magazine_prewarm_also_slugs%'
App\Controller\ArticleController:
bind:
$articlesCache: '@cache.drafts'
App\Service\Nip05VerificationService:
arguments:
$appCache: '@cache.app'

12
config/unfold.yaml

@ -35,10 +35,18 @@ parameters: @@ -35,10 +35,18 @@ parameters:
nip05_domain: 'blog.imwald.eu'
# 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'
# 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:
- title: "Unfold"
url: "https://github.com/decent-newsroom/unfold"
description: "Project source code on GitHub."
url: "https://git.imwald.eu/silberengel/unfold/src/branch/imwald"
description: "This site’s Unfold source (imwald branch)."
- title: "Decent Newsroom"
url: "https://decentnewsroom.com/"
description: "Decentralized magazine platform."

3
frankenphp/docker-entrypoint.sh

@ -40,6 +40,9 @@ if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then @@ -40,6 +40,9 @@ if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then
fi
# 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
echo 'Waiting for database to be ready...'
ATTEMPTS_LEFT_TO_REACH_DATABASE=60

31
migrations/Version20260424130000.php

@ -0,0 +1,31 @@ @@ -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 @@ -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('no-metadata', null, InputOption::VALUE_NONE, 'Skip Nostr profile metadata 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-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')
@ -185,20 +185,20 @@ final class PrewarmCommand extends Command @@ -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 {
$n = $this->magazineContent->ingestMissingLongformForAllMagazineCategories();
$n = $this->magazineContent->ingestLongformForAllMagazineCategories();
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 {
$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) {
$this->logger->error('app:prewarm longform ingest failed', ['e' => $e]);
$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();
if (!$input->getOption('no-deletions')) {
@ -319,8 +319,8 @@ final class PrewarmCommand extends Command @@ -319,8 +319,8 @@ final class PrewarmCommand extends Command
$bar->start();
try {
foreach (array_chunk($toWarm, $batchSize) as $chunk) {
$fetched = $this->nostrClient->fetchKind0MetadataForAuthors($chunk, $batchSize);
$n += $this->cacheService->putPrewarmMetadataBatch($chunk, $fetched, $keys);
$fetched = $this->nostrClient->fetchKind0WireEventsForAuthors($chunk, $batchSize);
$n += $this->cacheService->putPrewarmMetadataBatch($chunk, $fetched);
$bar->advance(\count($chunk));
$p0 = (string) ($chunk[0] ?? '');
$bar->setMessage('Batch up to · '.substr($p0, 0, 8).'…');

97
src/Controller/ArticleController.php

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

5
src/Controller/AuthorController.php

@ -75,10 +75,6 @@ class AuthorController extends AbstractController @@ -75,10 +75,6 @@ class AuthorController extends AbstractController
}
$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);
$fa = $featuredAuthorRepository->findOneByPubkeyHex($pubkey);
if ($fa !== null && $fa->isListed()) {
@ -96,7 +92,6 @@ class AuthorController extends AbstractController @@ -96,7 +92,6 @@ class AuthorController extends AbstractController
'profile_websites' => $profileIdentityLinks->buildWebsites($author, $kind0Tags),
'profile_nip05' => $profileNip05,
'profile_payment_links' => $profilePaymentLinks->buildPaymentRows($author, $kind0Tags, $extraPayto),
'jumble_profile_href' => $jumbleProfileHref,
]);
}

28
src/Controller/EventController.php

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

3
src/Controller/FeaturedAuthorsController.php

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

32
src/Controller/HealthController.php

@ -0,0 +1,32 @@ @@ -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; @@ -10,6 +10,7 @@ use App\Repository\ArticleRepository;
use App\Repository\FeaturedAuthorRepository;
use App\Service\MagazineContentService;
use App\Service\MagazineIndexStore;
use App\Service\NostrPathHelper;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
@ -31,6 +32,7 @@ final class SeoController extends AbstractController @@ -31,6 +32,7 @@ final class SeoController extends AbstractController
private readonly MagazineIndexStore $magazineIndexStore,
private readonly ParameterBagInterface $params,
private readonly FeaturedAuthorRepository $featuredAuthorRepository,
private readonly NostrPathHelper $nostrPathHelper,
) {
}
@ -57,8 +59,12 @@ final class SeoController extends AbstractController @@ -57,8 +59,12 @@ final class SeoController extends AbstractController
$articles = $this->articleRepository->findPublishedForSyndication(8000);
$bySlug = $this->dedupeArticlesByLatestRevision($articles);
foreach ($bySlug as $article) {
$loc = $this->nostrPathHelper->articleAbsoluteUrl($article);
if ($loc === '') {
continue;
}
$urls[] = [
'loc' => $this->absoluteUrlForRoute('article-slug', ['slug' => (string) $article->getSlug()]),
'loc' => $loc,
'lastmod' => $this->articleLastMod($article),
];
}
@ -277,7 +283,10 @@ final class SeoController extends AbstractController @@ -277,7 +283,10 @@ final class SeoController extends AbstractController
if ($slug === '') {
return '';
}
$permalink = $this->absoluteUrlForRoute('article-slug', ['slug' => $slug]);
$permalink = $this->nostrPathHelper->articleAbsoluteUrl($article);
if ($permalink === '') {
return '';
}
$title = (string) ($article->getTitle() ?? 'Untitled');
$tArticle = $this->articleLastMod($article);
$sum = (string) ($article->getSummary() ?? '');
@ -285,11 +294,11 @@ final class SeoController extends AbstractController @@ -285,11 +294,11 @@ final class SeoController extends AbstractController
$plain = preg_replace('/\s+/', ' ', (string) $article->getContent()) ?? '';
$sum = (string) mb_substr($plain, 0, 500);
}
$eId = (string) ($article->getEventId() ?? '');
if ($eId === '') {
$eId = (string) ($article->getId() ?? 'item');
}
$entryId = 'urn:web:'.$this->urlHostId($request).":article:{$eId}";
// One stable Atom <id> per row. Nostr eventId can repeat (revisions, duplicates); readers
// merge on <id> and would only show a single entry if ids collided.
$dbId = $article->getId();
$entryId = 'urn:web:'.$this->urlHostId($request)
.':db-article:'.($dbId !== null && $dbId !== '' ? (string) $dbId : \spl_object_id($article));
$pub = $article->getPublishedAt() ?? $article->getCreatedAt() ?? $tArticle;
$out = "\n <entry>";
@ -367,12 +376,21 @@ final class SeoController extends AbstractController @@ -367,12 +376,21 @@ final class SeoController extends AbstractController
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
{
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

21
src/Dto/NostrShareMenuContext.php

@ -0,0 +1,21 @@ @@ -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 @@ @@ -2,15 +2,27 @@
namespace App\Entity;
use App\Repository\EventRepository;
use Doctrine\DBAL\Types\Types;
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
{
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\Column(length: 225)]
private string $id;
@ -29,6 +41,12 @@ class Event @@ -29,6 +41,12 @@ class Event
#[ORM\Column(length: 255)]
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
{
return $this->id;
@ -111,6 +129,25 @@ class Event @@ -111,6 +129,25 @@ class Event
$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
{

62
src/Nostr/MagazineEventKeys.php

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

25
src/Repository/EventRepository.php

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

328
src/Service/CacheService.php

@ -1,98 +1,117 @@ @@ -1,98 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use App\Entity\Event;
use App\Nostr\MagazineEventKeys;
use App\Repository\EventRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use swentel\nostr\Key\Key;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
readonly class CacheService
{
public function __construct(
private NostrClient $nostrClient,
private CacheInterface $cache,
private LoggerInterface $logger,
private CacheItemPoolInterface $appCache,
private NostrClient $nostrClient,
private EntityManagerInterface $entityManager,
private EventRepository $eventRepository,
private LoggerInterface $logger,
) {
}
/**
* @param string $npub
* @return \stdClass
*/
public function getMetadata(string $npub): \stdClass
{
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>>}
*/
public function getMetadataBundle(string $npub): array
{
// One key per author: do not split on Nostr.Land / aggr (see comment thread cache). Otherwise
// prewarm and anonymous hits do not match logged-in readers → cold Nostr on every article view.
$cacheKey = '0_'.$npub;
$authorHex = $this->npubToAuthorHex64($npub);
if ($authorHex === null) {
return $this->placeholderMetadataBundle($npub);
}
$row = $this->eventRepository->findOneByCoreRowKey(MagazineEventKeys::profileKind0($authorHex));
if ($row !== null) {
return $this->bundleFromKind0EventRow($row, $npub);
}
try {
$cached = $this->cache->get($cacheKey, function (ItemInterface $item) use ($npub) {
$item->expiresAfter(3600); // 1 hour, adjust as needed
try {
$ev = $this->nostrClient->getNpubMetadata($npub);
$tags = self::normalizeEventTagsList($ev->tags ?? null);
try {
$data = \json_decode((string) $ev->content, false, 512, \JSON_THROW_ON_ERROR);
} catch (\JsonException) {
$data = new \stdClass();
}
if (!\is_object($data)) {
$data = new \stdClass();
}
return [
'content' => $data,
'kind0_tags' => $tags,
];
} 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'] : [],
];
$ev = $this->nostrClient->getNpubMetadata($npub);
if (!\is_object($ev)) {
return $this->placeholderMetadataBundle($npub);
}
// Legacy: cache stored only the decoded content object
if ($cached instanceof \stdClass) {
return ['content' => $cached, 'kind0_tags' => []];
$this->replaceByCoreKey(
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);
}
} catch (\Exception|InvalidArgumentException $e) {
$root = $e->getPrevious() ?? $e;
return ['content' => $content, 'kind0_tags' => $tags];
} catch (\Exception $e) {
$this->logger->warning('Profile metadata fetch failed; using npub placeholder.', [
'npub' => $npub,
'exception' => $root,
'exception' => $e->getPrevious() ?? $e,
]);
$content = new \stdClass();
$content->name = substr($npub, 0, 8) . '…' . substr($npub, -4);
}
return $this->placeholderMetadataBundle($npub);
}
return [
'content' => $content,
'kind0_tags' => [],
];
/**
* 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;
}
$content = new \stdClass();
$content->name = substr($npub, 0, 8) . '…' . substr($npub, -4);
return $n;
}
return [
'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 @@ -126,75 +145,162 @@ readonly class CacheService
return $out;
}
/**
* @param list<string> $authorPubkeyHex
* @param array<string, \stdClass> $metadataByHex from {@see NostrClient::fetchKind0MetadataForAuthors}
*/
public function putPrewarmMetadataBatch(array $authorPubkeyHex, array $metadataByHex, Key $key): int
private function npubToAuthorHex64(string $npub): ?string
{
$n = 0;
foreach ($authorPubkeyHex as $hex) {
if (strlen($hex) !== 64) {
continue;
if (64 === \strlen($npub) && ctype_xdigit($npub)) {
return strtolower($npub);
}
if (str_starts_with($npub, 'npub1')) {
try {
$h = (new Key())->convertToHex($npub);
} catch (\Throwable) {
$h = '';
}
$npub = $key->convertPublicKeyToBech32($hex);
if (isset($metadataByHex[$hex]) && $metadataByHex[$hex] instanceof \stdClass) {
$this->putProfileInCache($npub, $metadataByHex[$hex]);
} else {
$this->putProfilePlaceholderInCache($npub);
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;
try {
return $this->cache->get($cacheKey, function (ItemInterface $item) use ($npub) {
$item->expiresAfter(3600); // 1 hour
try {
return $this->nostrClient->getNpubRelays($npub);
} catch (\Exception $e) {
$this->logger->error('Error getting relays.', ['exception' => $e]);
return [];
}
});
} catch (InvalidArgumentException $e) {
$this->logger->error('Error getting relay data.', ['exception' => $e]);
return [];
$entity = $this->wireToEventEntity($rawWire);
if ($entity === null) {
return;
}
$entity->setCoreRowKey($coreKey);
$entity->setStorageRole($storageRole);
if ($entity->getEventId() === null) {
$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();
return;
}
if ($prev !== null) {
$this->entityManager->remove($prev);
$this->entityManager->flush();
}
$this->entityManager->persist($entity);
$this->entityManager->flush();
}
private function putProfileInCache(string $npub, \stdClass $content): void
private function wireToEventEntity(object $raw): ?Event
{
try {
$item = $this->appCache->getItem('0_'.$npub);
$item->set($content);
$item->expiresAfter(3600);
$this->appCache->save($item);
} catch (InvalidArgumentException $e) {
$this->logger->error('putProfileInCache', ['npub' => $npub, 'exception' => $e]);
$data = json_decode(json_encode($raw, \JSON_THROW_ON_ERROR), true, 512, \JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return null;
}
if (!\is_array($data)) {
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
{
try {
$item = $this->appCache->getItem('0_'.$npub);
if ($item->isHit()) {
// Prewarm miss: keep an earlier good (or any) value — do not downgrade to placeholder.
return;
}
} catch (InvalidArgumentException $e) {
$this->logger->error('putProfilePlaceholderInCache', ['npub' => $npub, 'exception' => $e]);
$content = $this->decodeKind0ContentString($row->getContent());
if (!\is_object($content) || $this->isPlaceholderContent($content, $npub)) {
$content = $this->namePlaceholderNpubObject($npub);
}
return;
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 {
$data = \json_decode($raw, false, 512, \JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return new \stdClass();
}
if (!\is_object($data)) {
return new \stdClass();
}
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->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; @@ -8,12 +8,13 @@ use App\Entity\Article;
use App\Entity\Event;
use App\Enum\EventStatusEnum;
use App\Repository\ArticleRepository;
use App\Util\NostrEventTags;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* Magazine index for templates. Reads {@see MagazineIndexStore} only on HTTP; relay refresh and DB
* backfill for category long-form are done by `app:prewarm` (cron) / CLI.
* Magazine index for templates. The store is filled by `app:prewarm` (cron) / CLI; missing 30040
* snapshots can be loaded once per request from relays (see ensure* methods).
*/
final class MagazineContentService
{
@ -65,6 +66,10 @@ final class MagazineContentService @@ -65,6 +66,10 @@ final class MagazineContentService
$npub = (string) $this->params->get('npub');
$dTag = (string) $this->params->get('d_tag');
$mag = $this->store->getRoot($npub, $dTag);
if ($mag === null) {
$this->ensureRoot30040FromRelays($npub, $dTag);
$mag = $this->store->getRoot($npub, $dTag);
}
return $this->categoryATagsFromMag($mag);
}
@ -78,11 +83,19 @@ final class MagazineContentService @@ -78,11 +83,19 @@ final class MagazineContentService
return [];
}
$tags = $mag->getTags();
$cats = array_filter($tags, static function (mixed $tag): bool {
return \is_array($tag) && ($tag[0] ?? null) === 'a';
});
$cats = [];
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 @@ -127,10 +140,14 @@ final class MagazineContentService
continue;
}
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;
}
$parts = explode(':', (string) $tag[1], 3);
$parts = explode(':', (string) $seq[1], 3);
if (\count($parts) < 2) {
continue;
}
@ -157,13 +174,18 @@ final class MagazineContentService @@ -157,13 +174,18 @@ final class MagazineContentService
if ($slug === '') {
return '';
}
$this->warmCategoryIndexIfMissing($slug);
$catIndex = $this->store->getCategory($slug);
if ($catIndex === null) {
return $slug;
}
foreach ($catIndex->getTags() as $tag) {
if (($tag[0] ?? null) === 'title' && isset($tag[1])) {
return (string) $tag[1];
if (!NostrEventTags::tagNameMatches($tag, 'title')) {
continue;
}
$seq = NostrEventTags::rowToStringList($tag);
if ($seq !== null && isset($seq[1])) {
return (string) $seq[1];
}
}
@ -172,26 +194,32 @@ final class MagazineContentService @@ -172,26 +194,32 @@ final class MagazineContentService
/**
* 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}}
*/
public function getCategoryPageData(string $slug): array
{
$this->warmCategoryIndexIfMissing($slug);
$catIndex = $this->store->getCategory($slug);
$list = [];
$coordinates = [];
$category = [];
if ($catIndex) {
foreach ($catIndex->getTags() as $tag) {
if ($tag[0] === 'title') {
$category['title'] = (string) $tag[1];
$seq = NostrEventTags::rowToStringList($tag);
if ($seq === null) {
continue;
}
if ($tag[0] === 'summary') {
$category['summary'] = (string) $tag[1];
$name = strtolower($seq[0] ?? '');
if ($name === 'title' && isset($seq[1])) {
$category['title'] = (string) $seq[1];
}
if ($tag[0] === 'a') {
$coordinates[] = $tag[1];
if ($name === 'summary' && isset($seq[1])) {
$category['summary'] = (string) $seq[1];
}
if ($name === 'a' && isset($seq[1])) {
$coordinates[] = (string) $seq[1];
}
}
}
@ -208,7 +236,7 @@ final class MagazineContentService @@ -208,7 +236,7 @@ final class MagazineContentService
continue;
}
$pairs[] = [
'pubkey' => (string) $parts[1],
'pubkey' => strtolower((string) $parts[1]),
'slug' => $slugPart,
];
}
@ -218,7 +246,7 @@ final class MagazineContentService @@ -218,7 +246,7 @@ final class MagazineContentService
if (\count($parts) < 3) {
continue;
}
$k = (string) $parts[1]."\0".trim((string) $parts[2]);
$k = strtolower((string) $parts[1])."\0".trim((string) $parts[2]);
if (isset($byAddress[$k])) {
$list[] = $byAddress[$k];
}
@ -235,19 +263,20 @@ final class MagazineContentService @@ -235,19 +263,20 @@ final class MagazineContentService
}
/**
* For every category in the root index, fetch Nostr long-form for `a` tags missing in MySQL.
* Nostr I/O; intended for {@see PrewarmCommand} / cron only.
* For every category in the store, fetch the latest Nostr long-form for each `a` tag so new
* 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;
foreach ($this->getCategorySlugsFromStore() as $catSlug) {
$missing = $this->findMissingLongformCoordinatesForCategory($catSlug);
if ($missing === []) {
$all = $this->findAllLongformCoordinatesForCategory($catSlug);
if ($all === []) {
continue;
}
$this->nostrClient->ingestMissingLongformForCategoryCoordinates($missing);
$n += \count($missing);
$this->nostrClient->ingestLongformForCategoryCoordinates($all);
$n += \count($all);
}
return $n;
@ -256,53 +285,30 @@ final class MagazineContentService @@ -256,53 +285,30 @@ final class MagazineContentService
/**
* @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);
if ($catIndex === null) {
return [];
}
$coordinates = [];
$out = [];
foreach ($catIndex->getTags() as $tag) {
if (($tag[0] ?? null) === 'a' && isset($tag[1])) {
$coordinates[] = (string) $tag[1];
}
}
if ($coordinates === []) {
return [];
}
$pairs = [];
foreach ($coordinates as $coordinate) {
$parts = explode(':', (string) $coordinate, 3);
if (\count($parts) < 3) {
if (!NostrEventTags::tagNameMatches($tag, 'a')) {
continue;
}
$slugPart = trim((string) $parts[2]);
if ($slugPart === '') {
$seq = NostrEventTags::rowToStringList($tag);
if ($seq === null || !isset($seq[1]) || (string) $seq[1] === '') {
continue;
}
$pairs[] = [
'pubkey' => (string) $parts[1],
'slug' => $slugPart,
];
}
if ($pairs === []) {
return [];
}
$byAddress = $this->articleRepository->findByAuthorAndSlugIndexed($pairs);
$missing = [];
foreach ($coordinates as $coordinate) {
$parts = explode(':', (string) $coordinate, 3);
if (\count($parts) < 3) {
$coordinate = (string) $seq[1];
$parts = explode(':', $coordinate, 3);
if (\count($parts) < 3 || trim((string) $parts[2]) === '') {
continue;
}
$k = (string) $parts[1]."\0".trim((string) $parts[2]);
if (!isset($byAddress[$k])) {
$missing[] = (string) $coordinate;
}
$out[] = $coordinate;
}
return $missing;
return $out;
}
/**
@ -358,4 +364,64 @@ final class MagazineContentService @@ -358,4 +364,64 @@ final class MagazineContentService
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); @@ -5,34 +5,33 @@ declare(strict_types=1);
namespace App\Service;
use App\Entity\Event;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use App\Nostr\MagazineEventKeys;
use App\Repository\EventRepository;
use Doctrine\ORM\EntityManagerInterface;
/**
* Read/write persisted magazine Nostr index events (kinds 30040) without callback-based relay I/O
* on the request path. Updated by {@see MagazineRefresher} (via `app:prewarm` / cron, or explicit CLI use).
* Magazine Nostr index events (kind 30040) in MySQL {@see Event}. Updated by {@see MagazineRefresher}
* (`app:prewarm` / cron).
*/
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(
private readonly CacheItemPoolInterface $pool,
private readonly EntityManagerInterface $entityManager,
private readonly EventRepository $eventRepository,
) {
}
public function getRoot(string $npub, string $dTag): ?Event
{
$item = $this->pool->getItem($this->rootKey($npub, $dTag));
if (!$item->isHit()) {
if ($dTag === '') {
return null;
}
$key = MagazineEventKeys::magazineRoot($npub, $dTag);
if ($key === '') {
return null;
}
return $this->unwrap($item->get());
return $this->eventRepository->findOneByCoreRowKey($key);
}
public function getCategory(string $slug): ?Event
@ -40,85 +39,86 @@ final class MagazineIndexStore @@ -40,85 +39,86 @@ final class MagazineIndexStore
if ($slug === '') {
return null;
}
$item = $this->pool->getItem($this->categoryKey($slug));
if (!$item->isHit()) {
return null;
}
$key = MagazineEventKeys::magazineCategory($slug);
return $this->unwrap($item->get());
return $this->eventRepository->findOneByCoreRowKey($key);
}
/**
* @throws InvalidArgumentException
*/
public function putRoot(string $npub, string $dTag, Event $event): void
{
$item = $this->pool->getItem($this->rootKey($npub, $dTag));
$item->set(serialize($event));
$item->expiresAfter(self::PERSIST_TTL);
$this->pool->save($item);
if ($dTag === '') {
return;
}
$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
{
if ($slug === '') {
return;
}
$item = $this->pool->getItem($this->categoryKey($slug));
$item->set(serialize($event));
$item->expiresAfter(self::PERSIST_TTL);
$this->pool->save($item);
$key = MagazineEventKeys::magazineCategory($slug);
$this->replaceByCoreKey($key, Event::STORAGE_MAGAZINE_CATEGORY, $event);
}
/**
* Remove a cached category index (NIP-09 / local invalidation).
*
* @throws InvalidArgumentException
*/
public function deleteCategory(string $slug): void
{
if ($slug === '') {
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
{
$this->pool->deleteItem($this->rootKey($npub, $dTag));
if ($dTag === '') {
return;
}
$key = MagazineEventKeys::magazineRoot($npub, $dTag);
$this->removeByCoreKey($key);
}
private function rootKey(string $npub, string $dTag): string
private function replaceByCoreKey(string $coreKey, string $role, Event $incoming): void
{
return self::ROOT_PREFIX.hash('sha256', $npub."\0".$dTag);
}
$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();
/**
* 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);
return;
}
if ($prev !== null) {
$this->entityManager->remove($prev);
$this->entityManager->flush();
}
$incoming->setCoreRowKey($coreKey);
$incoming->setStorageRole($role);
$this->entityManager->persist($incoming);
$this->entityManager->flush();
}
private function unwrap(mixed $value): ?Event
private function removeByCoreKey(string $coreKey): void
{
if (!\is_string($value) || $value === '') {
return null;
}
$e = unserialize($value, ['allowed_classes' => [Event::class]]);
if (!$e instanceof Event) {
return null;
$e = $this->eventRepository->findOneByCoreRowKey($coreKey);
if ($e === null) {
return;
}
return $e;
$this->entityManager->remove($e);
$this->entityManager->flush();
}
}

117
src/Service/MagazineRefresher.php

@ -5,6 +5,7 @@ declare(strict_types=1); @@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Service;
use App\Entity\Event;
use App\Util\NostrEventTags;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
@ -24,12 +25,27 @@ final class MagazineRefresher @@ -24,12 +25,27 @@ final class MagazineRefresher
private readonly LoggerInterface $logger,
private readonly CacheItemPoolInterface $appCache,
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
* are requested first (e.g. current /cat route) so they are less likely to miss the budget.
* Fetches the root 30040, then each category 30040. The soft wall-time budget applies to the
* **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
* Phases: `before_root`, `after_root` (total_steps, step, slug_count, slugs: list<string>),
@ -37,19 +53,24 @@ final class MagazineRefresher @@ -37,19 +53,24 @@ final class MagazineRefresher
*/
public function refreshFromRelays(int $budgetSeconds = 8, array $preferSlugs = [], ?callable $onProgress = null): void
{
$budgetSeconds = max(1, min(30, $budgetSeconds));
$deadline = microtime(true) + $budgetSeconds;
// Allow large budgets (PrewarmCommand --magazine-budget). Hard cap only to avoid runaway PHP time.
$budgetSeconds = max(1, min(600, $budgetSeconds));
$npub = (string) $this->params->get('npub');
$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
// after a 6s root fetch, "2s left" would become a 2s hard cap for the *next* relay I/O
// (e.g. slow TLS) and can fatal. Cap once with headroom; the $deadline loop limits work.
$this->applyExecutionTimeCap($budgetSeconds);
// Allow enough PHP wall time for a slow root fetch plus the full category-phase budget.
$this->applyExecutionTimeCap(2 * $budgetSeconds);
$defaultRelay = (string) $this->params->get('default_relay');
$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', []);
$root = $this->nostrClient->getMagazineIndex($npub, $dTag);
if ($root === null) {
@ -67,7 +88,20 @@ final class MagazineRefresher @@ -67,7 +88,20 @@ final class MagazineRefresher
$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);
$onProgress?->__invoke('after_root', [
'total_steps' => $totalSteps,
@ -152,14 +186,18 @@ final class MagazineRefresher @@ -152,14 +186,18 @@ final class MagazineRefresher
{
$slugs = [];
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;
}
$parts = explode(':', (string) $tag[1], 3);
$parts = explode(':', (string) $seq[1], 3);
if (\count($parts) < 3) {
continue;
}
$s = trim((string) end($parts));
$s = trim((string) $parts[2]);
if ($s !== '' && !\in_array($s, $slugs, true)) {
$slugs[] = $s;
}
@ -169,16 +207,29 @@ final class MagazineRefresher @@ -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> $prefer
* @param list<string> $also
*
* @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 {
return $s !== '';
}));
$out = $prefer;
foreach ($also as $s) {
$s = trim($s);
if ($s !== '' && !\in_array($s, $out, true)) {
$out[] = $s;
}
}
foreach ($allFromRoot as $s) {
if (!\in_array($s, $out, true)) {
$out[] = $s;
@ -205,8 +256,46 @@ final class MagazineRefresher @@ -205,8 +256,46 @@ final class MagazineRefresher
*/
private function applyExecutionTimeCap(int $budgetSeconds): void
{
$sec = max(30, min(120, $budgetSeconds + 30));
$sec = max(30, min(700, $budgetSeconds + 30));
@set_time_limit($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;
}
}

191
src/Service/Nip09DeletionApplier.php

@ -6,7 +6,9 @@ namespace App\Service; @@ -6,7 +6,9 @@ namespace App\Service;
use App\Entity\Event as MagazineNostrEvent;
use App\Enum\KindsEnum;
use App\Nostr\MagazineEventKeys;
use App\Repository\ArticleRepository;
use App\Repository\EventRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use swentel\nostr\Key\Key;
@ -15,14 +17,13 @@ use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; @@ -15,14 +17,13 @@ use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
/**
* Applies NIP-09 (kind 5) deletion requests to:
* - 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).
* For cached 30040 category indices (keyed by `d` only), we require the stored event’s author
* to match the deletion — not just an `a` tag whose own pubkey matches, so colliding `d` values
* across authors cannot wipe another author’s cache entry.
* For category 30040 rows (keyed by `d` only), we require the stored event’s author to match the
* deletion author so colliding `d` values across authors cannot wipe another author’s index.
*/
final class Nip09DeletionApplier
{
@ -30,6 +31,7 @@ final class Nip09DeletionApplier @@ -30,6 +31,7 @@ final class Nip09DeletionApplier
private readonly EntityManagerInterface $entityManager,
private readonly ArticleRepository $articleRepository,
private readonly MagazineIndexStore $magazineIndexStore,
private readonly EventRepository $eventRepository,
private readonly ParameterBagInterface $params,
private readonly LoggerInterface $logger,
) {
@ -73,9 +75,11 @@ final class Nip09DeletionApplier @@ -73,9 +75,11 @@ final class Nip09DeletionApplier
KindsEnum::LONGFORM->value,
KindsEnum::LONGFORM_DRAFT->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”
], true)) {
// Other kinds: we do not mirror in this app; skip.
continue;
}
if ($declared === 1) {
@ -86,7 +90,9 @@ final class Nip09DeletionApplier @@ -86,7 +90,9 @@ final class Nip09DeletionApplier
++$articlesPendingFlush;
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, [
KindsEnum::LONGFORM->value,
KindsEnum::LONGFORM_DRAFT->value,
@ -110,12 +116,10 @@ final class Nip09DeletionApplier @@ -110,12 +116,10 @@ final class Nip09DeletionApplier
}
}
if ($articlesPendingFlush > 0) {
try {
$this->entityManager->flush();
} catch (\Throwable $e) {
$this->logger->error('Nip09DeletionApplier: flush failed', ['exception' => $e]);
}
try {
$this->entityManager->flush();
} catch (\Throwable $e) {
$this->logger->error('Nip09DeletionApplier: flush failed', ['exception' => $e]);
}
return [
@ -125,53 +129,86 @@ final class Nip09DeletionApplier @@ -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
{
$npub = (string) $this->params->get('npub');
$dTag = (string) $this->params->get('d_tag');
if ($npub === '' || $dTag === '') {
$eid = strtolower($eventId);
$e = $this->eventRepository->find($eid);
if ($e === null) {
return 0;
}
if ((int) $e->getKind() !== KindsEnum::PUBLICATION_INDEX->value) {
return 0;
}
$root = $this->magazineIndexStore->getRoot($npub, $dTag);
if ($root === null) {
if (!$this->pubkeyEquals($e->getPubkey(), $deletionPubkey)) {
return 0;
}
if ($this->eventIdMatches($root, $eventId) && $this->pubkeyEquals($root->getPubkey(), $deletionPubkey)) {
$this->magazineIndexStore->deleteRoot($npub, $dTag);
$this->logger->notice('NIP-09: removed cached magazine root index', [
'event_id' => $eventId,
if ($e->getStorageRole() === MagazineNostrEvent::STORAGE_MAGAZINE_ROOT) {
$this->entityManager->remove($e);
$this->logger->notice('NIP-09: removed magazine root index (event table)', [
'event_id' => $eid,
]);
return 1;
}
foreach ($this->categorySlugsFromRoot($root) as $slug) {
$cat = $this->magazineIndexStore->getCategory($slug);
if ($cat === null) {
continue;
}
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;
}
if ($e->getStorageRole() === MagazineNostrEvent::STORAGE_MAGAZINE_CATEGORY) {
$this->entityManager->remove($e);
$this->logger->notice('NIP-09: removed magazine category index (event table)', [
'event_id' => $eid,
]);
return 2;
}
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
{
if (64 !== \strlen($a) || 64 !== \strlen($b)) {
@ -181,29 +218,6 @@ final class Nip09DeletionApplier @@ -181,29 +218,6 @@ final class Nip09DeletionApplier
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
*/
@ -261,11 +275,50 @@ final class Nip09DeletionApplier @@ -261,11 +275,50 @@ final class Nip09DeletionApplier
$kind = (int) $parts[0];
$pk = (string) $parts[1];
$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;
}
if ($kind === KindsEnum::LONGFORM->value || $kind === KindsEnum::LONGFORM_DRAFT->value) {
if ($d === '') {
return $out;
}
$article = $this->articleRepository->findOneBy(['pubkey' => $pk, 'slug' => $d]);
if ($article !== null) {
$eid = (string) ($article->getEventId() ?? '');

1068
src/Service/NostrClient.php

File diff suppressed because it is too large Load Diff

52
src/Service/NostrPathHelper.php

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

5
src/Twig/Components/UserMenu.php

@ -3,10 +3,15 @@ @@ -3,10 +3,15 @@
namespace App\Twig\Components;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent]
class UserMenu
{
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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -116,6 +116,18 @@
"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": {
"version": "7.2",
"recipe": {

3
templates/components/Footer.html.twig

@ -24,6 +24,9 @@ @@ -24,6 +24,9 @@
</nav>
</div>
<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">
{% for link in footer_links %}
<div class="footer-link">

9
templates/components/Header.html.twig

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

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

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
{% if article is defined %}
{% set card_title = article.title|default('')|trim %}
<div class="card">
<div class="metadata">
{% if category %}
@ -10,15 +11,15 @@ @@ -10,15 +11,15 @@
<small>{{ article.createdAt|date('F j Y') }}</small>
{% endif %}
</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">
{% if category %}<small class="text-uppercase">{{ category }}</small>{% endif %}
{% 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 %}
</div>
<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 %}
<p class="lede">
{{ article.summary }}

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

@ -1,5 +1,11 @@ @@ -1,5 +1,11 @@
{% if preview.type == 'naddr' %}
<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 %}
{% if tag[0] == 'title' %}
<div class="card-header">
@ -10,11 +16,6 @@ @@ -10,11 +16,6 @@
<p class="card-text">{{ tag[1] }}</p>
{% endif %}
{% 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>
{% elseif preview.type == 'nevent' %}
{% if preview.kind == 9802 %}
@ -23,7 +24,11 @@ @@ -23,7 +24,11 @@
<div>
<twig:Molecules:UserFromNpub ident="{{ preview.pubkey }}" />
</div>
<span class="ui-badge ui-badge--brand">Highlight</span>
<div class="nostr-card-header__actions">
<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 class="card-body">
<p>{{ preview.content }}</p>
@ -42,11 +47,6 @@ @@ -42,11 +47,6 @@
</blockquote>
{% endif %}
</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>
{% else %}
{% set is_longform = preview.kind == 30023 or preview.kind == 30024 %}
@ -66,11 +66,15 @@ @@ -66,11 +66,15 @@
<div>
<twig:Molecules:UserFromNpub ident="{{ preview.pubkey }}" />
</div>
{% if is_longform %}
<span class="ui-badge ui-badge--secondary">Article</span>
{% else %}
<span class="ui-badge ui-badge--primary">Note</span>
{% endif %}
<div class="nostr-card-header__actions">
{% if is_longform %}
<span class="ui-badge ui-badge--secondary">Article</span>
{% else %}
<span class="ui-badge ui-badge--primary">Note</span>
{% 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 class="card-body">
{% if is_longform %}
@ -88,15 +92,17 @@ @@ -88,15 +92,17 @@
</div>
<div class="card-footer nostr-preview-card__meta text-subtle">
<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>
{% endif %}
{% elseif preview.type == 'nprofile' %}
<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">
<h5 class="card-title">{{ preview.display_name ?: preview.name }} </h5>
<small class="text-subtle">@{{ preview.npub|shortenNpub }}</small>

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

@ -0,0 +1,40 @@ @@ -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 @@ @@ -1,7 +1,7 @@
<div {{ attributes }}>
{% set is_author_profile = is_author_profile|default(false) %}
{% 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>
{% endif %}
{% endfor %}

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

@ -66,7 +66,7 @@ @@ -66,7 +66,7 @@
{% set cdepth = item.unfold_depth|default(0) %}
{% 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="metadata">
<div class="metadata comment-card__head">
<p>
{% 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>
@ -77,7 +77,11 @@ @@ -77,7 +77,11 @@
{% endif %}
{% if cpk != '' %}<twig:Molecules:UserFromNpub ident="{{ cpk }}" />{% else %}<span class="text-subtle">Unknown</span>{% endif %}
</p>
<small>{% if cts is not null and cts != '' %}{{ cts|date('F j Y') }}{% endif %}</small>
<div class="metadata__end">
<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>
{% if not is_nip18_repost and item.unfold_reply_blurb|default('')|trim != '' %}
<div class="comment__reply-blurb" role="note" aria-label="Reply context">
@ -165,13 +169,14 @@ @@ -165,13 +169,14 @@
<div class="comments-quotes">
<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>
<div class="comments-quotes__list">
{% for item in quotes %}
{% set cid = item.id|default('') %}
{% set cpk = item.pubkey|default('') %}
{% set cts = item.created_at|default(null) %}
{% set q_repost = item.kind is defined and (item.kind == 6 or item.kind == 16) %}
<div class="card comment comment--quote">
<div class="metadata">
<div class="metadata comment-card__head">
<p>
{% if q_repost %}
<span class="ui-badge ui-badge--neutral" title="NIP-18 repost (body omitted)">repost (kind {{ item.kind }})</span>
@ -180,13 +185,13 @@ @@ -180,13 +185,13 @@
{% endif %}
{% if cpk != '' %}<twig:Molecules:UserFromNpub ident="{{ cpk }}" />{% else %}<span class="text-subtle">Unknown</span>{% endif %}
</p>
<small>
{% 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>
<div class="metadata__end">
<small>
{% if cts is not null and cts != '' %}{{ cts|date('F j Y') }}{% endif %}
</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 class="card-body">
{% if q_repost %}
@ -208,5 +213,6 @@ @@ -208,5 +213,6 @@
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endif %}

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

@ -7,7 +7,7 @@ @@ -7,7 +7,7 @@
<div>
{% set feature = list[0] %}
<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">
{% if feature.image %}
<img src="{{ feature.image }}" alt="Cover image for {{ feature.title }}">
@ -26,7 +26,7 @@ @@ -26,7 +26,7 @@
{% for item in list %}
{% if item != feature %}
<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">
<h2 class="card-title">{{ item.title }}</h2>
<p class="lede truncate">

2
templates/components/UserMenu.html.twig

@ -1,4 +1,4 @@ @@ -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 %}
<div class="notice info">
<twig:Molecules:UserFromNpub ident="{{ app.user.npub }}" />

2
templates/event/index.html.twig

@ -29,6 +29,8 @@ @@ -29,6 +29,8 @@
{% endif %}
<div class="event-page__meta">
<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 class="event-page__content">

6
templates/pages/article.html.twig

@ -21,7 +21,7 @@ @@ -21,7 +21,7 @@
{% set _og_default_dims = false %}
{% endif %}
{% 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 = '' %}
{% if author is defined and author %}
{% set _author_name = attribute(author, 'name')|default(attribute(author, 'display_name')|default('')) %}
@ -68,8 +68,10 @@ @@ -68,8 +68,10 @@
{% endif %}
<div class="card">
<div class="card-header">
<div class="card-header card-header--article">
<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>
{% if author %}
<div class="byline">

1
templates/pages/author.html.twig

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

7
templates/pages/category.html.twig

@ -29,6 +29,13 @@ @@ -29,6 +29,13 @@
{% endblock %}
{% 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">
<twig:Organisms:CardList :list="list" class="article-list" />
</div>

5
templates/pages/featured_authors.html.twig

@ -26,12 +26,11 @@ @@ -26,12 +26,11 @@
profile_nip05: [],
profile_websites: row.profile_websites,
profile_payment_links: row.profile_payment_links,
jumble_profile_href: row.jumble_profile_href,
} only %}
</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>
</p>
</div>
</article>
{% else %}
<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 @@ @@ -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 %}
{% if author.picture is defined and author.picture %}
{% set author_pic = author.picture %}
@ -77,9 +77,3 @@ @@ -77,9 +77,3 @@
{{ author.about|markdown_to_html|mentionify|linkify }}
{% endif %}
</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