Skip to main content

max / makenotwork

5.3 KB · 149 lines History Blame Raw
1 // Shared S3 upload primitives, ported from static/upload.js and typed. Adds
2 // `presignPutConfirm`, which unifies the presign→PUT→confirm orchestration that
3 // the legacy upload sites each reimplemented.
4 //
5 // Security note: the client-supplied `content_type` and file are advisory only.
6 // The server's presign/confirm endpoints and the async malware-scan gate remain
7 // authoritative — nothing here should be treated as trusted by the backend.
8
9 import { formatSpeed, formatEta } from './s3.logic.ts';
10 import { csrfHeaders, apiError } from '../../core/net.ts';
11
12 export interface UploaderEls {
13 filenameEl?: HTMLElement | null;
14 percentEl?: HTMLElement | null;
15 progressBar?: HTMLElement | null;
16 speedEl?: HTMLElement | null;
17 }
18
19 /** XHR PUT to a presigned URL with progress/speed/ETA reporting. */
20 export class S3Uploader {
21 #els: UploaderEls;
22 #xhr: XMLHttpRequest | null = null;
23
24 constructor(els: UploaderEls) {
25 this.#els = els;
26 }
27
28 /** Start an upload; resolves to `s3Key` on 2xx. */
29 upload(url: string, file: File, s3Key: string, fallbackContentType?: string, cacheControl?: string): Promise<string> {
30 const els = this.#els;
31 const startTime = Date.now();
32 if (els.filenameEl) els.filenameEl.textContent = file.name;
33 if (els.percentEl) els.percentEl.textContent = '0%';
34 if (els.progressBar) els.progressBar.style.width = '0%';
35 if (els.speedEl) els.speedEl.textContent = '';
36
37 return new Promise((resolve, reject) => {
38 const xhr = new XMLHttpRequest();
39 this.#xhr = xhr;
40
41 xhr.upload.addEventListener('progress', (e) => {
42 if (!e.lengthComputable) return;
43 const percent = Math.round((e.loaded / e.total) * 100);
44 if (els.percentEl) els.percentEl.textContent = percent + '%';
45 if (els.progressBar) els.progressBar.style.width = percent + '%';
46 if (els.speedEl && e.loaded > 0) {
47 const elapsed = (Date.now() - startTime) / 1000;
48 if (elapsed > 0.5) {
49 const speed = e.loaded / elapsed;
50 const remaining = (e.total - e.loaded) / speed;
51 els.speedEl.textContent = `${formatSpeed(speed)} — ${formatEta(remaining)} remaining`;
52 }
53 }
54 });
55
56 xhr.addEventListener('load', () => {
57 this.#xhr = null;
58 if (xhr.status >= 200 && xhr.status < 300) resolve(s3Key);
59 else reject(new Error('Upload failed: ' + xhr.status));
60 });
61 xhr.addEventListener('error', () => {
62 this.#xhr = null;
63 reject(new Error('Network error during upload'));
64 });
65 xhr.addEventListener('abort', () => {
66 this.#xhr = null;
67 reject(new Error('Upload cancelled'));
68 });
69
70 xhr.open('PUT', url);
71 xhr.setRequestHeader('Content-Type', file.type || fallbackContentType || 'application/octet-stream');
72 if (cacheControl) xhr.setRequestHeader('Cache-Control', cacheControl);
73 xhr.send(file);
74 });
75 }
76
77 /** Abort any in-progress upload. */
78 cancel(): void {
79 if (this.#xhr) {
80 this.#xhr.abort();
81 this.#xhr = null;
82 }
83 }
84 }
85
86 /** Wire drag/drop + click-to-pick on a dropzone; `onFile` gets the chosen file. */
87 export function initDropzone(dropzoneEl: HTMLElement, fileInputEl: HTMLInputElement, onFile: (file: File) => void): void {
88 dropzoneEl.addEventListener('dragover', (e) => {
89 e.preventDefault();
90 dropzoneEl.classList.add('dragover');
91 });
92 dropzoneEl.addEventListener('dragleave', () => dropzoneEl.classList.remove('dragover'));
93 dropzoneEl.addEventListener('drop', (e) => {
94 e.preventDefault();
95 dropzoneEl.classList.remove('dragover');
96 const f = e.dataTransfer?.files[0];
97 if (f) onFile(f);
98 });
99 dropzoneEl.addEventListener('click', () => fileInputEl.click());
100 fileInputEl.addEventListener('change', () => {
101 const f = fileInputEl.files?.[0];
102 if (f) onFile(f);
103 });
104 }
105
106 interface PresignResponse {
107 upload_url: string;
108 s3_key: string;
109 cache_control?: string;
110 }
111
112 export interface PresignPutConfirmOpts {
113 presignUrl: string;
114 confirmUrl: string;
115 presignBody: Record<string, unknown>;
116 /** Build the confirm body from the returned s3 key. */
117 confirmBody: (s3Key: string) => Record<string, unknown>;
118 file: File;
119 uploader: S3Uploader;
120 fallbackContentType?: string;
121 }
122
123 /**
124 * The full presign → PUT-to-S3 → confirm round trip, with the server error
125 * message surfaced on failure at each step. The single implementation the
126 * upload sites should share.
127 */
128 export async function presignPutConfirm<T>(opts: PresignPutConfirmOpts): Promise<T> {
129 const presignRes = await fetch(opts.presignUrl, {
130 method: 'POST',
131 headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
132 body: JSON.stringify(opts.presignBody),
133 credentials: 'same-origin',
134 });
135 if (!presignRes.ok) throw new Error(await apiError(presignRes, 'Failed to get upload URL'));
136 const data = (await presignRes.json()) as PresignResponse;
137
138 const s3Key = await opts.uploader.upload(data.upload_url, opts.file, data.s3_key, opts.fallbackContentType, data.cache_control);
139
140 const confirmRes = await fetch(opts.confirmUrl, {
141 method: 'POST',
142 headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
143 body: JSON.stringify(opts.confirmBody(s3Key)),
144 credentials: 'same-origin',
145 });
146 if (!confirmRes.ok) throw new Error(await apiError(confirmRes, 'Failed to confirm upload'));
147 return (await confirmRes.json()) as T;
148 }
149