diff --git a/package.json b/package.json index ba354f7..61236f0 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "db:migrate:remote": "wrangler d1 migrations apply 3dprints-db --remote", "deploy": "wrangler deploy", "images": "node scripts/build-image-manifest.mjs", - "test": "node test/run.mjs && node test/shop.mjs && node test/payments.mjs && node test/orders.mjs && node test/admin.mjs && node test/customers.mjs && node test/cart.mjs && node test/coupons.mjs && node test/chatcoupons.mjs && node test/manifest.mjs && node test/seo.mjs && node test/pdp.mjs && node test/quotes.mjs && node test/chatorders.mjs && node test/agent.mjs && node test/apicache.mjs && node test/pricing-display.mjs", + "test": "node test/run.mjs && node test/shop.mjs && node test/payments.mjs && node test/orders.mjs && node test/admin.mjs && node test/customers.mjs && node test/cart.mjs && node test/coupons.mjs && node test/chatcoupons.mjs && node test/manifest.mjs && node test/seo.mjs && node test/pdp.mjs && node test/quotes.mjs && node test/chatorders.mjs && node test/agent.mjs && node test/apicache.mjs && node test/pricing-display.mjs && node test/uploads.mjs", "test:layout": "node test/browser/hero-geometry.mjs", "test:pin": "node test/browser/pin-control.mjs", "test:tracker": "node test/browser/order-tracker.mjs", diff --git a/public/assets/js/main.js b/public/assets/js/main.js index 49cc215..5061cdf 100644 --- a/public/assets/js/main.js +++ b/public/assets/js/main.js @@ -237,17 +237,21 @@ fileDrop?.addEventListener('drop', (e) => { if (file) { fileInput.files = e.dataTransfer.files; showFile(file); } }); +// To our own Worker and our own R2 bucket. This used to POST the customer's +// file to litterbox.catbox.moe — an anonymous public host with a 72-hour expiry +// — which is not where someone's part design belongs. See src/uploads.js. async function uploadFile(file) { - const fd = new FormData(); - fd.append('reqtype', 'fileupload'); - fd.append('time', '72h'); - fd.append('fileToUpload', file); - const res = await fetch('https://litterbox.catbox.moe/resources/internals/api.php', { - method: 'POST', body: fd, + const res = await fetch('/api/quote/upload', { + method: 'POST', + headers: { + 'Content-Type': file.type || 'application/octet-stream', + 'X-File-Name': encodeURIComponent(file.name), + }, + body: file, }); - const url = await res.text(); - if (!url.startsWith('https://')) throw new Error('Upload failed'); - return url.trim(); + const data = await res.json().catch(() => ({})); + if (!res.ok || !data.file_key) throw new Error(data.error || 'Upload failed'); + return data; } /* ===== QUOTE FORM ===== */ @@ -408,7 +412,7 @@ form?.addEventListener('submit', async (e) => { type: form.querySelector('[name=type]').value, qty: form.querySelector('[name=qty]').value, desc: form.querySelector('[name=desc]').value.trim(), - file_url: '', + file_key: '', file_name: '', // Set when they arrived here from a gallery image or a product card. Empty // for an ordinary quote request, so that path is unchanged. @@ -420,10 +424,13 @@ form?.addEventListener('submit', async (e) => { if (file) { btn.innerHTML = ' Uploading file…'; try { - payload.file_url = await uploadFile(file); - payload.file_name = file.name; - } catch { - showFormError('The file upload failed. Please check your connection and try again.'); + const up = await uploadFile(file); + payload.file_key = up.file_key; + payload.file_name = up.file_name; + } catch (err) { + showFormError(err?.message && err.message !== 'Upload failed' + ? err.message + : 'The file upload failed. Please check your connection and try again.'); btn.innerHTML = SUBMIT_LABEL; btn.disabled = false; return; diff --git a/public/privacy.html b/public/privacy.html index 7577d29..ee97b4f 100644 --- a/public/privacy.html +++ b/public/privacy.html @@ -49,8 +49,10 @@

What we collect

