1import { MAX_BYTES, type UploadRow } from "../../src/policy.ts";
2import "./style.css";
3
4const element = <T extends HTMLElement>(selector: string) =>
5 document.querySelector<T>(selector)!;
6const account = element<HTMLFormElement>("#account");
7const upload = element<HTMLFormElement>("#upload");
8const message = element("#message");
9const files = element("#files");
10const empty = element("#empty");
11const signout = element<HTMLButtonElement>("#signout");
12const uploadButton = element<HTMLButtonElement>("#upload-button");
13const apiUrl = import.meta.env.VITE_API_URL?.replace(/\/$/, "");
14const authUrl = import.meta.env.VITE_NEON_AUTH_URL;
15let signedIn = false;
16let generation = 0;
17const show = (text: string, error = false) => {
18 message.textContent = text;
19 message.dataset.error = String(error);
20};
21const explain = (error: unknown) =>
22 show(
23 error instanceof Error
24 ? error.message
25 : "Request failed. Please try again.",
26 true,
27 );
28
29async function start() {
30 if (!apiUrl || !authUrl) {
31 show(
32 "Backend not configured. Deploy the stack, or set VITE_API_URL and VITE_NEON_AUTH_URL before starting Vite. No demo backend is substituted.",
33 true,
34 );
35 account
36 .querySelectorAll("button")
37 .forEach((button) => (button.disabled = true));
38 return;
39 }
40 const { createAuthClient } = await import("@neondatabase/neon-js/auth");
41 const auth = createAuthClient(authUrl);
42 const authBaseUrl = authUrl.replace(/\/$/, "");
43
44 function clearSession() {
45 signedIn = false;
46 generation++;
47 files.replaceChildren();
48 empty.textContent = "Sign in to see your files.";
49 account.hidden = false;
50 signout.hidden = true;
51 uploadButton.disabled = true;
52 element("#identity").textContent = "Signed out";
53 }
54
55 async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
56 // The pinned auth SDK aliases /token to its getSession cache.
57 const tokenResponse = await fetch(`${authBaseUrl}/token`, {
58 credentials: "include",
59 signal: AbortSignal.timeout(15_000),
60 });
61 const data = await tokenResponse.json().catch(() => undefined);
62 if (!tokenResponse.ok || typeof data?.token !== "string" || !data.token) {
63 clearSession();
64 throw new Error("Your session has expired. Sign in again.");
65 }
66 const response = await fetch(`${apiUrl}${path}`, {
67 ...init,
68 headers: {
69 "content-type": "application/json",
70 authorization: `Bearer ${data.token}`,
71 ...init.headers,
72 },
73 signal: AbortSignal.timeout(15_000),
74 });
75 if (response.status === 401) clearSession();
76 if (!response.ok) {
77 const body = await response.json().catch(() => undefined);
78 throw new Error(
79 body?.error ?? `Request failed (HTTP ${response.status}).`,
80 );
81 }
82 return response.json();
83 }
84
85 async function refresh() {
86 if (!signedIn) {
87 show("Sign in to view uploads.");
88 return [];
89 }
90 const current = generation;
91 const rows = await api<UploadRow[]>("/api/uploads");
92 if (current !== generation) return [];
93 files.replaceChildren();
94 empty.textContent = rows.length
95 ? ""
96 : "No uploads yet. Add your first file above.";
97 for (const row of rows) {
98 const item = document.createElement("li");
99 const text = document.createElement("div");
100 const name = document.createElement("strong");
101 name.textContent = row.filename;
102 const status = document.createElement("span");
103 status.className = "file-status";
104 status.textContent =
105 row.status === "ready"
106 ? `Ready · ${row.actual_bytes} bytes`
107 : row.status === "rejected"
108 ? "Rejected · uploaded metadata did not match"
109 : "Awaiting upload event / processing";
110 text.append(name, status);
111 item.append(text);
112 if (row.status === "ready") {
113 const button = document.createElement("button");
114 button.className = "secondary";
115 button.textContent = "Download";
116 button.addEventListener("click", () => {
117 button.disabled = true;
118 api<{ url: string }>(`/api/uploads/${row.id}/download`)
119 .then(({ url }) => {
120 // Navigate directly: the signed GET does not require cross-origin fetch or cookies.
121 const link = document.createElement("a");
122 link.href = url;
123 link.target = "_blank";
124 link.rel = "noopener noreferrer";
125 link.click();
126 show("Opened a private download URL. It expires in 60 seconds.");
127 })
128 .catch(explain)
129 .finally(() => (button.disabled = false));
130 });
131 item.append(button);
132 }
133 files.append(item);
134 }
135 return rows;
136 }
137
138 async function session() {
139 const result = await auth.getSession();
140 if (result.error)
141 throw new Error(result.error.message ?? "Could not read your session.");
142 if (!result.data?.user) {
143 clearSession();
144 return;
145 }
146 signedIn = true;
147 generation++;
148 element("#identity").textContent = `Signed in as ${result.data.user.email}`;
149 account.hidden = true;
150 signout.hidden = false;
151 uploadButton.disabled = false;
152 await refresh();
153 }
154
155 account.addEventListener("submit", (event) => {
156 event.preventDefault();
157 const fields = new FormData(account);
158 const mode = (event.submitter as HTMLButtonElement).value;
159 const input = {
160 name: String(fields.get("name")),
161 email: String(fields.get("email")),
162 password: String(fields.get("password")),
163 };
164 account
165 .querySelectorAll("button")
166 .forEach((button) => (button.disabled = true));
167 show(mode === "signup" ? "Creating your account…" : "Signing in…");
168 const request =
169 mode === "signup" ? auth.signUp.email(input) : auth.signIn.email(input);
170 request
171 .then(async (result) => {
172 if (result.error)
173 throw new Error(result.error.message ?? "Authentication failed.");
174 account.querySelector<HTMLInputElement>('[name="password"]')!.value =
175 "";
176 await session();
177 show(
178 signedIn
179 ? "Signed in. Your files are private."
180 : "Check your email to complete sign-in.",
181 );
182 })
183 .catch(explain)
184 .finally(() =>
185 account
186 .querySelectorAll("button")
187 .forEach((button) => (button.disabled = false)),
188 );
189 });
190
191 signout.addEventListener("click", () => {
192 signout.disabled = true;
193 auth
194 .signOut()
195 .then((result) => {
196 if (result.error)
197 throw new Error(result.error.message ?? "Signout failed.");
198 clearSession();
199 show("Signed out. Your session has ended.");
200 })
201 .catch(explain)
202 .finally(() => (signout.disabled = false));
203 });
204
205 upload.addEventListener("submit", (event) => {
206 event.preventDefault();
207 const file = element<HTMLInputElement>('[name="file"]').files?.[0];
208 if (!file || file.size < 1 || file.size > MAX_BYTES) {
209 show("Choose a nonempty file up to 10 MiB.", true);
210 return;
211 }
212 uploadButton.disabled = true;
213 const current = generation;
214 const run = async () => {
215 show("Requesting a short-lived upload URL…");
216 const signed = await api<{
217 id: string;
218 url: string;
219 contentType: string;
220 }>("/api/uploads", {
221 method: "POST",
222 body: JSON.stringify({
223 filename: file.name,
224 contentType: file.type || "application/octet-stream",
225 size: file.size,
226 }),
227 });
228 show("Uploading directly to private storage…");
229 const response = await fetch(signed.url, {
230 method: "PUT",
231 body: file,
232 headers: { "content-type": signed.contentType },
233 signal: AbortSignal.timeout(30_000),
234 });
235 if (!response.ok)
236 throw new Error(
237 `Storage upload failed (HTTP ${response.status}). Request a new upload URL by trying again.`,
238 );
239 upload.reset();
240 show("Uploaded. Waiting for the real bucket event…");
241 for (let attempt = 0; attempt < 9 && current === generation; attempt++) {
242 const rows = await refresh();
243 const row = rows.find((value) => value.id === signed.id);
244 if (row?.status === "ready") {
245 show("Processed successfully. Your download is ready.");
246 return;
247 }
248 if (row?.status === "rejected")
249 throw new Error(
250 "The uploaded file did not match its declared metadata.",
251 );
252 if (attempt < 8)
253 await new Promise((resolve) => setTimeout(resolve, 3000));
254 }
255 if (current === generation)
256 show(
257 "The upload event is still pending. Use Refresh status to check again; no processing success has been assumed.",
258 );
259 };
260 run()
261 .catch(explain)
262 .finally(() => (uploadButton.disabled = !signedIn));
263 });
264 element("#refresh").addEventListener("click", () => {
265 refresh().catch(explain);
266 });
267 await session();
268 show(
269 signedIn
270 ? "Welcome back. Your upload journal is ready."
271 : "Create an account or sign in to start.",
272 );
273}
274
275start().catch(explain);