// Shared S3 upload primitives, ported from static/upload.js and typed. Adds // `presignPutConfirm`, which unifies the presign→PUT→confirm orchestration that // the legacy upload sites each reimplemented. // // Security note: the client-supplied `content_type` and file are advisory only. // The server's presign/confirm endpoints and the async malware-scan gate remain // authoritative — nothing here should be treated as trusted by the backend. import { formatSpeed, formatEta } from './s3.logic.ts'; import { csrfHeaders, apiError } from '../../core/net.ts'; export interface UploaderEls { filenameEl?: HTMLElement | null; percentEl?: HTMLElement | null; progressBar?: HTMLElement | null; speedEl?: HTMLElement | null; } /** XHR PUT to a presigned URL with progress/speed/ETA reporting. */ export class S3Uploader { #els: UploaderEls; #xhr: XMLHttpRequest | null = null; constructor(els: UploaderEls) { this.#els = els; } /** Start an upload; resolves to `s3Key` on 2xx. */ upload(url: string, file: File, s3Key: string, fallbackContentType?: string, cacheControl?: string): Promise { const els = this.#els; const startTime = Date.now(); if (els.filenameEl) els.filenameEl.textContent = file.name; if (els.percentEl) els.percentEl.textContent = '0%'; if (els.progressBar) els.progressBar.style.width = '0%'; if (els.speedEl) els.speedEl.textContent = ''; return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); this.#xhr = xhr; xhr.upload.addEventListener('progress', (e) => { if (!e.lengthComputable) return; const percent = Math.round((e.loaded / e.total) * 100); if (els.percentEl) els.percentEl.textContent = percent + '%'; if (els.progressBar) els.progressBar.style.width = percent + '%'; if (els.speedEl && e.loaded > 0) { const elapsed = (Date.now() - startTime) / 1000; if (elapsed > 0.5) { const speed = e.loaded / elapsed; const remaining = (e.total - e.loaded) / speed; els.speedEl.textContent = `${formatSpeed(speed)} — ${formatEta(remaining)} remaining`; } } }); xhr.addEventListener('load', () => { this.#xhr = null; if (xhr.status >= 200 && xhr.status < 300) resolve(s3Key); else reject(new Error('Upload failed: ' + xhr.status)); }); xhr.addEventListener('error', () => { this.#xhr = null; reject(new Error('Network error during upload')); }); xhr.addEventListener('abort', () => { this.#xhr = null; reject(new Error('Upload cancelled')); }); xhr.open('PUT', url); xhr.setRequestHeader('Content-Type', file.type || fallbackContentType || 'application/octet-stream'); if (cacheControl) xhr.setRequestHeader('Cache-Control', cacheControl); xhr.send(file); }); } /** Abort any in-progress upload. */ cancel(): void { if (this.#xhr) { this.#xhr.abort(); this.#xhr = null; } } } /** Wire drag/drop + click-to-pick on a dropzone; `onFile` gets the chosen file. */ export function initDropzone(dropzoneEl: HTMLElement, fileInputEl: HTMLInputElement, onFile: (file: File) => void): void { dropzoneEl.addEventListener('dragover', (e) => { e.preventDefault(); dropzoneEl.classList.add('dragover'); }); dropzoneEl.addEventListener('dragleave', () => dropzoneEl.classList.remove('dragover')); dropzoneEl.addEventListener('drop', (e) => { e.preventDefault(); dropzoneEl.classList.remove('dragover'); const f = e.dataTransfer?.files[0]; if (f) onFile(f); }); dropzoneEl.addEventListener('click', () => fileInputEl.click()); fileInputEl.addEventListener('change', () => { const f = fileInputEl.files?.[0]; if (f) onFile(f); }); } interface PresignResponse { upload_url: string; s3_key: string; cache_control?: string; } export interface PresignPutConfirmOpts { presignUrl: string; confirmUrl: string; presignBody: Record; /** Build the confirm body from the returned s3 key. */ confirmBody: (s3Key: string) => Record; file: File; uploader: S3Uploader; fallbackContentType?: string; } /** * The full presign → PUT-to-S3 → confirm round trip, with the server error * message surfaced on failure at each step. The single implementation the * upload sites should share. */ export async function presignPutConfirm(opts: PresignPutConfirmOpts): Promise { const presignRes = await fetch(opts.presignUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', ...csrfHeaders() }, body: JSON.stringify(opts.presignBody), credentials: 'same-origin', }); if (!presignRes.ok) throw new Error(await apiError(presignRes, 'Failed to get upload URL')); const data = (await presignRes.json()) as PresignResponse; const s3Key = await opts.uploader.upload(data.upload_url, opts.file, data.s3_key, opts.fallbackContentType, data.cache_control); const confirmRes = await fetch(opts.confirmUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', ...csrfHeaders() }, body: JSON.stringify(opts.confirmBody(s3Key)), credentials: 'same-origin', }); if (!confirmRes.ok) throw new Error(await apiError(confirmRes, 'Failed to confirm upload')); return (await confirmRes.json()) as T; }