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}