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.