Skip to content

Commit a4d80f9

Browse files
ralyodioclaude
andauthored
moshpit: publish pages, sections and posts to a name over HTTP (#411)
The feed pointed a name at writing that already existed somewhere else. This is the other half: somewhere to put writing that does not exist anywhere yet, reachable over HTTP so whatever already produces the thing being published can post it without anyone opening a dashboard. POST /api/moshpit/sites/blue.eggs/content {"kind":"link","title":"Worth reading","url":"https://example.com/post"} Eight kinds, in one table because they are one row with a different `kind`: section and page are the navigation, and text, link, image, gallery, video and embed are the posts. That is the set a link aggregator has, because it is the set people actually post — say something, point at something, show something, play something. It upserts on the slug. The caller is a webhook, and anything firing over HTTP retries: on a timeout it never saw the answer to, on a redeploy, on a queue redelivery. A publish endpoint that makes a second copy every time is one that fills a site with duplicates the first time a network blips. An array publishes up to 50 at once and reports each item separately, so one bad entry in a batch does not discard the good ones — 207 when that happens, because 200 would claim it all worked and 400 would suggest none of it did. The site is drawn at /n/<name>, each item at /n/<name>/<slug>. The nav is one row and capped at twelve: a name's site is a handful of sections and pages, and the moment navigation needs its own layout it has stopped being navigation. Everything past the cap is on the front page, which is where a nav was pointing anyway. Precedence for a name is now target, then posts published here, then feed, then the ending's directory. Everything on the page arrived over an API from a script somebody else wrote, so it is escaped on the way out and only http(s) survives validation on the way in. A video is inlined when it is a media file or on a short allow-list of hosts whose embed URL is documented; anything else becomes a card that links out, because an arbitrary URL in an iframe on the origin where accounts live is a phishing surface with our hostname on it. The slug path is served only for names that publish here. A pointed name answers as it did before — the gateway does not serve sub-paths, and quietly starting to do so from a content route is not the place to change that. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 0e88cc6 commit a4d80f9

8 files changed

Lines changed: 1552 additions & 16 deletions

File tree

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
// What a name may publish, and what a caller has to send to publish it.
2+
//
3+
// Every value here arrives over HTTP from a script holding an API key. That is
4+
// the point — publishing to a Moshpit name should be one call from whatever is
5+
// already generating the thing being published — but it means nothing can be
6+
// assumed about shape, size or intent. So this module is the whole of the
7+
// validation: the route parses JSON, calls normalizeContent, and stores what
8+
// comes back or returns the error it got. There is no second, looser path in.
9+
//
10+
// Kept apart from the SQL for the usual reason: what a gallery is allowed to
11+
// contain is a rule worth testing without a database in front of it.
12+
13+
import { safeUrl } from "./feed.mjs";
14+
15+
/**
16+
* The kinds a name can publish.
17+
*
18+
* Two structural, six that are posts. The six are the set a link aggregator
19+
* has, because that is the set people actually post: say something, point at
20+
* something, show something, play something.
21+
*/
22+
export const CONTENT_KINDS = ["section", "page", "text", "link", "image", "gallery", "video", "embed"];
23+
24+
/** The kinds that are navigation rather than content. */
25+
export const NAV_KINDS = ["section", "page"];
26+
27+
/** The kinds that are a post on the front page. */
28+
export const POST_KINDS = ["text", "link", "image", "gallery", "video", "embed"];
29+
30+
/** The kinds whose whole content is a URL somewhere else. */
31+
const URL_KINDS = ["link", "image", "video", "embed"];
32+
33+
export const MAX_TITLE = 200;
34+
export const MAX_BODY = 20_000;
35+
export const MAX_GALLERY = 24;
36+
export const MAX_SLUG = 64;
37+
38+
/** The most items one name's site will hold. A site, not a database. */
39+
export const MAX_ITEMS_PER_NAME = 500;
40+
41+
/** The most entries a navigation will draw. Past this it is not a nav, it is a list. */
42+
export const MAX_NAV = 12;
43+
44+
/**
45+
* A slug: lowercase, dashes, no leading or trailing dash.
46+
*
47+
* This is the URL, so it has the same shape as a name's label — and for the
48+
* same reason. A slug that needs escaping to appear in a path is a slug that
49+
* will be wrong somewhere.
50+
*/
51+
export function normalizeSlug(input) {
52+
const raw = String(input ?? "").trim().toLowerCase();
53+
if (!raw) return null;
54+
const slug = raw
55+
.replace(/[^a-z0-9]+/g, "-")
56+
.replace(/^-+|-+$/g, "")
57+
.slice(0, MAX_SLUG)
58+
.replace(/-+$/g, "");
59+
return slug || null;
60+
}
61+
62+
/**
63+
* A slug derived from a title, when a caller did not send one.
64+
*
65+
* Publishing should be one call with the fields you already have, and a script
66+
* posting a headline has a headline, not a URL segment. A collision is the
67+
* caller's to resolve by sending an explicit slug — silently appending `-2`
68+
* would make a retry create a second copy, which is the exact failure the
69+
* slug-as-primary-key is there to prevent.
70+
*/
71+
export function slugFromTitle(title) {
72+
return normalizeSlug(title);
73+
}
74+
75+
/** Reserved because the site's own routes use them. */
76+
const RESERVED_SLUGS = new Set(["api", "feed", "feed.xml", "rss", "sitemap.xml", "robots.txt", "n", "pit"]);
77+
78+
/**
79+
* Trim, cap, and take the control characters out.
80+
*
81+
* They are invisible in a payload, survive HTML escaping unchanged, and are how
82+
* a title smuggles a line break into a nav that assumed one line. Prose keeps
83+
* its newlines — a body is the one field where a line break is content rather
84+
* than an attack on the layout.
85+
*/
86+
function clean(value, max, { multiline = false } = {}) {
87+
const text = String(value ?? "");
88+
// eslint-disable-next-line no-control-regex
89+
const stripped = multiline ? text.replace(/[\x00-\x09\x0b-\x1f\x7f]/g, " ") : text.replace(/[\x00-\x1f\x7f]/g, " ");
90+
const collapsed = multiline
91+
? stripped.replace(/[^\S\n]+/g, " ").replace(/\n{3,}/g, "\n\n")
92+
: stripped.replace(/\s+/g, " ");
93+
return collapsed.trim().slice(0, max);
94+
}
95+
96+
/** A gallery's pictures: a list of URLs, or of {url, alt} objects. */
97+
function normalizeMedia(input) {
98+
if (input === undefined || input === null) return { ok: true, media: null };
99+
const list = Array.isArray(input) ? input : [input];
100+
if (!list.length) return { ok: true, media: null };
101+
if (list.length > MAX_GALLERY) {
102+
return { ok: false, error: `a gallery holds up to ${MAX_GALLERY} pictures` };
103+
}
104+
105+
const media = [];
106+
for (const entry of list) {
107+
const raw = typeof entry === "string" ? { url: entry } : (entry || {});
108+
const url = safeUrl(raw.url);
109+
if (!url) return { ok: false, error: "every picture in a gallery needs an http(s) url" };
110+
media.push({ url, alt: clean(raw.alt, MAX_TITLE) });
111+
}
112+
return { ok: true, media };
113+
}
114+
115+
/**
116+
* A publishing time.
117+
*
118+
* Accepts what a caller is likely to already hold: epoch milliseconds, epoch
119+
* seconds, or anything Date can parse. `null` is an explicit draft; leaving the
120+
* field out means "now", because a script that posts something without saying
121+
* when meant now.
122+
*/
123+
function normalizePublished(input, now) {
124+
if (input === undefined) return { ok: true, at: now };
125+
if (input === null || input === false || input === "") return { ok: true, at: null };
126+
if (input === true) return { ok: true, at: now };
127+
128+
if (typeof input === "number" && Number.isFinite(input)) {
129+
// Seconds are ten digits until 2286; milliseconds are thirteen. Nobody
130+
// publishing today means 1970-01-20, so the small number is seconds.
131+
return { ok: true, at: Math.round(input < 1e11 ? input * 1000 : input) };
132+
}
133+
const parsed = Date.parse(String(input));
134+
if (!Number.isFinite(parsed)) return { ok: false, error: "that is not a date we can read" };
135+
return { ok: true, at: parsed };
136+
}
137+
138+
/**
139+
* Validate one item to publish, and return the row to store.
140+
*
141+
* Every kind has exactly one thing it cannot be published without — a link with
142+
* no URL and an image with no picture are not posts, they are empty rows that
143+
* render as a title and a shrug. Saying which is missing is the difference
144+
* between a caller fixing their payload and a caller filing a bug.
145+
*/
146+
export function normalizeContent(input = {}, { now = Date.now() } = {}) {
147+
const kind = String(input.kind ?? "").trim().toLowerCase();
148+
if (!CONTENT_KINDS.includes(kind)) {
149+
return { ok: false, error: `kind must be one of ${CONTENT_KINDS.join(", ")}` };
150+
}
151+
152+
const title = clean(input.title, MAX_TITLE);
153+
const slug = normalizeSlug(input.slug) || slugFromTitle(title);
154+
if (!slug) {
155+
return { ok: false, error: "send a slug, or a title one can be made from" };
156+
}
157+
if (RESERVED_SLUGS.has(slug)) {
158+
return { ok: false, error: `"${slug}" is reserved` };
159+
}
160+
161+
// A title is what a nav entry, a card and a permalink are all labelled with.
162+
// Only a picture can do without one, because a picture is its own label.
163+
if (!title && kind !== "image" && kind !== "gallery") {
164+
return { ok: false, error: `a ${kind} needs a title` };
165+
}
166+
167+
const body = clean(input.body ?? input.text ?? input.content, MAX_BODY, { multiline: true });
168+
if (kind === "text" && !body) return { ok: false, error: "a text post needs a body" };
169+
if (kind === "page" && !body) return { ok: false, error: "a page needs a body" };
170+
171+
let url = null;
172+
if (URL_KINDS.includes(kind)) {
173+
url = safeUrl(input.url ?? input.href ?? input.link);
174+
if (!url) return { ok: false, error: `a ${kind} needs an http(s) url` };
175+
}
176+
177+
const media = normalizeMedia(input.media ?? input.images ?? input.gallery);
178+
if (!media.ok) return media;
179+
if (kind === "gallery" && !media.media?.length) {
180+
return { ok: false, error: "a gallery needs at least one picture" };
181+
}
182+
183+
// Read by key presence rather than with `??`: an explicit `null` is how a
184+
// caller says "draft", and `??` would fall through it to the next field and
185+
// then to "now" — quietly publishing the thing that asked not to be.
186+
const publishedInput = "published_at" in input ? input.published_at
187+
: "published" in input ? input.published
188+
: undefined;
189+
const published = normalizePublished(publishedInput, now);
190+
if (!published.ok) return published;
191+
192+
// A post's section is a slug like any other; an unknown one is not an error,
193+
// it just does not appear in the nav until the section is created.
194+
const section = POST_KINDS.includes(kind) ? normalizeSlug(input.section ?? input.parent) : null;
195+
196+
// Sections and pages are the nav; posts are not, unless somebody insists.
197+
const nav = input.nav === undefined ? NAV_KINDS.includes(kind) : Boolean(input.nav);
198+
199+
const position = Number.parseInt(input.position, 10);
200+
201+
return {
202+
ok: true,
203+
item: {
204+
slug,
205+
kind,
206+
title,
207+
body: body || null,
208+
url,
209+
media: media.media ? JSON.stringify(media.media) : null,
210+
section,
211+
nav: nav ? 1 : 0,
212+
position: Number.isFinite(position) ? Math.max(-999, Math.min(999, position)) : 0,
213+
published_at: published.at,
214+
},
215+
};
216+
}
217+
218+
/** A stored row, as the API and the renderer want it: media parsed, nav a boolean. */
219+
export function contentOut(row) {
220+
if (!row) return null;
221+
let media = null;
222+
try { media = row.media ? JSON.parse(row.media) : null; } catch { media = null; }
223+
return {
224+
slug: row.slug,
225+
kind: row.kind,
226+
title: row.title,
227+
body: row.body ?? null,
228+
url: row.url ?? null,
229+
media,
230+
section: row.section ?? null,
231+
nav: Boolean(row.nav),
232+
position: row.position ?? 0,
233+
published_at: row.published_at ?? null,
234+
created_at: row.created_at,
235+
updated_at: row.updated_at,
236+
};
237+
}
238+
239+
/**
240+
* The navigation for a site.
241+
*
242+
* Sections and pages that are published and marked for the nav, in position
243+
* order, bounded. Bounded rather than scrolled: a nav is a promise that the
244+
* whole site is a few clicks away, and one that wraps onto three lines has
245+
* stopped making it. Everything past the cap is still reachable — it is on the
246+
* front page, which is where a nav was pointing anyway.
247+
*/
248+
export function navFor(items = [], { max = MAX_NAV } = {}) {
249+
return items
250+
.filter((item) => item.nav && NAV_KINDS.includes(item.kind) && item.published_at)
251+
// `home` is the masthead — its title is already the site's title, and a nav
252+
// that lists it puts the same page next to "Home" under a different name.
253+
.filter((item) => item.slug !== "home")
254+
.sort((a, b) => a.position - b.position || a.title.localeCompare(b.title))
255+
.slice(0, max);
256+
}
257+
258+
/**
259+
* The posts on the front page, newest first.
260+
*
261+
* Undated posts sort last rather than first: a published post with no date is
262+
* usually an import that lost one, and letting it sit above today's writing is
263+
* how an archive buries a site.
264+
*/
265+
export function postsFor(items = [], { section = null } = {}) {
266+
return items
267+
.filter((item) => POST_KINDS.includes(item.kind) && item.published_at)
268+
.filter((item) => (section === null ? true : item.section === section))
269+
.sort((a, b) => (b.published_at ?? 0) - (a.published_at ?? 0));
270+
}

0 commit comments

Comments
 (0)