jevstrudel.git / worker / src / content.ts
1// Listeners' content (website/src/jev/listeners.mjs): songs signed-in
2// listeners publish from the editor, with revisions and a cover, comments on
3// any song, and pitches (ideas for Jev songs). Writing needs an account;
4// reading is public, and shows only what Jev has screened fine
5// (content-jobs.ts, screen.ts).
6//
7//   GET  /jev/listeners/songs                   public songs, the most art first
8//   POST /jev/listeners/songs                   { title, description, spec, code } → 201 { id, rev, status }
9//   GET  /jev/listeners/songs/<id>              one public song: its newest fine revision, whole
10//   POST /jev/listeners/songs/<id>/revisions    the next revision (its author only) → 201 { rev, status }
11//   PUT  /jev/listeners/songs/<id>/cover        multipart { image, alt } (its author only) → 201 { id, status }
12//   GET  /jev/listeners/covers/<id>             a public song's cover image
13//   GET  /jev/listeners/comments?song=<song>    public comments: a site song's id, or listener:<id>
14//   POST /jev/listeners/comments                { song, body } → 201 { id, status }
15//   GET  /jev/listeners/pitches?sort=new|votes  public pitches, newest or most voted first, each with its
16//                                               votes, whether you voted, and the listener songs answering it
17//   POST /jev/listeners/pitches                 { body } → 201 { id, status }
18//   PUT  /jev/listeners/pitches/<id>/vote       your vote for a public pitch → { votes, voted }
19//   DELETE /jev/listeners/pitches/<id>/vote     your vote taken back → { votes, voted }
20//   PUT  /jev/listeners/songs/<id>/pitch        { pitch: id | null }: the pitch your song answers (its author only)
21//   GET  /jev/listeners/mine                    everything you wrote, with its status
22//
23// A write answers with the new item's status: public, held (Jev's verdict),
24// or pending (why, and when Jev is asked again). It is screened before the
25// answer, so usually the status is the verdict; a song's score follows in
26// waitUntil. Status and retries are content-jobs.ts's.
27import { d1Accounts, type Accounts, type User } from './accounts-store';
28import { signedIn } from './auth';
29import { sweep } from './content-jobs';
30import { d1Content, type ContentStore, type Kind, type SongFields, type Target } from './content-store';
31import type { Env } from './env';
32import { randomId, refuseCrossOrigin } from './session';
33import { whyNotCode } from './screen';
34import { knownSongs, VOTE_LIMIT } from './votes';
35import config from '../wrangler.json';
36
37export const LISTENERS_PREFIX = '/jev/listeners/';
38
39// Sizes, in UTF-8 bytes where they bound storage and a Jev call's cost: the
40// code as the relay's MAX_BYTES (the critic sends a song whole), a spec
41// half that, so a revision's score is at most ~24k input tokens a run.
42export const MAX_CODE_BYTES = 64 * 1024;
43export const MAX_SPEC_BYTES = 32 * 1024;
44export const TITLE_MAX = 80;
45export const DESCRIPTION_MAX = 280;
46export const TEXT_MAX = 2000; // a comment or a pitch, in characters
47export const ALT_MAX = 200;
48export const MAX_COVER_BYTES = 2 * 1024 * 1024;
49// A request's own cap, above what its fields may hold once JSON-escaped.
50const MAX_SONG_REQUEST = 4 * (MAX_CODE_BYTES + MAX_SPEC_BYTES);
51const MAX_TEXT_REQUEST = 16 * 1024;
52const MAX_COVER_REQUEST = MAX_COVER_BYTES + 16 * 1024;
53
54// Items of one author waiting on a screening, at most. Writing is allowed
55// with the day's budget spent (the item waits), so this is what bounds what
56// one account can store unscreened: pending items drain only as fast as the
57// author's budget screens them.
58export const MAX_PENDING = 20;
59
60// Per signed-in author (CONTENT_LIMIT, COVER_LIMIT) and per visitor
61// (BROWSE_LIMIT), set in wrangler.json. Writing: a person comments or
62// publishes a few times a minute at most, 10; times the largest request is
63// what an account can send a minute. Covers are 2 MiB each, 3 a minute.
64// Reading: the song list, a song, its comments and its cover per song
65// opened, plus every cover on the list's cards (cached a day by the
66// browser): 240 a minute.
67const limitOf = (name: string) => config.ratelimits.find((r) => r.name === name)!.simple;
68export const CONTENT_LIMIT = limitOf('CONTENT_LIMIT');
69export const COVER_LIMIT = limitOf('COVER_LIMIT');
70export const BROWSE_LIMIT = limitOf('BROWSE_LIMIT');
71
72const SITE_SONG = /^[a-z0-9-]{1,64}\/[a-z0-9-]{1,64}$/;
73const ID = /^[A-Za-z0-9_-]{22}$/;
74export const LISTENER_SONG = /^listener:([A-Za-z0-9_-]{22})$/;
75
76const json = (status: number, body: unknown, headers: Record<string, string> = {}) =>
77  Response.json(body, { status, headers: { 'Cache-Control': 'no-store', ...headers } });
78const fail = (status: number, error: string, headers: Record<string, string> = {}) => json(status, { error }, headers);
79
80const isObject = (x: unknown): x is Record<string, unknown> => typeof x === 'object' && x !== null && !Array.isArray(x);
81const bytes = (s: string) => new TextEncoder().encode(s).length;
82
83// One line of text as kept: NFC, trimmed, spaces folded, no control or
84// format characters; null when empty (unless allowed) or too long.
85function line(value: unknown, max: number, { empty = false } = {}): string | null {
86  if (typeof value !== 'string') return null;
87  const s = value.normalize('NFC').trim().replace(/\s+/g, ' ');
88  if ((!s && !empty) || [...s].length > max || /\p{Cc}|\p{Cf}/u.test(s)) return null;
89  return s;
90}
91// Text with lines: NFC, trimmed, CRLF as LF, no control characters but
92// newlines and tabs.
93function text(value: unknown, { maxChars, maxBytes, empty = false }: { maxChars?: number; maxBytes?: number; empty?: boolean }) {
94  if (typeof value !== 'string') return null;
95  const s = value.normalize('NFC').replace(/\r\n?/g, '\n').trim();
96  if (!s && !empty) return null;
97  if (/(?![\n\t])\p{Cc}/u.test(s)) return null;
98  if (maxChars !== undefined && [...s].length > maxChars) return null;
99  if (maxBytes !== undefined && bytes(s) > maxBytes) return null;
100  return s;
101}
102
103// A song's fields as kept, or why not.
104export function songFields(body: unknown): SongFields | string {
105  if (!isObject(body)) return 'body must be a JSON object';
106  const extra = Object.keys(body).filter((k) => !['title', 'description', 'spec', 'code'].includes(k));
107  if (extra.length) return `unexpected fields: ${extra.join(', ')}`;
108  const title = line(body.title, TITLE_MAX);
109  if (!title) return `a title is 1 to ${TITLE_MAX} characters on one line`;
110  const description = line(body.description ?? '', DESCRIPTION_MAX, { empty: true });
111  if (description === null) return `a description is at most ${DESCRIPTION_MAX} characters on one line`;
112  const spec = text(body.spec ?? '', { maxBytes: MAX_SPEC_BYTES, empty: true });
113  if (spec === null) return `a spec is text of at most ${MAX_SPEC_BYTES / 1024} KiB`;
114  // the code exactly as written, bar line endings: it is what plays
115  if (typeof body.code !== 'string') return 'code is the song, as text';
116  const code = body.code.replace(/\r\n?/g, '\n');
117  if (!code.trim() || bytes(code) > MAX_CODE_BYTES) return `code is 1 byte to ${MAX_CODE_BYTES / 1024} KiB`;
118  if (/(?![\n\t])\p{Cc}/u.test(code)) return 'code has control characters';
119  const why = whyNotCode(code);
120  if (why) return why;
121  return { title, description, spec, code };
122}
123
124// The song a comment is on: one of the deploy's songs, or a public listener song.
125async function target(env: Env, store: ContentStore, song: unknown): Promise<Target | string> {
126  if (typeof song !== 'string') return 'song is a song id';
127  const listener = LISTENER_SONG.exec(song);
128  if (listener) return (await store.publicSong(listener[1])) ? { listener: listener[1] } : 'no such public song';
129  if (!SITE_SONG.test(song)) return 'song is a song id';
130  const known = await knownSongs(env);
131  return known.has(song) ? { site: song } : 'song must be a song on this site';
132}
133
134// What an image's own bytes say it is; never what the upload claims.
135export function sniffImage(b: Uint8Array): 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' | null {
136  const at = (i: number, ...xs: number[]) => xs.every((x, k) => b[i + k] === x);
137  const ascii = (i: number, s: string) => at(i, ...[...s].map((c) => c.charCodeAt(0)));
138  if (at(0, 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return 'image/png';
139  if (at(0, 0xff, 0xd8, 0xff)) return 'image/jpeg';
140  if (ascii(0, 'GIF87a') || ascii(0, 'GIF89a')) return 'image/gif';
141  if (ascii(0, 'RIFF') && ascii(8, 'WEBP')) return 'image/webp';
142  return null;
143}
144
145// The body, read no further than `max` bytes; null when it is longer.
146export async function readBounded(request: Request, max: number): Promise<Uint8Array | null> {
147  const declared = Number(request.headers.get('Content-Length'));
148  if (Number.isFinite(declared) && declared > max) return null;
149  if (!request.body) return new Uint8Array();
150  const reader = request.body.getReader();
151  const chunks: Uint8Array[] = [];
152  let length = 0;
153  for (;;) {
154    const { done, value } = await reader.read();
155    if (done) break;
156    length += value.byteLength;
157    if (length > max) {
158      await reader.cancel();
159      return null;
160    }
161    chunks.push(value);
162  }
163  const out = new Uint8Array(length);
164  let at = 0;
165  for (const c of chunks) {
166    out.set(c, at);
167    at += c.byteLength;
168  }
169  return out;
170}
171
172async function readJson(request: Request, max: number): Promise<{ body: unknown } | Response> {
173  const raw = await readBounded(request, max);
174  if (!raw) return fail(413, 'request too large');
175  try {
176    return { body: JSON.parse(new TextDecoder().decode(raw)) };
177  } catch {
178    return fail(400, 'body must be JSON');
179  }
180}
181
182type Ctx = Pick<ExecutionContext, 'waitUntil'>;
183
184export async function content(
185  request: Request,
186  env: Env,
187  ctx: Ctx,
188  accounts: Accounts = d1Accounts(env.DB),
189  store: ContentStore = d1Content(env.DB),
190): Promise<Response> {
191  const url = new URL(request.url);
192  const parts = url.pathname.slice(LISTENERS_PREFIX.length).split('/');
193  const route = parts[0];
194  const id = parts[1];
195  const method = request.method;
196
197  // which routes exist, and their methods
198  const allowed: string[] | null =
199    route === 'songs' && parts.length === 1
200      ? ['GET', 'POST']
201      : route === 'songs' && parts.length === 2
202        ? ['GET']
203        : route === 'songs' && parts.length === 3 && parts[2] === 'revisions'
204          ? ['POST']
205          : route === 'songs' && parts.length === 3 && (parts[2] === 'cover' || parts[2] === 'pitch')
206            ? ['PUT']
207            : route === 'covers' && parts.length === 2
208              ? ['GET']
209              : (route === 'comments' || route === 'pitches') && parts.length === 1
210                ? ['GET', 'POST']
211                : route === 'pitches' && parts.length === 3 && parts[2] === 'vote'
212                  ? ['PUT', 'DELETE']
213                : route === 'mine' && parts.length === 1
214                  ? ['GET']
215                  : null;
216  if (!allowed) return fail(404, 'not found');
217  if (!allowed.includes(method)) return fail(405, `${allowed.join(', ')} only`, { Allow: allowed.join(', ') });
218  if (id !== undefined && !ID.test(id)) return fail(404, 'not found');
219
220  const writes = method !== 'GET';
221  // index.ts refuses these first; here too, so this route holds alone
222  const crossOrigin = refuseCrossOrigin(request);
223  if (crossOrigin) return crossOrigin;
224  let user: User | null = null;
225  if (writes || route === 'mine') {
226    user = await signedIn(request, accounts);
227    if (!user) return fail(401, writes ? 'sign in to publish, comment, pitch or vote' : 'sign in to see what you wrote');
228  } else if (route === 'pitches') {
229    // whether you voted for each; reading needs no account
230    user = await signedIn(request, accounts);
231  }
232
233  // a vote, and a song's pitch, add nothing Jev must screen
234  const pitchVote = route === 'pitches' && parts[2] === 'vote';
235  const songPitch = route === 'songs' && parts[2] === 'pitch';
236  // a song or its revision is writeSong's, gate and all
237  const songWrite = route === 'songs' && writes && parts[2] !== 'cover' && !songPitch;
238  if (writes && !songWrite) {
239    const cover = parts[2] === 'cover';
240    // a pitch vote is a vote: the votes' own limit, per account
241    const [limiter, sized] = pitchVote
242      ? [env.VOTE_LIMIT, VOTE_LIMIT]
243      : cover
244        ? [env.COVER_LIMIT, COVER_LIMIT]
245        : [env.CONTENT_LIMIT, CONTENT_LIMIT];
246    const { success } = await limiter.limit({ key: `user:${user!.id}` });
247    if (!success) {
248      return fail(429, pitchVote ? 'too many votes; try again in a minute' : 'too many posts; try again in a minute', {
249        'Retry-After': String(sized.period),
250      });
251    }
252    if (!pitchVote && !songPitch && (await store.pendingScreens(user!.id)) >= MAX_PENDING) {
253      return fail(429, `you have ${MAX_PENDING} things waiting for Jev to screen; post again once they are through`);
254    }
255  } else {
256    const visitor = user ? `user:${user.id}` : (request.headers.get('CF-Connecting-IP') ?? 'local');
257    const { success } = await env.BROWSE_LIMIT.limit({ key: visitor });
258    if (!success) return fail(429, 'too many requests; try again in a minute', { 'Retry-After': String(BROWSE_LIMIT.period) });
259  }
260
261  const screened = async (kind: Kind, item: string, extra: object) =>
262    json(201, await screenNow(env, ctx, store, user!, kind, item, extra));
263
264  switch (route) {
265    case 'songs': {
266      if (parts.length === 1 && method === 'GET') return json(200, { songs: await store.publicSongs() });
267      if (parts.length === 1) {
268        const read = await readJson(request, MAX_SONG_REQUEST);
269        if (read instanceof Response) return read;
270        const written = await writeSong(env, ctx, store, user!, null, read.body);
271        return json(written.status, written.body, written.headers);
272      }
273      if (method === 'GET') {
274        const song = await store.publicSong(id!);
275        return song ? json(200, song) : fail(404, 'no such public song');
276      }
277      if (parts[2] === 'revisions') {
278        const read = await readJson(request, MAX_SONG_REQUEST);
279        if (read instanceof Response) return read;
280        const written = await writeSong(env, ctx, store, user!, id!, read.body);
281        return json(written.status, written.body, written.headers);
282      }
283      const owner = await store.songOwner(id!);
284      if (!owner) return fail(404, 'no such song');
285      if (owner !== user!.id) return fail(403, 'only its author can change a song');
286      if (songPitch) {
287        const read = await readJson(request, MAX_TEXT_REQUEST);
288        if (read instanceof Response) return read;
289        const b = read.body;
290        if (!isObject(b) || Object.keys(b).some((k) => k !== 'pitch') || !('pitch' in b)) return fail(400, 'a song\'s pitch is { pitch: id | null }');
291        const pitch = b.pitch;
292        if (pitch !== null && (typeof pitch !== 'string' || !ID.test(pitch))) return fail(400, 'pitch is a pitch id, or null');
293        if (pitch !== null && !(await store.pitchIsPublic(pitch))) return fail(400, 'no such public pitch');
294        await store.setSongPitch(id!, pitch);
295        return json(200, { pitch });
296      }
297      return putCover(request, env, ctx, store, user!, id!, screened);
298    }
299    case 'covers': {
300      const cover = await store.publicCover(id!);
301      const object = cover && (await env.COVERS.get(`covers/${id}`));
302      if (!cover || !object) return fail(404, 'no such cover');
303      return new Response(object.body, {
304        headers: {
305          'Content-Type': cover.contentType,
306          'Content-Length': String(object.size),
307          ETag: object.httpEtag,
308          // a cover's id is new with each upload, so its bytes never change
309          'Cache-Control': 'public, max-age=86400',
310          'X-Content-Type-Options': 'nosniff',
311          'Content-Security-Policy': "default-src 'none'; sandbox",
312        },
313      });
314    }
315    case 'comments': {
316      const on = async (song: unknown) => {
317        try {
318          return await target(env, store, song);
319        } catch (e) {
320          // the deploy's song list could not be read (votes.ts's knownSongs)
321          console.error({ event: 'jev.content', error: (e as Error).message });
322          return null;
323        }
324      };
325      if (method === 'GET') {
326        const song = await on(url.searchParams.get('song'));
327        if (song === null) return fail(503, 'comments cannot be read right now');
328        if (typeof song === 'string') return fail(400, song);
329        return json(200, { comments: await store.publicComments(song) });
330      }
331      const read = await readJson(request, MAX_TEXT_REQUEST);
332      if (read instanceof Response) return read;
333      const written = await addComment(env, ctx, store, user!, read.body);
334      return json(written.status, written.body, written.headers);
335    }
336    case 'pitches': {
337      if (pitchVote) {
338        const vote = await store.votePitch(id!, user!.id, method === 'PUT');
339        return vote ? json(200, vote) : fail(404, 'no such public pitch');
340      }
341      if (method === 'GET') {
342        const sort = url.searchParams.get('sort') ?? 'new';
343        if (sort !== 'new' && sort !== 'votes') return fail(400, 'sort is new or votes');
344        return json(200, { pitches: await store.publicPitches(100, { sort, viewer: user?.id ?? null }) });
345      }
346      const read = await readJson(request, MAX_TEXT_REQUEST);
347      if (read instanceof Response) return read;
348      const b = read.body;
349      if (!isObject(b) || Object.keys(b).some((k) => k !== 'body')) return fail(400, 'a pitch is { body }');
350      const body = text(b.body, { maxChars: TEXT_MAX });
351      if (!body) return fail(400, `a pitch is 1 to ${TEXT_MAX} characters`);
352      const pitchId = randomId(16);
353      await store.addPitch(pitchId, user!.id, body);
354      return screened('pitch', pitchId, { id: pitchId });
355    }
356    default: {
357      // mine: the author's due jobs run behind the answer, so what is
358      // pending moves while they look (the page asks again)
359      ctx.waitUntil(sweep(env, store, { userId: user!.id, limit: 3 }).then(() => undefined));
360      return json(200, await store.mine(user!.id));
361    }
362  }
363}
364
365// A new item, screened now: the answer carries its status. The author's
366// other due jobs may run first (oldest due first), which is as fair.
367async function screenNow(env: Env, ctx: Ctx, store: ContentStore, user: User, kind: Kind, item: string, extra: object) {
368  await sweep(env, store, { userId: user.id, limit: 2 });
369  // a song screened fine now owes its score: asked after the answer
370  if (kind === 'revision') ctx.waitUntil(sweep(env, store, { userId: user.id, limit: 1 }).then(() => undefined));
371  return { ...extra, ...(await store.itemStatus(kind, item)) };
372}
373
374export type Written = { status: number; body: Record<string, unknown>; headers?: Record<string, string> };
375
376// A signed-in author's comment `{ song, body }`, once the caller has held
377// them to CONTENT_LIMIT and the pending cap: the route above does, and the
378// hosted MCP's `comment` goes through commentAs, which does both.
379async function addComment(env: Env, ctx: Ctx, store: ContentStore, user: User, b: unknown): Promise<Written> {
380  const failed = (status: number, error: string): Written => ({ status, body: { error } });
381  if (!isObject(b) || Object.keys(b).some((k) => k !== 'song' && k !== 'body')) return failed(400, 'a comment is { song, body }');
382  const body = text(b.body, { maxChars: TEXT_MAX });
383  if (!body) return failed(400, `a comment is 1 to ${TEXT_MAX} characters`);
384  let song: Target | string;
385  try {
386    song = await target(env, store, b.song);
387  } catch (e) {
388    // the deploy's song list could not be read (votes.ts's knownSongs)
389    console.error({ event: 'jev.content', error: (e as Error).message });
390    return failed(503, 'comments are not being taken right now');
391  }
392  if (typeof song === 'string') return failed(400, song);
393  const commentId = randomId(16);
394  await store.addComment(commentId, user.id, song, body);
395  return { status: 201, body: await screenNow(env, ctx, store, user, 'comment', commentId, { id: commentId }) };
396}
397
398// A comment by a signed-in author from elsewhere than the page (the hosted
399// MCP): the page's rate and pending cap, then the same screening.
400export async function commentAs(env: Env, ctx: Ctx, store: ContentStore, user: User, body: unknown): Promise<Written> {
401  const { success } = await env.CONTENT_LIMIT.limit({ key: `user:${user.id}` });
402  if (!success) return { status: 429, body: { error: 'too many posts; try again in a minute' }, headers: { 'Retry-After': String(CONTENT_LIMIT.period) } };
403  if ((await store.pendingScreens(user.id)) >= MAX_PENDING) {
404    return { status: 429, body: { error: `you have ${MAX_PENDING} things waiting for Jev to screen; post again once they are through` } };
405  }
406  return addComment(env, ctx, store, user, body);
407}
408
409// A signed-in author writing a song: a new one (`songId` null) or the next
410// revision of their own. The one way a song is written, from the page's
411// routes above and from the hosted MCP (hosted-mcp.ts) alike: the same
412// rate (CONTENT_LIMIT), the same pending cap, the same fields, the same
413// screening and scoring.
414export async function writeSong(
415  env: Env,
416  ctx: Ctx,
417  store: ContentStore,
418  user: User,
419  songId: string | null,
420  body: unknown,
421): Promise<Written> {
422  const failed = (status: number, error: string, headers?: Record<string, string>): Written => ({
423    status,
424    body: { error },
425    headers,
426  });
427  const { success } = await env.CONTENT_LIMIT.limit({ key: `user:${user.id}` });
428  if (!success) return failed(429, 'too many posts; try again in a minute', { 'Retry-After': String(CONTENT_LIMIT.period) });
429  if ((await store.pendingScreens(user.id)) >= MAX_PENDING) {
430    return failed(429, `you have ${MAX_PENDING} things waiting for Jev to screen; post again once they are through`);
431  }
432  const fields = songFields(body);
433  if (typeof fields === 'string') return failed(400, fields);
434  const revisionId = randomId(16);
435  if (songId === null) {
436    const id = randomId(16);
437    await store.createSong(user.id, id, revisionId, fields);
438    return { status: 201, body: await screenNow(env, ctx, store, user, 'revision', revisionId, { id, rev: 1 }) };
439  }
440  if (!ID.test(songId)) return failed(404, 'no such song');
441  const owner = await store.songOwner(songId);
442  if (!owner) return failed(404, 'no such song');
443  if (owner !== user.id) return failed(403, 'only its author can change a song');
444  const rev = await store.addRevision(songId, revisionId, fields);
445  return { status: 201, body: await screenNow(env, ctx, store, user, 'revision', revisionId, { id: songId, rev }) };
446}
447
448// A cover upload: multipart `image` (a PNG, JPEG, WebP or GIF of at most
449// MAX_COVER_BYTES, its type read from its bytes) and `alt` (what it shows,
450// which Jev screens). Stored as `covers/<new id>` in R2, then recorded,
451// replacing the song's old cover, whose object is deleted after.
452async function putCover(
453  request: Request,
454  env: Env,
455  ctx: Ctx,
456  store: ContentStore,
457  user: User,
458  song: string,
459  screened: (kind: 'cover', item: string, extra: object) => Promise<Response>,
460): Promise<Response> {
461  const raw = await readBounded(request, MAX_COVER_REQUEST);
462  if (!raw) return fail(413, `a cover is at most ${MAX_COVER_BYTES / 1024 / 1024} MiB`);
463  let form: FormData;
464  try {
465    form = await new Response(raw, { headers: { 'Content-Type': request.headers.get('Content-Type') ?? '' } }).formData();
466  } catch {
467    return fail(400, 'a cover is multipart form data: image and alt');
468  }
469  const image = form.get('image');
470  const alt = line(form.get('alt'), ALT_MAX);
471  if (!alt) return fail(400, `alt describes the image in 1 to ${ALT_MAX} characters`);
472  if (!image || typeof image === 'string') return fail(400, 'image is the cover, as a file');
473  const bytes = new Uint8Array(await image.arrayBuffer());
474  if (!bytes.byteLength || bytes.byteLength > MAX_COVER_BYTES) {
475    return fail(413, `a cover is at most ${MAX_COVER_BYTES / 1024 / 1024} MiB`);
476  }
477  const contentType = sniffImage(bytes);
478  if (!contentType) return fail(415, 'a cover is a PNG, JPEG, WebP or GIF image');
479  const id = randomId(16);
480  await env.COVERS.put(`covers/${id}`, bytes, { httpMetadata: { contentType } });
481  let old: string | null;
482  try {
483    old = await store.putCover({ id, song, userId: user.id, contentType, bytes: bytes.byteLength, alt });
484  } catch (e) {
485    ctx.waitUntil(env.COVERS.delete(`covers/${id}`));
486    throw e;
487  }
488  if (old) ctx.waitUntil(env.COVERS.delete(`covers/${old}`));
489  return screened('cover', id, { id });
490}