Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
35 changes: 21 additions & 14 deletions public/assets/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ===== */
Expand Down Expand Up @@ -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.
Expand All @@ -420,10 +424,13 @@ form?.addEventListener('submit', async (e) => {
if (file) {
btn.innerHTML = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="animation:spin 1s linear infinite"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg> 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;
Expand Down
6 changes: 4 additions & 2 deletions public/privacy.html
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,10 @@ <h2>What we collect</h2>
delivery address. We cannot ship without these.</li>
<li><strong>If you create an account:</strong> your email address, and any name, phone
and address you choose to save so checkout can pre-fill them.</li>
<li><strong>If you request a quote:</strong> what you tell us, plus any reference image
you upload.</li>
<li><strong>If you request a quote:</strong> 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.</li>
<li><strong>If you use the live chat:</strong> your messages, and your name and email if
you are signed in.</li>
<li><strong>Automatically:</strong> aggregate, privacy-preserving visit statistics via
Expand Down
31 changes: 24 additions & 7 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(() => ({}))
: {};
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 —
Expand All @@ -562,19 +573,25 @@ 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 };
}

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.
Expand Down
7 changes: 5 additions & 2 deletions src/security.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down Expand Up @@ -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" },
Expand Down
122 changes: 122 additions & 0 deletions src/uploads.js
Original file line number Diff line number Diff line change
@@ -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/<yyyy>/<uuid>/<safe-name>. 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);
Loading
Loading