const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']; export type PresignedUrlResponse = { upload_url: string; key: string; uuid: string; }; export class UploadError extends Error { constructor(message: string) { super(message); this.name = 'UploadError'; } } function getSessionCookie(): string { return document.cookie.split('; ').find((c) => c.startsWith('XSRF-TOKEN='))?.split('=')[1] ?? ''; } export async function requestPresignedUrl( fileName: string, mimeType: string, folder?: string, ): Promise { const response = await fetch('/api/presigned-url', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'X-XSRF-TOKEN': decodeURIComponent(getSessionCookie()), }, body: JSON.stringify({ file_name: fileName, mime_type: mimeType, folder }), }); if (!response.ok) { const data = await response.json().catch(() => null); throw new UploadError(data?.message ?? 'Gagal membuat URL upload.'); } return response.json(); } export async function uploadToS3(uploadUrl: string, file: File): Promise { const response = await fetch(uploadUrl, { method: 'PUT', body: file, headers: { 'Content-Type': file.type, }, }); if (!response.ok) { throw new UploadError('Gagal mengunggah file ke storage.'); } } export async function uploadFile(file: File, folder?: string): Promise { if (!ALLOWED_TYPES.includes(file.type)) { throw new UploadError('Tipe file tidak didukung. Hanya JPG, PNG, WebP, dan GIF yang diizinkan.'); } if (file.size > MAX_FILE_SIZE) { throw new UploadError('Ukuran file melebihi batas 10MB.'); } const { upload_url, key } = await requestPresignedUrl(file.name, file.type, folder); await uploadToS3(upload_url, file); return key; } export function getTemporaryUrl(key: string, minutes = 60): string { // This is a client-side helper - the actual presigned GET URL // should be generated server-side via the Expense model accessor return `/api/presigned-url/${encodeURIComponent(key)}?minutes=${minutes}`; }