1import {
2 GetObjectCommand,
3 HeadObjectCommand,
4 PutObjectCommand,
5 S3Client,
6} from "@aws-sdk/client-s3";
7import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
8import { neon } from "@neondatabase/serverless";
9import { makeAuthenticate } from "./authenticate.ts";
10import { corsHeaders, objectKey, parseUpload, UUID } from "./policy.ts";
11
12const sql = neon(process.env.DATABASE_URL!);
13const bucket = process.env.UPLOAD_BUCKET!;
14const storage = new S3Client({
15 endpoint: process.env.AWS_ENDPOINT_URL_S3!,
16 region: process.env.AWS_REGION!,
17 forcePathStyle: true,
18 credentials: {
19 accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
20 secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
21 },
22});
23const authenticate = makeAuthenticate(
24 process.env.AUTH_URL!,
25 process.env.AUTH_JWKS_URL!,
26);
27
28async function processUpload(request: Request) {
29 if (request.method !== "POST") return new Response(null, { status: 405 });
30 const invocation = request.headers.get("x-neon-trigger-invocation-id");
31 if (!invocation)
32 return new Response("Missing Neon trigger attestation", { status: 403 });
33 const event = await request.json().catch(() => undefined);
34 if (
35 event?.version !== 1 ||
36 event?.trigger?.type !== "storage_object_created" ||
37 event?.trigger?.name !== (process.env.TRIGGER_NAME ?? "ProcessUploads") ||
38 event?.data?.bucket_name !== bucket ||
39 typeof event?.data?.object_key !== "string" ||
40 !event.data.object_key.startsWith("incoming/")
41 ) {
42 return new Response("Invalid object event", { status: 400 });
43 }
44 if (event.invocation_id !== invocation)
45 return new Response("Invocation mismatch", { status: 403 });
46 // Neon strips caller-supplied X-Neon-* headers at its edge. This is not a local-server trust boundary.
47 const object = await storage.send(
48 new HeadObjectCommand({ Bucket: bucket, Key: event.data.object_key }),
49 );
50 const bytes = object.ContentLength ?? 0;
51 await sql`
52 WITH delivery AS (
53 INSERT INTO upload_events (invocation_id, object_key)
54 VALUES (${invocation}, ${event.data.object_key})
55 ON CONFLICT DO NOTHING RETURNING invocation_id
56 )
57 UPDATE uploads SET actual_bytes = ${bytes}, processed_at = now(),
58 status = CASE WHEN expected_bytes = ${bytes} AND content_type = ${object.ContentType ?? ""}
59 THEN 'ready' ELSE 'rejected' END
60 WHERE object_key = ${event.data.object_key} AND EXISTS (SELECT 1 FROM delivery)
61 `;
62 return new Response(null, { status: 204 });
63}
64
65export default {
66 async fetch(request: Request) {
67 const path = new URL(request.url).pathname;
68 const headers = corsHeaders(
69 request.headers.get("origin"),
70 process.env.APP_ORIGIN,
71 );
72 try {
73 if (path === "/jobs/upload") return await processUpload(request);
74 if (!headers) return new Response("Untrusted origin", { status: 403 });
75 const respond = (value: unknown, status = 200) =>
76 Response.json(value, { status, headers });
77 if (request.method === "OPTIONS")
78 return new Response(null, { status: 204, headers });
79 if (path === "/health") return respond({ ok: true, runtime: "native" });
80 const owner = await authenticate(request.headers.get("authorization"));
81 if (!owner)
82 return respond(
83 {
84 error: "Sign in again: your token is missing, invalid, or expired.",
85 },
86 401,
87 );
88 if (path === "/api/me" && request.method === "GET")
89 return respond({ userId: owner });
90 if (path === "/api/settings" && request.method === "GET") {
91 const settings = await storage.send(
92 new GetObjectCommand({ Bucket: bucket, Key: "config/settings.json" }),
93 );
94 return respond(
95 JSON.parse((await settings.Body?.transformToString()) ?? "null"),
96 );
97 }
98 if (path === "/api/uploads" && request.method === "GET") {
99 return respond(
100 await sql`
101 SELECT id, filename, object_key, content_type, expected_bytes, actual_bytes, status, created_at
102 FROM uploads WHERE owner_id = ${owner} ORDER BY created_at DESC LIMIT 100
103 `,
104 );
105 }
106 if (path === "/api/uploads" && request.method === "POST") {
107 const input = parseUpload(await request.json().catch(() => undefined));
108 if (!input)
109 return respond(
110 {
111 error:
112 "Choose a nonempty file up to 10 MiB with a valid content type.",
113 },
114 400,
115 );
116 const id = crypto.randomUUID();
117 const key = objectKey(owner, id);
118 const url = await getSignedUrl(
119 storage,
120 new PutObjectCommand({
121 Bucket: bucket,
122 Key: key,
123 ContentType: input.contentType,
124 }),
125 { expiresIn: 120 },
126 );
127 await sql`
128 INSERT INTO uploads (id, owner_id, object_key, filename, content_type, expected_bytes)
129 VALUES (${id}, ${owner}, ${key}, ${input.filename}, ${input.contentType}, ${input.size})
130 `;
131 return respond({ id, url, contentType: input.contentType }, 201);
132 }
133 const match = /^\/api\/uploads\/([^/]+)\/download$/.exec(path);
134 if (match && request.method === "GET") {
135 if (!UUID.test(match[1]!)) return respond({ error: "Not found" }, 404);
136 const [row] =
137 await sql`SELECT * FROM uploads WHERE id = ${match[1]!} AND owner_id = ${owner}`;
138 if (!row) return respond({ error: "Not found" }, 404);
139 if (row.status !== "ready")
140 return respond(
141 { error: "This upload is not ready to download." },
142 409,
143 );
144 const url = await getSignedUrl(
145 storage,
146 new GetObjectCommand({ Bucket: bucket, Key: row.object_key }),
147 { expiresIn: 60 },
148 );
149 return respond({ url });
150 }
151 return respond({ error: "Not found" }, 404);
152 } catch {
153 console.error("Upload API request failed");
154 return new Response("Upload service unavailable. Retry the request.", {
155 status: 500,
156 headers,
157 });
158 }
159 },
160};