Skip to content

Commit 0e88cc6

Browse files
ralyodioclaude
andauthored
moshpit: let a name publish a feed, and draw it as a blog or a podcast (#410)
A name could show exactly one thing, and only if its owner already ran a web server: `target` is an address the gateway proxies. That is the right primitive and the wrong first step — most people who claim a name have something to publish and nowhere to publish it from, so the commonest outcome in the registry is a name that never becomes a site. A name can now carry a feed. Paste an RSS or Atom URL into the field next to any name you hold and /n/<name> renders it: posts with dates and excerpts for a blog, episodes with cover art, running times and a player for a podcast. The layout is inferred from whether the entries carry audio enclosures, and can be overridden per name when the guess is wrong. Precedence is target, then feed, then the directory. A server somebody stood up beats a page we drew, and the feed becomes the soft landing when that server goes away. `?view=directory` is the way back out to the ending — deliberately consulted only after the proxy has had its turn, since past that point every query string belongs to somebody else's site. The feed URL is attacker-controlled, so it goes through the same deny-list the gateway applies to a target: literals are checked when saved, hostnames when fetched, and every redirect hop is re-checked rather than followed. Bodies are capped mid-stream, DOCTYPE is stripped and entities come from a fixed table, and everything parsed out is text that is escaped again on the way into the page. Feeds are cached in-process for five minutes so a name that gets linked somewhere busy does not become traffic on its owner's feed host, and a stale copy is served for a day rather than an error page when the origin stops answering. The parser is a second copy of the one in src/news.mjs rather than an import: apps/pwa deploys on its own, and the two have already diverged over enclosures, cover art and durations, which are most of what a podcast page is. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent fb8ab64 commit 0e88cc6

8 files changed

Lines changed: 1752 additions & 15 deletions

File tree

apps/pwa/src/lib/feed.mjs

Lines changed: 615 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
// The site a Moshpit name gets when its owner has a feed but no server.
2+
//
3+
// Two layouts, because a feed is one of two things to a reader. A blog is read
4+
// — the entry is a headline, a date and enough of the opening to decide, and
5+
// the destination is somebody else's page. A podcast is played — the entry is
6+
// an episode with artwork and a running time, and the destination is right
7+
// here, in an audio element, because making someone leave to press play is the
8+
// one thing a podcast page must not do.
9+
//
10+
// Everything drawn here came out of a document written by whoever owns the
11+
// feed, which is not necessarily whoever owns the name and is certainly not us.
12+
// So every string goes through esc() and every URL through the parser's
13+
// safeUrl() before it reaches an href, a src or a player. There is no path in
14+
// this file that interpolates feed content unescaped.
15+
16+
import { esc } from "./html.mjs";
17+
18+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
19+
20+
/**
21+
* A date a reader can scan, in UTC.
22+
*
23+
* Fixed rather than localised: this is rendered on the server for a visitor
24+
* whose locale we do not know, so a format that changes with the request's
25+
* headers would only make the same page differ between caches.
26+
*/
27+
export function feedDate(ms) {
28+
if (!Number.isFinite(ms)) return "";
29+
const date = new Date(ms);
30+
return `${date.getUTCDate()} ${MONTHS[date.getUTCMonth()]} ${date.getUTCFullYear()}`;
31+
}
32+
33+
/** An ISO stamp for `<time datetime>`, so a machine reading the page gets the real value. */
34+
function isoDate(ms) {
35+
return Number.isFinite(ms) ? new Date(ms).toISOString() : "";
36+
}
37+
38+
/** A file size in the units a reader thinks in. Enclosures are megabytes. */
39+
function fileSize(bytes) {
40+
if (!Number.isFinite(bytes) || bytes <= 0) return "";
41+
const mb = bytes / (1024 * 1024);
42+
return mb >= 1 ? `${mb.toFixed(mb >= 10 ? 0 : 1)} MB` : `${Math.round(bytes / 1024)} KB`;
43+
}
44+
45+
export const FEED_CSS = `
46+
.feed-wrap{max-width:760px;margin:0 auto;padding:56px 24px 80px}
47+
.feed-head{display:flex;gap:22px;align-items:flex-start;margin-bottom:34px}
48+
.feed-cover{width:132px;height:132px;flex:none;border-radius:var(--r);border:1px solid var(--line-2);
49+
object-fit:cover;background:var(--surface)}
50+
.feed-head-text{min-width:0}
51+
.feed-name{font-family:var(--mono);font-size:.68rem;letter-spacing:.2em;text-transform:uppercase;
52+
color:var(--acid);margin:0 0 8px}
53+
.feed-title{font-size:1.9rem;line-height:1.12;margin:0 0 10px;text-transform:none;letter-spacing:-.02em}
54+
.feed-desc{color:var(--dim);margin:0;font-size:.95rem;max-width:60ch}
55+
.feed-meta{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:14px}
56+
.feed-note{border:1px solid color-mix(in srgb,var(--warn) 40%,var(--line));color:var(--warn);
57+
border-radius:9px;padding:9px 13px;font-family:var(--mono);font-size:.74rem;margin:0 0 24px}
58+
.feed-list{list-style:none;margin:0;padding:0;display:grid;gap:2px}
59+
.feed-item{border-top:1px solid var(--line);padding:22px 0}
60+
.feed-item:last-child{border-bottom:1px solid var(--line)}
61+
.feed-when{font-family:var(--mono);font-size:.68rem;letter-spacing:.14em;text-transform:uppercase;
62+
color:var(--faint);display:flex;gap:10px;flex-wrap:wrap;align-items:center}
63+
.feed-item h2{font-size:1.12rem;line-height:1.25;margin:8px 0 0;text-transform:none;letter-spacing:-.01em}
64+
.feed-item h2 a:hover{color:var(--acid)}
65+
.feed-sum{color:var(--dim);font-size:.9rem;margin:8px 0 0;max-width:64ch}
66+
.feed-more{font-family:var(--mono);font-size:.74rem;color:var(--acid);display:inline-block;margin-top:10px}
67+
.feed-more:hover{text-decoration:underline}
68+
.ep{display:flex;gap:16px;align-items:flex-start}
69+
.ep-art{width:74px;height:74px;flex:none;border-radius:8px;border:1px solid var(--line);object-fit:cover;
70+
background:var(--surface)}
71+
.ep-body{min-width:0;flex:1}
72+
.ep audio{width:100%;margin-top:12px;height:38px;border-radius:8px}
73+
.feed-foot{margin-top:44px;padding-top:22px;border-top:1px solid var(--line);
74+
display:flex;gap:14px;flex-wrap:wrap;align-items:center;justify-content:space-between;
75+
font-family:var(--mono);font-size:.72rem;color:var(--faint)}
76+
.feed-foot a{color:var(--dim)}
77+
.feed-foot a:hover{color:var(--acid)}
78+
@media (max-width:620px){
79+
.feed-head{flex-direction:column;gap:16px}
80+
.feed-cover{width:104px;height:104px}
81+
.feed-title{font-size:1.5rem}
82+
.ep{gap:12px}
83+
.ep-art{width:56px;height:56px}
84+
}
85+
`;
86+
87+
/** The chips under the title: what it is, who makes it, and how to subscribe. */
88+
function headMeta({ feed, kind, feedUrl }) {
89+
const chips = [`<span class="pill on">${kind === "podcast" ? "podcast" : "blog"}</span>`];
90+
if (feed.author) chips.push(`<span class="pill">${esc(feed.author)}</span>`);
91+
if (feed.site) chips.push(`<a class="pill" href="${esc(feed.site)}" rel="noopener nofollow ugc">website ↗</a>`);
92+
chips.push(`<a class="pill" href="${esc(feedUrl)}" rel="noopener nofollow ugc">subscribe ↗</a>`);
93+
return `<div class="feed-meta">${chips.join("")}</div>`;
94+
}
95+
96+
/**
97+
* One post.
98+
*
99+
* The whole row is not a link even though the title is: an entry with no link
100+
* is a legitimate feed entry (a note with no permalink), and wrapping the row
101+
* would leave a clickable card that goes nowhere.
102+
*/
103+
function post(item) {
104+
const when = item.date
105+
? `<time datetime="${esc(isoDate(item.date))}">${esc(feedDate(item.date))}</time>`
106+
: "";
107+
const by = item.author ? `<span>${esc(item.author)}</span>` : "";
108+
const title = item.link
109+
? `<a href="${esc(item.link)}" rel="noopener nofollow ugc">${esc(item.title)}</a>`
110+
: esc(item.title);
111+
112+
return `<li class="feed-item">
113+
${when || by ? `<div class="feed-when">${when}${by}</div>` : ""}
114+
<h2>${title}</h2>
115+
${item.summary ? `<p class="feed-sum">${esc(item.summary)}</p>` : ""}
116+
${item.link ? `<a class="feed-more" href="${esc(item.link)}" rel="noopener nofollow ugc">read →</a>` : ""}
117+
</li>`;
118+
}
119+
120+
/**
121+
* One episode.
122+
*
123+
* `preload="none"` on every player: a page of twenty episodes that each start
124+
* buffering on load is tens of megabytes pulled from the show's host for a
125+
* visitor who has pressed nothing.
126+
*/
127+
function episode(item, feed) {
128+
const art = item.image || feed.image;
129+
const when = item.date
130+
? `<time datetime="${esc(isoDate(item.date))}">${esc(feedDate(item.date))}</time>`
131+
: "";
132+
const bits = [when];
133+
if (item.duration) bits.push(`<span>${esc(item.duration)}</span>`);
134+
if (item.audio?.bytes) bits.push(`<span>${esc(fileSize(item.audio.bytes))}</span>`);
135+
const title = item.link
136+
? `<a href="${esc(item.link)}" rel="noopener nofollow ugc">${esc(item.title)}</a>`
137+
: esc(item.title);
138+
139+
// A video enclosure in a podcast feed is still an episode; it just needs the
140+
// element that can play it.
141+
const player = item.audio
142+
? `<${item.audio.video ? "video" : "audio"} controls preload="none" src="${esc(item.audio.url)}"></${item.audio.video ? "video" : "audio"}>`
143+
: "";
144+
145+
return `<li class="feed-item"><div class="ep">
146+
${art ? `<img class="ep-art" src="${esc(art)}" alt="" loading="lazy" referrerpolicy="no-referrer">` : ""}
147+
<div class="ep-body">
148+
${bits.filter(Boolean).length ? `<div class="feed-when">${bits.filter(Boolean).join("")}</div>` : ""}
149+
<h2>${title}</h2>
150+
${item.summary ? `<p class="feed-sum">${esc(item.summary)}</p>` : ""}
151+
${player}
152+
</div>
153+
</div></li>`;
154+
}
155+
156+
/**
157+
* The page a name with a feed shows.
158+
*
159+
* @param {object} input
160+
* @param {string} input.name the Moshpit name being visited
161+
* @param {object} input.feed what parseFeed returned
162+
* @param {string} input.feedUrl where it came from
163+
* @param {string|null} input.kind the owner's choice of layout, if they made one
164+
* @param {boolean} input.stale the origin failed and this is the last good copy
165+
*/
166+
export function feedPage({ name, feed, feedUrl, kind = null, stale = false }) {
167+
const layout = kind || feed.kind || "blog";
168+
const items = layout === "podcast"
169+
? feed.items.map((item) => episode(item, feed)).join("")
170+
: feed.items.map(post).join("");
171+
172+
return `<main class="feed-wrap">
173+
<header class="feed-head">
174+
${layout === "podcast" && feed.image
175+
? `<img class="feed-cover" src="${esc(feed.image)}" alt="" loading="lazy" referrerpolicy="no-referrer">`
176+
: ""}
177+
<div class="feed-head-text">
178+
<p class="feed-name">${esc(name)}</p>
179+
<h1 class="feed-title">${esc(feed.title || name)}</h1>
180+
${feed.description ? `<p class="feed-desc">${esc(feed.description)}</p>` : ""}
181+
${headMeta({ feed, kind: layout, feedUrl })}
182+
</div>
183+
</header>
184+
185+
${stale ? `<p class="feed-note">The feed has not answered recently — showing the last copy that came through.</p>` : ""}
186+
187+
<ul class="feed-list">${items}</ul>
188+
189+
<div class="feed-foot">
190+
<span>${esc(name)} · served from its feed by the pit</span>
191+
<span><a href="/n/${encodeURIComponent(name)}?view=directory">what else is on this ending →</a> · <a href="/pit">the pit →</a></span>
192+
</div>
193+
</main>`;
194+
}
195+
196+
/**
197+
* What a name shows when its feed cannot be read.
198+
*
199+
* Not a 502 page: the name is claimed and pointed at something, so the visitor
200+
* is looking at a site whose contents are temporarily missing, not at a broken
201+
* name. The reason is named because exactly one person can act on it, and
202+
* "the feed answered 404" tells them which thing to go and fix.
203+
*/
204+
export function feedUnavailable({ name, feedUrl, error }) {
205+
return `<main class="feed-wrap">
206+
<header class="feed-head"><div class="feed-head-text">
207+
<p class="feed-name">${esc(name)}</p>
208+
<h1 class="feed-title">Nothing came back from the feed.</h1>
209+
<p class="feed-desc">This name publishes
210+
<a class="acid" href="${esc(feedUrl)}" rel="noopener nofollow ugc">its feed</a>,
211+
but ${esc(error || "it could not be read")}.</p>
212+
</div></header>
213+
<div class="feed-foot">
214+
<span>${esc(name)}</span>
215+
<span><a href="/n/${encodeURIComponent(name)}?view=directory">the rest of this ending →</a> · <a href="/pit">the pit →</a></span>
216+
</div>
217+
</main>`;
218+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
-- The feed a name publishes: `blue.eggs` → https://example.com/feed.xml.
2+
--
3+
-- Until now a name could show exactly one thing, and only if its owner already
4+
-- ran a web server: `target` is an address the gateway proxies. That is the
5+
-- right primitive and the wrong first step. Most people who claim a name have
6+
-- something to publish and nowhere to publish it from, so the name sits parked
7+
-- on the directory page — the registry's commonest outcome is a name that
8+
-- never becomes a site.
9+
--
10+
-- A feed closes that gap without asking anyone to host anything. Almost
11+
-- everybody writing already emits RSS or Atom from somewhere else — a blog, a
12+
-- podcast host, a newsletter — and pointing a name at that URL is enough for
13+
-- the pit to render a real site at it. The name gets a page; nobody stands up
14+
-- a server.
15+
--
16+
-- Two columns on the name rather than a table of their own, unlike
17+
-- moshpit_records. Records are many-per-name by nature (two AAAA records are
18+
-- how a name gets a second address); a name has one feed, the same way it has
19+
-- one target, because what it publishes is a single stream. If names ever grow
20+
-- a real multi-feed page, that is when this grows a table.
21+
ALTER TABLE moshpit_names ADD COLUMN feed_url TEXT;
22+
23+
-- 'blog' or 'podcast', or null for "work it out from the feed".
24+
--
25+
-- Null is the default and the honest one: whether a feed is a podcast is a
26+
-- property of its entries (do they carry audio enclosures?), not of anything
27+
-- its owner has to declare. The column exists for the feeds that guess wrong —
28+
-- a blog that attaches one audio file, a podcast whose host omits enclosures
29+
-- from the summary feed — so the owner has a way to say which layout they
30+
-- meant instead of filing a bug about a heuristic.
31+
ALTER TABLE moshpit_names ADD COLUMN feed_kind TEXT;

apps/pwa/src/moshpit.mjs

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
// checkable rather than trusted.
1212

1313
import { db, get, all, run } from "./db.mjs";
14+
import { normalizeFeedKind, normalizeFeedUrl } from "./lib/feed.mjs";
1415
import { normalizeTarget } from "./lib/moshpit-gateway.mjs";
1516
import {
1617
BULK_CHUNK,
@@ -282,7 +283,7 @@ async function ownedTldAndLabel(tldInput, labelInput, userId) {
282283

283284
/* ---- names under a TLD ---- */
284285

285-
const NAME_COLS = `tld, label, user_id, target, created_at`;
286+
const NAME_COLS = `tld, label, user_id, target, feed_url, feed_kind, created_at`;
286287

287288
export async function getName(tld, label) {
288289
return get(`SELECT ${NAME_COLS} FROM moshpit_names WHERE tld = ? AND label = ?`, [tld, label]);
@@ -351,7 +352,7 @@ export async function listNamesForUser(userId) {
351352
* it under a different TLD than the one asked for, and repointing the alias
352353
* later would strand it.
353354
*/
354-
export async function registerName({ tld: tldInput, label: labelInput, userId, target = null }) {
355+
export async function registerName({ tld: tldInput, label: labelInput, userId, target = null, feed = null, feedKind = null }) {
355356
const tld = normalizeTld(tldInput);
356357
const label = normalizeLabel(labelInput);
357358
if (!tld || !label) return { ok: false, error: "not a valid name — letters, digits and dashes only" };
@@ -366,9 +367,19 @@ export async function registerName({ tld: tldInput, label: labelInput, userId, t
366367
const dest = normalizeTarget(target);
367368
if (!dest.ok) return { ok: false, error: dest.error };
368369

370+
// Same reasoning as the target, one field over: a feed URL that will never
371+
// parse is a name that looks like a site and shows an error to everyone who
372+
// visits it. Rejected at the form, not discovered by a reader.
373+
const stream = normalizeFeedUrl(feed);
374+
if (!stream.ok) return { ok: false, error: stream.error };
375+
const layout = normalizeFeedKind(feedKind);
376+
if (!layout.ok) return { ok: false, error: layout.error };
377+
369378
try {
370-
await run(`INSERT INTO moshpit_names (tld, label, user_id, target, created_at) VALUES (?,?,?,?,?)`,
371-
[tld, label, userId, dest.target, Date.now()]);
379+
await run(
380+
`INSERT INTO moshpit_names (tld, label, user_id, target, feed_url, feed_kind, created_at) VALUES (?,?,?,?,?,?,?)`,
381+
[tld, label, userId, dest.target, stream.feed, layout.kind, Date.now()],
382+
);
372383
} catch {
373384
const existing = await getName(tld, label);
374385
if (existing) return { ok: false, error: `${label}.${tld} is already registered`, taken: true };
@@ -391,6 +402,35 @@ export async function setNameTarget({ tld: tldInput, label: labelInput, userId,
391402
return { ok: true };
392403
}
393404

405+
/**
406+
* Point a name at a feed, or take the feed off it.
407+
*
408+
* Separate from setNameTarget rather than another argument on it, because the
409+
* two answer different questions and owners set them at different times: a
410+
* target is "I run a server", a feed is "I publish somewhere else". A name may
411+
* carry both, and which one a visitor gets is decided at read time by /n/ —
412+
* the server wins, because an owner who has stood one up did not do it so we
413+
* could show them a feed instead.
414+
*
415+
* An empty feed clears the row's feed and its layout together. Leaving a
416+
* `feed_kind` behind on a name with no feed is a setting for a page that no
417+
* longer exists, and it would silently apply to whatever feed came next.
418+
*/
419+
export async function setNameFeed({ tld: tldInput, label: labelInput, userId, feed, kind = null }) {
420+
const owned = await ownedName(tldInput, labelInput, userId);
421+
if (!owned.ok) return owned;
422+
423+
const stream = normalizeFeedUrl(feed);
424+
if (!stream.ok) return { ok: false, error: stream.error };
425+
const layout = normalizeFeedKind(kind);
426+
if (!layout.ok) return { ok: false, error: layout.error };
427+
428+
await run(`UPDATE moshpit_names SET feed_url = ?, feed_kind = ? WHERE tld = ? AND label = ?`,
429+
[stream.feed, stream.feed ? layout.kind : null, owned.tld, owned.label]);
430+
await logAction(owned.tld, userId, `${stream.feed ? "feed" : "unfeed"}:${owned.label}`);
431+
return { ok: true, feed: stream.feed, kind: stream.feed ? layout.kind : null };
432+
}
433+
394434
/** Give the name back. */
395435
export async function releaseName({ tld: tldInput, label: labelInput, userId }) {
396436
const owned = await ownedName(tldInput, labelInput, userId);
@@ -665,6 +705,11 @@ export async function resolveMoshpitName(input) {
665705
...(Boolean(owner.alias_of) && !aliased ? { exempt: true } : {}),
666706
name_registered: Boolean(entry),
667707
target: entry?.target ?? null,
708+
// Carried alongside the target rather than folded into it. A resolver
709+
// answering AAAA has no use for a feed and ignores these; /n/ is the caller
710+
// that turns them into a page, and it needs both to decide which it serves.
711+
feed: entry?.feed_url ?? null,
712+
feed_kind: entry?.feed_kind ?? null,
668713
};
669714
}
670715

0 commit comments

Comments
 (0)