const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
export default {
async fetch(request, env) {
const origin = request.headers.get('Origin') || '';
const allowed = (env.ALLOWED_ORIGINS || '').split(',').map((s) => s.trim()).filter(Boolean);
const originOk = allowed.includes(origin);
if (request.method === 'OPTIONS') {
return preflight(origin, originOk);
}
const url = new URL(request.url);
if (request.method !== 'POST' || url.pathname !== '/submit') {
return json({ error: 'not found' }, 404, origin, originOk);
}
if (!originOk) {
return json({ error: 'origin not allowed' }, 403, origin, false);
}
const maxBytes = parseInt(env.MAX_UPLOAD_BYTES || '52428800', 10);
const declared = parseInt(request.headers.get('Content-Length') || '0', 10);
if (declared > maxBytes) {
return json({ error: 'file too large', max_bytes: maxBytes }, 413, origin, originOk);
}
const ip = request.headers.get('CF-Connecting-IP') || '0.0.0.0';
if (env.UPLOAD_LIMITER) {
const { success } = await env.UPLOAD_LIMITER.limit({ key: ip });
if (!success) {
return json({ error: 'rate limited, try again shortly' }, 429, origin, originOk);
}
}
const token = request.headers.get('X-Turnstile-Token');
if (!token) {
return json({ error: 'missing turnstile token' }, 403, origin, originOk);
}
const verdict = await verifyTurnstile(token, ip, env.TURNSTILE_SECRET);
if (!verdict.success) {
return json(
{ error: 'turnstile verification failed', codes: verdict['error-codes'] || [] },
403, origin, originOk,
);
}
let bytes;
try {
bytes = new Uint8Array(await request.arrayBuffer());
} catch {
return json({ error: 'could not read body' }, 400, origin, originOk);
}
if (bytes.byteLength === 0) {
return json({ error: 'empty body' }, 400, origin, originOk);
}
if (bytes.byteLength > maxBytes) {
return json({ error: 'file too large', max_bytes: maxBytes }, 413, origin, originOk);
}
const rawName = request.headers.get('X-File-Name') || 'unnamed';
const safeName = sanitizeName(rawName);
const context = (request.headers.get('X-Context') || '').slice(0, 500);
const id = crypto.randomUUID();
const day = new Date().toISOString().slice(0, 10);
const key = `submissions/${day}/${id}-${safeName}`;
try {
await env.SUBMISSIONS.put(key, bytes, {
httpMetadata: { contentType: 'application/octet-stream' },
customMetadata: {
original_name: rawName.slice(0, 200),
context,
submitted_at: new Date().toISOString(),
size_bytes: String(bytes.byteLength),
},
});
} catch (err) {
console.error('r2 put failed', { key, message: String(err) });
return json({ error: 'storage failed' }, 500, origin, originOk);
}
console.log('submission stored', { key, size: bytes.byteLength });
return json({ ok: true, id }, 200, origin, originOk);
},
};
async function verifyTurnstile(token, ip, secret) {
if (!secret) {
console.error('TURNSTILE_SECRET is not set');
return { success: false, 'error-codes': ['missing-input-secret'] };
}
try {
const res = await fetch(TURNSTILE_VERIFY_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ secret, response: token, remoteip: ip }),
});
return await res.json();
} catch (err) {
console.error('siteverify request failed', String(err));
return { success: false, 'error-codes': ['internal-error'] };
}
}
function sanitizeName(name) {
const base = name.split(/[/\\]/).pop() || 'unnamed';
const cleaned = base.replace(/[^A-Za-z0-9._-]/g, '_').replace(/^\.+/, '');
return (cleaned || 'unnamed').slice(0, 80);
}
function corsHeaders(origin, originOk) {
if (!originOk) return {};
return {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, X-Turnstile-Token, X-File-Name, X-Context',
'Access-Control-Max-Age': '86400',
Vary: 'Origin',
};
}
function preflight(origin, originOk) {
return new Response(null, { status: originOk ? 204 : 403, headers: corsHeaders(origin, originOk) });
}
function json(body, status, origin, originOk) {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json', ...corsHeaders(origin, originOk) },
});
}