delivery address. We cannot ship without these.
  • If you create an account: your email address, and any name, phone and address you choose to save so checkout can pre-fill them.
  • -
  • If you request a quote: what you tell us, plus any reference image - you upload.
  • +
  • If you request a quote: what you tell us, plus any model or + reference file you upload. Files are stored privately in our own Cloudflare + storage, are only ever downloaded by Aswin to price and make the job, and are + never posted to a public or third-party file host.
  • If you use the live chat: your messages, and your name and email if you are signed in.
  • Automatically: aggregate, privacy-preserving visit statistics via diff --git a/src/index.js b/src/index.js index 17585fc..1617fd7 100644 --- a/src/index.js +++ b/src/index.js @@ -20,6 +20,7 @@ import { import { chatCouponHandler } from "./chatcoupons.js"; import { chatOrdersHandler } from "./chatorders.js"; import { listQuotes, replyToQuote, updateQuoteStatus } from "./quotes.js"; +import { uploadQuoteFile, downloadQuoteFile, validFileKey, quoteFileUrl } from "./uploads.js"; import { agentVerdict } from "./agent.js"; import { createOrderHandler, verifyOrderHandler, getOrderHandler, razorpayWebhook, @@ -326,6 +327,10 @@ async function api(request, env, url, ctx) { // the bot has no session, it carries its own signature. See chatorders.js. if (p === "/api/chat/orders" && m === "POST") return chatOrdersHandler(request, env); + // The quote form's file, as raw bytes — so it too must be dispatched before + // the JSON parse below. Anonymous, like the form; rate-limited with it. + if (p === "/api/quote/upload" && m === "POST") return uploadQuoteFile(request, env); + const body = (m === "POST" || m === "PUT" || m === "PATCH") ? await request.json().catch(() => ({})) : {}; @@ -513,6 +518,9 @@ async function api(request, env, url, ctx) { // AGENT_ROUTES: these read customer names, emails and phone numbers, and // /reply mints a live payment link. if (p === "/api/admin/quotes" && m === "GET") return listQuotes(env, url); + // The customer's uploaded model or photo. Owner-only by position: this is + // the only route that can read the UPLOADS bucket back. + if (p === "/api/admin/quotes/file" && m === "GET") return downloadQuoteFile(env, url); // Longest path first — /reply would otherwise be shadowed by the bare :id // form below, the same ordering trap the coupon routes above call out. @@ -548,7 +556,10 @@ function validateQuote(b) { type: clip(b.type, MAX.type), qty: Math.max(1, Math.min(1000, parseInt(b.qty, 10) || 0)), desc: clip(b.desc, MAX.desc), - file_url: clip(b.file_url, MAX.file), + // The R2 key returned by /api/quote/upload. Turned into an owner-only + // download URL below; the browser never supplies a URL any more. + file_key: clip(b.file_key, MAX.file), + file_url: "", file_name: clip(b.file_name, MAX.file), // Set when the request came from a gallery image or a product card. Free // text from the client, so it's clipped and escaped like any other field — @@ -562,12 +573,10 @@ function validateQuote(b) { if (q.desc.length < 10) errors.push("Please describe your project in a little more detail."); if (!b.qty || q.qty < 1) errors.push("Quantity must be at least 1."); - // Only accept an https URL from the uploader — never echo arbitrary text - // into a link in the owner's email. - if (q.file_url && !/^https:\/\/[^\s"'<>]+$/.test(q.file_url)) { - q.file_url = ""; - q.file_name = ""; - } + // Only a key of the exact shape the uploader mints becomes a link in the + // owner's email — never arbitrary text, and never a URL the browser chose. + if (q.file_key && !validFileKey(q.file_key)) q.file_key = ""; + if (!q.file_key) q.file_name = ""; return { q, errors }; } @@ -575,6 +584,14 @@ async function quote(request, env, ctx, body) { const { q, errors } = validateQuote(body); if (errors.length) return json({ error: errors[0], errors }, 400); + // The stored file_url is the owner-only download route for the key, so the + // email's "Download" button and the dashboard's link both work unchanged, and + // both require signing in as the owner. + if (q.file_key) { + q.file_url = quoteFileUrl(env, q.file_key); + if (!q.file_name) q.file_name = q.file_key.split("/").pop(); + } + // Record it before mailing. A request used to exist ONLY as two emails, so // losing the email lost the job — including the uploaded model, which was a // link inside that one message and nowhere else. diff --git a/src/security.js b/src/security.js index 9e65cad..9090b03 100644 --- a/src/security.js +++ b/src/security.js @@ -82,10 +82,11 @@ const CSP = [ // previews the quote uploader generates client-side. `img-src 'self' data: blob: https://*.razorpay.com ${CHATWOOT}`, - // litterbox.catbox.moe is the quote-form file uploader (see main.js). // cloudflareinsights also POSTs its collected metrics back, so allowing only // the script would still leave a violation on every page view. - `connect-src 'self' https://litterbox.catbox.moe ${CF_ANALYTICS} ${CHATWOOT} ${CHATWOOT_WS} ${RAZORPAY.join(" ")}`, + // Quote-form files go to our own /api/quote/upload ('self'); they used to go + // to litterbox.catbox.moe from the browser. + `connect-src 'self' ${CF_ANALYTICS} ${CHATWOOT} ${CHATWOOT_WS} ${RAZORPAY.join(" ")}`, // Razorpay Standard Checkout renders as an iframe from api.razorpay.com. `frame-src 'self' https://api.razorpay.com https://checkout.razorpay.com ${CHATWOOT}`, @@ -181,6 +182,8 @@ const RULES = [ { test: (p, m) => p === "/api/orders" && m === "POST", limiter: "RL_ORDER" }, // Sends email. { test: (p, m) => p === "/api/quote" && m === "POST", limiter: "RL_QUOTE" }, + // Writes up to 100 MB into R2 per call. Same budget as the form it belongs to. + { test: (p, m) => p === "/api/quote/upload" && m === "POST", limiter: "RL_QUOTE" }, // Unauthenticated and sends email. The per-email cap in customers.js already // bounds damage to one mailbox; this bounds how fast one IP can spray many. { test: (p) => p.startsWith("/api/auth/code"), limiter: "RL_AUTH" }, diff --git a/src/uploads.js b/src/uploads.js new file mode 100644 index 0000000..6e1ae1f --- /dev/null +++ b/src/uploads.js @@ -0,0 +1,122 @@ +// Quote-form file uploads, stored in our own R2 bucket. +// +// The quote form used to POST the customer's file — an STL of their part, a +// photo of the thing to copy, a PDF of a drawing — to litterbox.catbox.moe, an +// anonymous public host, from the browser. Anyone with the returned URL could +// download it, it expired after 72 hours whether or not the job had been +// priced, and the privacy policy did not mention a third party at all. For a +// customer sending a design they may not own the rights to share, that is not +// an acceptable place for it to live. +// +// Now: the browser PUTs the bytes to POST /api/quote/upload, the Worker writes +// them to the UPLOADS bucket under a key nobody can guess, and the quote row +// carries that key. The only way to read the object back is +// GET /api/admin/quotes/file?key=…, which sits behind the owner gate in +// index.js like every other /api/admin/ route. +// +// The bucket is `3dprints-uploads` (wrangler.toml). Retention is a lifecycle +// rule on the bucket, set in the Cloudflare dashboard, not code here. + +import { json, bad } from "./lib.js"; + +// What the form's accept= attribute lists, and nothing else. Checked by +// extension, since a browser's Content-Type for .stl or .3mf is whatever it +// feels like — often application/octet-stream — and is not worth trusting. +export const ALLOWED_EXTENSIONS = new Set([ + "stl", "obj", "3mf", "step", "stp", "jpg", "jpeg", "png", "pdf", +]); + +// Matches the "Max 100MB" the form promises. Also the Workers request body +// ceiling on the free plan, so a larger figure here would not be honoured. +export const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; + +// quotes///. The uuid is the secret; the year is so the +// bucket can be browsed by hand and so a lifecycle rule has a prefix to bite on. +const KEY_RE = /^quotes\/\d{4}\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/[A-Za-z0-9._-]{1,120}$/; + +export const validFileKey = (key) => KEY_RE.test(String(key || "")); + +// A filename safe to put in a key and a Content-Disposition header. Keeps the +// extension (checked separately), replaces everything else that is not a plain +// character, and refuses to become empty. +export function safeFileName(name) { + const base = String(name || "").split(/[\\/]/).pop().trim(); + const cleaned = base.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^[-.]+/, "").slice(0, 120); + return cleaned || "file"; +} + +const extensionOf = (name) => { + const m = /\.([A-Za-z0-9]+)$/.exec(String(name || "")); + return m ? m[1].toLowerCase() : ""; +}; + +// POST /api/quote/upload — raw body is the file; the name travels in a header. +// +// Dispatched before the JSON body parser in index.js, since the body is bytes. +// No auth: the quote form itself is anonymous. Bounded by RL_QUOTE (the same +// limiter as the form, 6/min per IP) and by the size and extension checks. +export async function uploadQuoteFile(request, env) { + if (!env.UPLOADS) { + console.error("upload received but the UPLOADS R2 binding is missing"); + return bad("File uploads aren't available right now — send the file by email instead.", 503); + } + + const rawName = decodeURIComponent(request.headers.get("x-file-name") || ""); + const name = safeFileName(rawName); + const ext = extensionOf(name); + if (!ALLOWED_EXTENSIONS.has(ext)) { + return bad("That file type isn't supported. Send an STL, OBJ, 3MF, STEP, image or PDF."); + } + + // Content-Length is set by the browser for a fetch() with a File body and is + // the cheap check. The read below is the real one: a chunked body with no + // length cannot exceed the cap either. + const declared = Number(request.headers.get("content-length") || 0); + if (declared > MAX_UPLOAD_BYTES) return bad("That file is larger than 100 MB.", 413); + + const bytes = await request.arrayBuffer(); + if (bytes.byteLength === 0) return bad("The file was empty."); + if (bytes.byteLength > MAX_UPLOAD_BYTES) return bad("That file is larger than 100 MB.", 413); + + const key = `quotes/${new Date().getUTCFullYear()}/${crypto.randomUUID()}/${name}`; + await env.UPLOADS.put(key, bytes, { + httpMetadata: { + contentType: request.headers.get("content-type") || "application/octet-stream", + }, + customMetadata: { + original_name: rawName.slice(0, 200), + uploaded_at: String(Date.now()), + ip: request.headers.get("cf-connecting-ip") || "", + }, + }); + + return json({ ok: true, file_key: key, file_name: name, bytes: bytes.byteLength }); +} + +// GET /api/admin/quotes/file?key=… — owner only (the gate is in index.js). +export async function downloadQuoteFile(env, url) { + if (!env.UPLOADS) return bad("File storage isn't configured.", 503); + const key = url.searchParams.get("key") || ""; + if (!validFileKey(key)) return bad("Bad file key.", 400); + + const obj = await env.UPLOADS.get(key); + if (!obj) return bad("That file is no longer stored.", 404); + + const name = key.split("/").pop(); + const headers = new Headers(); + obj.writeHttpMetadata(headers); + if (!headers.get("content-type")) headers.set("content-type", "application/octet-stream"); + // Attachment, always: an uploaded .html or .svg opened inline on this origin + // would run as us. The extension allowlist keeps those out too, but the + // header is the one that counts if the list is ever widened. + headers.set("content-disposition", `attachment; filename="${name}"`); + headers.set("cache-control", "private, no-store"); + headers.set("x-content-type-options", "nosniff"); + return new Response(obj.body, { status: 200, headers }); +} + +// The URL the owner's email and the dashboard link to. Built here so the two +// cannot disagree about the route. +export const quoteFileUrl = (env, key) => + (env.APP_BASE_URL || "https://3d-prints.aswincloud.com").replace(/\/$/, "") + + "/api/admin/quotes/file?key=" + encodeURIComponent(key); diff --git a/test/uploads.mjs b/test/uploads.mjs new file mode 100644 index 0000000..1e4fa67 --- /dev/null +++ b/test/uploads.mjs @@ -0,0 +1,139 @@ +// Quote-form uploads: into our own bucket, out only to the owner. +// +// node test/uploads.mjs +// +// The point of src/uploads.js is WHERE the file goes and WHO can read it back, +// so that is what these test: the key shape nobody can guess, the extension +// allowlist, the size cap, and that the download route hands the object back +// as an attachment with the name the customer gave it. +import { + uploadQuoteFile, downloadQuoteFile, validFileKey, safeFileName, quoteFileUrl, + MAX_UPLOAD_BYTES, +} from "../src/uploads.js"; + +let pass = 0, fail = 0; +const ok = (name, cond, detail = "") => { + if (cond) { pass++; console.log(` ok ${name}`); } + else { fail++; console.log(` FAIL ${name}${detail ? " — " + detail : ""}`); } +}; +const section = (t) => console.log(`\n${t}`); + +// In-memory R2: just enough of the binding's surface for uploads.js. +function fakeR2() { + const store = new Map(); + return { + _store: store, + async put(key, bytes, opts) { store.set(key, { bytes, opts }); }, + async get(key) { + const o = store.get(key); + if (!o) return null; + return { + body: o.bytes, + writeHttpMetadata(h) { + if (o.opts?.httpMetadata?.contentType) h.set("content-type", o.opts.httpMetadata.contentType); + }, + }; + }, + }; +} + +const upload = (env, { name, bytes = new Uint8Array([1, 2, 3]), type = "application/octet-stream", length } = {}) => + uploadQuoteFile(new Request("https://x/api/quote/upload", { + method: "POST", + headers: { + "x-file-name": encodeURIComponent(name), + "content-type": type, + ...(length !== undefined ? { "content-length": String(length) } : {}), + "cf-connecting-ip": "1.2.3.4", + }, + body: bytes, + }), env); + +section("filenames"); +ok("keeps a plain name", safeFileName("bracket_v2.stl") === "bracket_v2.stl"); +ok("strips a path", safeFileName("C:\\Users\\me\\part.stl") === "part.stl"); +ok("strips a unix path", safeFileName("../../etc/passwd.stl") === "passwd.stl", safeFileName("../../etc/passwd.stl")); +ok("replaces spaces and unicode", /^[A-Za-z0-9._-]+$/.test(safeFileName("my part (final) ✓.stl"))); +ok("never empty", safeFileName("") === "file" && safeFileName("///") === "file"); +ok("a header-breaking name cannot escape the quotes", !/["\r\n]/.test(safeFileName('a"b\r\n.stl'))); + +section("key shape"); +ok("accepts a minted key", + validFileKey("quotes/2026/3f2b1a9c-1111-4222-8333-444455556666/part.stl")); +for (const k of ["", "quotes/2026/not-a-uuid/part.stl", "quotes/2026/3f2b1a9c-1111-4222-8333-444455556666/../x", + "other/2026/3f2b1a9c-1111-4222-8333-444455556666/part.stl", + "https://litterbox.catbox.moe/x.stl", "javascript:alert(1)"]) { + ok(`rejects ${JSON.stringify(k)}`, !validFileKey(k)); +} +ok("the owner URL goes through the admin route", + quoteFileUrl({ APP_BASE_URL: "https://3d-prints.aswincloud.com/" }, "quotes/2026/a/b.stl") + === "https://3d-prints.aswincloud.com/api/admin/quotes/file?key=quotes%2F2026%2Fa%2Fb.stl"); + +section("upload"); +{ + const env = { UPLOADS: fakeR2() }; + const res = await upload(env, { name: "my bracket.STL", type: "model/stl" }); + const out = await res.json(); + ok("200", res.status === 200, String(res.status)); + ok("returns a key of the minted shape", validFileKey(out.file_key), out.file_key); + ok("the key ends in the safe name", out.file_key.endsWith("/my-bracket.STL"), out.file_key); + ok("returns the safe name", out.file_name === "my-bracket.STL"); + ok("the object is in the bucket", env.UPLOADS._store.has(out.file_key)); + const stored = env.UPLOADS._store.get(out.file_key); + ok("content type kept", stored.opts.httpMetadata.contentType === "model/stl"); + ok("original name kept as metadata", stored.opts.customMetadata.original_name === "my bracket.STL"); + + // Two uploads of the same name never collide. + const again = await (await upload(env, { name: "my bracket.STL" })).json(); + ok("a second upload of the same name gets a different key", again.file_key !== out.file_key); +} + +section("upload — refusals"); +{ + const env = { UPLOADS: fakeR2() }; + for (const name of ["shell.html", "run.exe", "model.svg", "noext", "x.stl.js"]) { + const res = await upload(env, { name }); + ok(`${name} is refused (400)`, res.status === 400, String(res.status)); + } + ok("nothing was stored for a refused type", env.UPLOADS._store.size === 0); + + ok("empty body is refused", (await upload(env, { name: "a.stl", bytes: new Uint8Array(0) })).status === 400); + ok("a declared size over the cap is refused with 413", + (await upload(env, { name: "a.stl", length: MAX_UPLOAD_BYTES + 1 })).status === 413); + ok("cap is 100 MB", MAX_UPLOAD_BYTES === 104857600); + + const noBinding = await upload({}, { name: "a.stl" }); + ok("missing bucket binding → 503, not a crash", noBinding.status === 503); +} + +section("download"); +{ + const env = { UPLOADS: fakeR2() }; + const { file_key } = await (await upload(env, { name: "part.stl", type: "model/stl" })).json(); + + const res = await downloadQuoteFile(env, new URL("https://x/api/admin/quotes/file?key=" + encodeURIComponent(file_key))); + ok("200", res.status === 200, String(res.status)); + ok("served as an attachment, named", res.headers.get("content-disposition") === 'attachment; filename="part.stl"', + res.headers.get("content-disposition")); + ok("content type from the object", res.headers.get("content-type") === "model/stl"); + ok("never cached", res.headers.get("cache-control") === "private, no-store"); + ok("nosniff", res.headers.get("x-content-type-options") === "nosniff"); + + ok("a bad key is 400", (await downloadQuoteFile(env, new URL("https://x/f?key=../etc"))).status === 400); + ok("a missing object is 404", + (await downloadQuoteFile(env, new URL("https://x/f?key=quotes/2026/3f2b1a9c-1111-4222-8333-444455556666/gone.stl"))).status === 404); +} + +section("the storefront no longer talks to a third-party host"); +import { readFileSync } from "node:fs"; +const read = (f) => readFileSync(new URL("../" + f, import.meta.url), "utf8"); +for (const f of ["public/assets/js/main.js", "public/assets/js/quote-modal.js", "src/security.js", "src/index.js"]) { + ok(`${f} has no catbox URL`, !/https?:\/\/[a-z.]*catbox/i.test(read(f))); +} +ok("main.js uploads to /api/quote/upload", /fetch\('\/api\/quote\/upload'/.test(read("public/assets/js/main.js"))); +ok("main.js sends file_key, not file_url", /file_key/.test(read("public/assets/js/main.js")) && !/file_url/.test(read("public/assets/js/main.js"))); +ok("the CSP connect-src is 'self' plus known services only", + /connect-src 'self' \$\{CF_ANALYTICS\}/.test(read("src/security.js"))); + +console.log(`\n uploads: ${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); diff --git a/wrangler.toml b/wrangler.toml index 500b2fd..c9fdccd 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -60,6 +60,15 @@ binding = "DB" database_name = "3dprints-db" database_id = "f553c677-81a3-4d47-a012-9179ddfceb63" +# Quote-form uploads (STL/OBJ/3MF/STEP, photos, PDFs). Private: nothing is +# served from it except through the owner-only route in src/uploads.js. +# Create once with `wrangler r2 bucket create 3dprints-uploads`. Set a lifecycle +# rule on the bucket in the dashboard if files should expire (e.g. 180 days on +# the `quotes/` prefix); there is no rule by default, so they are kept. +[[r2_buckets]] +binding = "UPLOADS" +bucket_name = "3dprints-uploads" + # Rate limiting. Counted at the edge, keyed on CF-Connecting-IP (see # src/security.js). `period` accepts only 10 or 60 — not arbitrary windows — so # the limits below are expressed per-minute.