Skip to content

Commit 59facf4

Browse files
authored
moshpit: size the body limit for the batch the API documents (#417)
The publishing endpoint documents a ceiling of 50 items. body-parser's default limit is 100kb, and 50 items of the documented length weigh about ten times that — so a caller sending exactly what the docs described was rejected before the handler ever ran, for a reason no field limit in moshpit-content.mjs explained. It did not report as a rejection either. The catch-all error handler turned body-parser's 413 into the generic 500 HTML page, so "your batch is too big" arrived as "a bug got in" and sent you reading server logs for a fault that was not there. - MAX_BATCH and MAX_PUBLISH_BYTES live beside the other content limits, and the ceiling is derived (MAX_BATCH * (MAX_BODY + MAX_TITLE) * 2) rather than picked, so it cannot drift from the field limits it exists to accommodate. The route's hardcoded 50 now reads MAX_BATCH. - The larger limit is scoped to /api/moshpit/sites, not global. The limit is what stops an unauthenticated POST making the process buffer megabytes, and `verify` copies every body into a string as well, so raising it globally is paid on every route while only this one needs the room. - It is mounted BEFORE the global parser, because body-parser skips a request whose body was already read. Whichever runs first sets the limit, and reversing the two lines silently restores the 100kb default — which is why there is a drift guard for the order. - 4xx from body-parser now reports as itself, in JSON under /api/, and says to split the batch. Splitting is safe advice specifically because publishing upserts on the slug. A batch of 50 maximal galleries still exceeds this and 413s, which is deliberate: the alternative is accepting multi-megabyte bodies on every request to buy headroom for a shape nobody sends. Verified against the real server, not a replica of its wiring: a 241,201-byte batch returns 201 with every item created, and a 2,100,064-byte one returns 413 with the JSON explanation. 551/551 apps/pwa and 2353/2353 root tests pass.
1 parent c99b27b commit 59facf4

7 files changed

Lines changed: 254 additions & 13 deletions

File tree

apps/pwa/deploy/README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,10 @@ are in the right order already.
7676
usually because the unit was copied from another box. Re-run `bootstrap.sh`;
7777
it resolves the real `node` binary rather than a mise shim, which is a path
7878
that only works with mise's environment loaded.
79-
- **A large publish batch 413s.** The API takes up to 50 items, but
80-
`express.json()` in `src/server.mjs` uses its 100kb default, which a batch of
81-
substantial posts exceeds. The vhost allows 2m so nginx is not a second,
82-
more confusing limit — the app's own ceiling is the real one.
79+
- **A very large publish batch 413s.** The app sizes itself for a batch of 50
80+
documented-length posts (`MAX_PUBLISH_BYTES`, ~1.9M) and the vhost allows 2m
81+
so nginx is never the one saying no. Past that you get a 413 in JSON telling
82+
you to split the batch — safe to do, because publishing upserts on the slug.
8383

8484
## Verifying, one layer at a time
8585

apps/pwa/deploy/nginx-vhost.conf

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,11 @@ server {
4545
ssl_protocols TLSv1.2 TLSv1.3;
4646
ssl_prefer_server_ciphers off;
4747

48-
# The publishing endpoint takes a batch of up to 50 items. nginx's 1m
49-
# default would truncate a large one into a 413 that looks like the API
50-
# rejecting valid JSON. Note that Express's own body limit (100kb, the
51-
# default in src/server.mjs) is the tighter ceiling in practiceraising
52-
# this only stops nginx from being a SECOND, more confusing limit.
48+
# The publishing endpoint takes a batch of up to MAX_BATCH items, which the
49+
# app sizes itself for (MAX_PUBLISH_BYTES in lib/moshpit-content.mjs, ~1.9M).
50+
# nginx's 1m default would cut that down again, turning a batch the app
51+
# would have accepted into a 413 from the proxyso this sits just above
52+
# the app's own ceiling and lets the app be the one that says no.
5353
client_max_body_size 2m;
5454

5555
location / {

apps/pwa/src/lib/moshpit-content.mjs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,29 @@ export const MAX_ITEMS_PER_NAME = 500;
4141
/** The most entries a navigation will draw. Past this it is not a nav, it is a list. */
4242
export const MAX_NAV = 12;
4343

44+
/** The most items one POST may carry. A webhook delivers a batch; this bounds it. */
45+
export const MAX_BATCH = 50;
46+
47+
/**
48+
* The largest a batch may be on the wire, in bytes.
49+
*
50+
* Derived rather than picked, because the two numbers have to agree: the API
51+
* documents a ceiling of MAX_BATCH items, and body-parser's 100kb default is
52+
* far below what MAX_BATCH items of this size actually weigh. The documented
53+
* limit would then 413 before reaching the handler — the request rejected for
54+
* a reason no field limit here explains, which is a bad afternoon.
55+
*
56+
* MAX_BATCH full-length bodies is ~1 MB of text. The doubling covers titles,
57+
* slugs, sections, URLs and JSON escaping, which can widen one character to
58+
* six bytes on the wire.
59+
*
60+
* A batch of MAX_BATCH *maximal galleries* is larger still and will 413. That
61+
* is deliberate — the alternative is accepting multi-megabyte bodies on every
62+
* request to buy headroom for a shape nobody sends. Split the batch; the
63+
* endpoint upserts on the slug, so splitting is safe and retryable.
64+
*/
65+
export const MAX_PUBLISH_BYTES = MAX_BATCH * (MAX_BODY + MAX_TITLE) * 2;
66+
4467
/**
4568
* A slug: lowercase, dashes, no leading or trailing dash.
4669
*

apps/pwa/src/routes/moshpit.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import { resolverConfig } from "../lib/moshpit-resolvers.mjs";
3434
import { landingFor } from "../lib/moshpit-landing.mjs";
3535
import { FEED_KINDS, loadFeed } from "../lib/feed.mjs";
3636
import { FEED_CSS, feedPage, feedUnavailable } from "../lib/moshpit-feed-page.mjs";
37-
import { CONTENT_KINDS } from "../lib/moshpit-content.mjs";
37+
import { CONTENT_KINDS, MAX_BATCH } from "../lib/moshpit-content.mjs";
3838
import { SITE_CSS, sitePage, sitePart } from "../lib/moshpit-site-page.mjs";
3939
import { nameQuery, tldQuery } from "../lib/moshpit-search.mjs";
4040
import {
@@ -481,7 +481,7 @@ moshpitRouter.post("/api/moshpit/sites/:name/content", async (req, res) => {
481481

482482
const payload = Array.isArray(req.body) ? req.body : [req.body ?? {}];
483483
if (!payload.length) return bad(res, "nothing to publish");
484-
if (payload.length > 50) return bad(res, "publish up to 50 items at a time");
484+
if (payload.length > MAX_BATCH) return bad(res, `publish up to ${MAX_BATCH} items at a time`);
485485

486486
const results = [];
487487
for (const input of payload) {

apps/pwa/src/server.mjs

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,29 @@ import { pagesRouter } from "./routes/pages.mjs";
1616
import { settingsSyncRouter } from "./routes/settings-sync.mjs";
1717
import { moshpitRouter } from "./routes/moshpit.mjs";
1818
import { socialsRouter } from "./routes/socials.mjs";
19+
import { MAX_BATCH, MAX_PUBLISH_BYTES } from "./lib/moshpit-content.mjs";
1920

2021
const app = express();
2122
app.disable("x-powered-by");
2223
if (config.secure) app.set("trust proxy", 1); // Railway terminates TLS
2324

2425
// body parsing — keep the raw body for HMAC signature verification
25-
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf.toString("utf8"); } }));
26+
const captureRaw = (req, _res, buf) => { req.rawBody = buf.toString("utf8"); };
27+
28+
// Publishing takes a batch, and a batch does not fit in body-parser's 100kb
29+
// default: the API documents a ceiling of MAX_BATCH items and would have 413'd
30+
// a legitimate one before the handler ever saw it.
31+
//
32+
// Scoped to the publishing paths rather than raised globally. The limit is what
33+
// stops an unauthenticated POST from making the process buffer megabytes, and
34+
// `verify` above copies every body into a string as well — so the cost of
35+
// raising it is paid on every route, while only this one needs the room.
36+
//
37+
// Mounted BEFORE the global parser because body-parser skips a request whose
38+
// body has already been read. Whichever runs first sets the limit; reverse
39+
// these two lines and the 100kb default silently wins again.
40+
app.use("/api/moshpit/sites", express.json({ limit: MAX_PUBLISH_BYTES, verify: captureRaw }));
41+
app.use(express.json({ verify: captureRaw }));
2642
app.use(express.urlencoded({ extended: false }));
2743
app.use(cookieParser());
2844

@@ -64,7 +80,19 @@ app.use((req, res) => res.status(404).type("html").send(
6480
`<body style="background:#070806;color:#edf2e4;font-family:monospace;padding:14vh 24px;text-align:center"><h1 style="color:#a6ff1a">404</h1><p>no such page in the pit.</p><a style="color:#a6ff1a" href="/">back to the pit →</a></body>`));
6581

6682
// eslint-disable-next-line no-unused-vars
67-
app.use((err, _req, res, _next) => {
83+
app.use((err, req, res, _next) => {
84+
// An unreadable or oversized body is the caller's mistake, and body-parser
85+
// reports it as a 4xx. Without this branch it fell through to the 500 below,
86+
// so a script that sent too much was told the server had a bug — and went
87+
// looking in the wrong place. Report the status body-parser chose.
88+
const status = Number(err?.status ?? err?.statusCode) || 500;
89+
if (status >= 400 && status < 500) {
90+
const detail = err?.type === "entity.too.large"
91+
? `that body is too large. Publish up to ${MAX_BATCH} items at a time, and split the batch if it is still refused — publishing upserts on the slug, so a split batch is safe to retry.`
92+
: "could not read that request body as JSON";
93+
if (req.path.startsWith("/api/")) return res.status(status).json({ error: detail });
94+
return res.status(status).type("text").send(`${detail}\n`);
95+
}
6896
console.error(err);
6997
res.status(500).type("html").send(`<body style="background:#070806;color:#ff0050;font-family:monospace;padding:14vh 24px;text-align:center"><h1>500</h1><p>a bug got in. (there are no bugs, only features.)</p></body>`);
7098
});
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
// How much a webhook is allowed to publish in one call.
2+
//
3+
// The publishing endpoint documents a ceiling of MAX_BATCH items, and for a
4+
// while that was a promise the server could not keep: body-parser's default
5+
// limit is 100kb, and MAX_BATCH items of the documented size weigh about ten
6+
// times that. A caller sending exactly what the docs described got a rejection
7+
// no field limit in moshpit-content.mjs explained.
8+
//
9+
// Worse, it did not even report as a rejection. The catch-all error handler
10+
// turned body-parser's 413 into the generic 500 HTML page, so "your batch is
11+
// too big" arrived as "a bug got in" and sent people reading server logs.
12+
//
13+
// These tests pin all three halves of the fix: the limit is derived from the
14+
// field limits rather than guessed, a batch far past the old default is
15+
// accepted, and one past the new ceiling is refused as JSON with the status
16+
// body-parser chose.
17+
import assert from "node:assert/strict";
18+
import fs from "node:fs";
19+
import { mkdtempSync } from "node:fs";
20+
import { tmpdir } from "node:os";
21+
import path from "node:path";
22+
import { fileURLToPath } from "node:url";
23+
import { createRequire } from "node:module";
24+
import test from "node:test";
25+
26+
import { MAX_BATCH, MAX_BODY, MAX_PUBLISH_BYTES } from "../src/lib/moshpit-content.mjs";
27+
28+
const require = createRequire(import.meta.url);
29+
let deps = null;
30+
try {
31+
deps = { express: require("express"), cookieParser: require("cookie-parser") };
32+
} catch {
33+
deps = null;
34+
}
35+
36+
const workdir = mkdtempSync(path.join(tmpdir(), "moshcode-publish-limit-test-"));
37+
process.env.DATABASE_URL = `file:${path.join(workdir, "test.db")}`;
38+
process.env.SESSION_SECRET = "test-secret";
39+
40+
const SRC = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../src");
41+
42+
async function boot() {
43+
const { migrate } = await import("../src/migrate.mjs");
44+
await migrate();
45+
const { run, db } = await import("../src/db.mjs");
46+
const { sessionMiddleware, csrfGuard } = await import("../src/lib/session.mjs");
47+
const { moshpitRouter } = await import("../src/routes/moshpit.mjs");
48+
const { createApiKey } = await import("../src/lib/apikey.mjs");
49+
50+
await run(`INSERT OR REPLACE INTO users (id, email, display_name, created_at) VALUES ('u1','a@b.c','one',1)`);
51+
await run(`INSERT INTO moshpit_tlds (tld,user_id,owner_email,created_at) VALUES ('eggs','u1','a@b.c',1)`);
52+
await run(`INSERT INTO moshpit_names (tld,label,user_id,created_at) VALUES ('eggs','blue','u1',1)`);
53+
54+
const key = (await createApiKey("u1", "cli one")).plaintext;
55+
56+
// Wired the way src/server.mjs wires it: the scoped parser first, the global
57+
// one after. That order is the fix — see the drift guard at the bottom.
58+
const app = deps.express();
59+
const captureRaw = (req, _res, buf) => { req.rawBody = buf.toString("utf8"); };
60+
app.use("/api/moshpit/sites", deps.express.json({ limit: MAX_PUBLISH_BYTES, verify: captureRaw }));
61+
app.use(deps.express.json({ verify: captureRaw }));
62+
app.use(deps.express.urlencoded({ extended: false }));
63+
app.use(deps.cookieParser());
64+
app.use(sessionMiddleware);
65+
app.use(csrfGuard);
66+
app.use(moshpitRouter);
67+
68+
// eslint-disable-next-line no-unused-vars
69+
app.use((err, req, res, _next) => {
70+
const status = Number(err?.status ?? err?.statusCode) || 500;
71+
if (status >= 400 && status < 500) {
72+
const detail = err?.type === "entity.too.large" ? "too large" : "unreadable";
73+
if (req.path.startsWith("/api/")) return res.status(status).json({ error: detail });
74+
return res.status(status).type("text").send(`${detail}\n`);
75+
}
76+
return res.status(500).type("html").send("<body>500</body>");
77+
});
78+
79+
const server = await new Promise((resolve) => {
80+
const s = app.listen(0, "127.0.0.1", () => resolve(s));
81+
});
82+
const base = `http://127.0.0.1:${server.address().port}`;
83+
84+
const call = async (method, p, body) => {
85+
const res = await fetch(`${base}${p}`, {
86+
method,
87+
headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
88+
body: body === undefined ? undefined : JSON.stringify(body),
89+
});
90+
const text = await res.text();
91+
let json = null;
92+
try { json = JSON.parse(text); } catch { /* HTML error page */ }
93+
return { status: res.status, json, text };
94+
};
95+
96+
return { server, db, call };
97+
}
98+
99+
let booted = null;
100+
const app = () => (booted ||= boot());
101+
102+
test.after(() => {
103+
if (!booted) return;
104+
booted.then(({ server, db }) => { server.close(); db.close?.(); })
105+
.finally(() => { try { fs.rmSync(workdir, { recursive: true, force: true }); } catch { /* noop */ } });
106+
});
107+
108+
const skip = { skip: !deps && "apps/pwa deps not installed" };
109+
110+
test("publish limit: the ceiling is derived from the field limits, not guessed", () => {
111+
// The property that matters: a batch of the documented size must fit. If
112+
// MAX_BODY or MAX_BATCH grows and this is still a hardcoded "2mb", the two
113+
// drift apart silently and the endpoint starts refusing documented input.
114+
assert.ok(
115+
MAX_PUBLISH_BYTES >= MAX_BATCH * MAX_BODY,
116+
`${MAX_PUBLISH_BYTES} must hold ${MAX_BATCH} bodies of ${MAX_BODY}`,
117+
);
118+
119+
// And it must be meaningfully past the 100kb default, or nothing was fixed.
120+
assert.ok(MAX_PUBLISH_BYTES > 100 * 1024 * 4, "should be well past body-parser's 100kb default");
121+
});
122+
123+
test("publish limit: a batch past the old 100kb default is accepted", skip, async () => {
124+
const { call } = await app();
125+
126+
// ~240kb of JSON: comfortably past the old default, comfortably under the
127+
// new ceiling. This is the request that used to fail.
128+
const items = Array.from({ length: 20 }, (_, i) => ({
129+
kind: "text",
130+
title: `post ${i}`,
131+
slug: `post-${i}`,
132+
body: "x".repeat(12_000),
133+
}));
134+
const payload = JSON.stringify(items);
135+
assert.ok(payload.length > 100 * 1024, `fixture must exceed the old default, got ${payload.length}`);
136+
assert.ok(payload.length < MAX_PUBLISH_BYTES, "fixture must stay under the new ceiling");
137+
138+
const res = await call("POST", "/api/moshpit/sites/blue.eggs/content", items);
139+
// 201 when every item was created, 200 on a pure update, 207 when the batch
140+
// was partly valid. Any of the three means the body was read — which is the
141+
// thing under test. What must not come back is 413.
142+
assert.ok(
143+
[200, 201, 207].includes(res.status),
144+
`expected a published batch, got ${res.status} ${res.text.slice(0, 200)}`,
145+
);
146+
assert.equal(res.json.results.length, 20);
147+
assert.ok(res.json.results.every((r) => r.ok), "every item in a valid batch should publish");
148+
});
149+
150+
test("publish limit: past the ceiling it is a 413 in JSON, not a 500 in HTML", skip, async () => {
151+
const { call } = await app();
152+
153+
// One item whose body alone exceeds the whole-batch ceiling.
154+
const huge = [{ kind: "text", title: "too much", slug: "too-much", body: "x".repeat(MAX_PUBLISH_BYTES + 1024) }];
155+
156+
const res = await call("POST", "/api/moshpit/sites/blue.eggs/content", huge);
157+
158+
// The status body-parser chose, not the catch-all 500 this used to become.
159+
assert.equal(res.status, 413, `expected 413, got ${res.status}`);
160+
// JSON, because /api/ is an API. An HTML error page here is unparseable by
161+
// the scripts this endpoint exists for.
162+
assert.ok(res.json, `expected a JSON body, got ${res.text.slice(0, 200)}`);
163+
assert.match(res.json.error, /too large/i);
164+
});
165+
166+
test("publish limit: the scoped parser is mounted before the global one", () => {
167+
// A drift guard rather than a behaviour test, because the wiring lives in
168+
// server.mjs and server.mjs listens on import — it cannot be imported here.
169+
//
170+
// Order is the whole fix. body-parser skips a request whose body another
171+
// parser already read, so if the global 100kb parser is mounted first it
172+
// wins and the scoped limit becomes decorative. That reversal is invisible:
173+
// every test above still passes, because they wire their own stack.
174+
const src = fs.readFileSync(path.join(SRC, "server.mjs"), "utf8");
175+
176+
const scoped = src.indexOf('app.use("/api/moshpit/sites", express.json(');
177+
const global = src.indexOf("app.use(express.json({ verify: captureRaw }))");
178+
179+
assert.ok(scoped !== -1, "server.mjs should mount a scoped JSON parser for /api/moshpit/sites");
180+
assert.ok(global !== -1, "server.mjs should still mount a global JSON parser");
181+
assert.ok(scoped < global, "the scoped parser must be mounted BEFORE the global one, or its limit never applies");
182+
183+
// And it must use the derived constant, not a literal that can drift.
184+
assert.match(src, /limit:\s*MAX_PUBLISH_BYTES/, "the scoped parser should use MAX_PUBLISH_BYTES");
185+
});

docs/hosting-a-moshpit-name.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,11 @@ of creating two, which matters the first time a delivery is retried. A batch
232232
that is partly valid answers `207` and reports each item separately, so one bad
233233
entry does not discard the good ones.
234234

235+
**A batch is capped by size as well as by count.** Fifty posts of the maximum
236+
body length fit; fifty maximal galleries do not, and come back as a `413`
237+
saying so. Split it — upserting on the slug is what makes splitting safe, so
238+
the halves can be retried independently and in any order.
239+
235240
### What the site looks like
236241

237242
`/n/<name>` is the front page — every published post, newest first. Sections and

0 commit comments

Comments
 (0)