{"version":3,"file":"upload-url.mjs","names":["path"],"sources":["../../../../../src/astro/routes/api/media/upload-url.ts"],"sourcesContent":["/**\n * Media upload URL endpoint\n *\n * POST /_emdash/api/media/upload-url\n *\n * Returns a signed URL for direct upload to storage.\n * Creates a pending media record that must be confirmed after upload.\n */\n\nimport * as path from \"node:path\";\n\nimport type { APIRoute } from \"astro\";\nimport { MediaRepository } from \"@premium-cms/emdash\";\nimport { ulid } from \"ulidx\";\n\nimport { requirePerm } from \"#api/authorize.js\";\nimport { apiError, apiSuccess, handleError } from \"#api/error.js\";\nimport { GLOBAL_UPLOAD_ALLOWLIST, resolveFieldAllowlist } from \"#api/handlers/media-allowlist.js\";\nimport { isParseError, parseBody } from \"#api/parse.js\";\nimport { DEFAULT_MAX_UPLOAD_SIZE, mediaUploadUrlBody } from \"#api/schemas.js\";\nimport { matchesMimeAllowlist, normalizeMime } from \"#media/mime.js\";\n\nexport const prerender = false;\n\ninterface UploadUrlResponse {\n\tuploadUrl: string;\n\tmethod: \"PUT\";\n\theaders: Record<string, string>;\n\tmediaId: string;\n\tstorageKey: string;\n\texpiresAt: string;\n}\n\n/** Response when content already exists (deduplication) */\ninterface ExistingMediaResponse {\n\texisting: true;\n\tmediaId: string;\n\tstorageKey: string;\n\turl: string;\n}\n\nfunction isUnsupportedSignedUpload(error: unknown): boolean {\n\treturn error instanceof Error && \"code\" in error && error.code === \"NOT_SUPPORTED\";\n}\n\n/**\n * Get a signed upload URL for direct-to-storage upload\n */\nexport const POST: APIRoute = async ({ request, locals }) => {\n\tconst { emdash, user } = locals;\n\n\tconst denied = requirePerm(user, \"media:upload\");\n\tif (denied) return denied;\n\n\tif (!emdash?.storage) {\n\t\treturn apiError(\n\t\t\t\"NO_STORAGE\",\n\t\t\t\"Storage not configured. Signed URL uploads require S3-compatible storage.\",\n\t\t\t501,\n\t\t);\n\t}\n\n\tif (!emdash?.db) {\n\t\treturn apiError(\"NOT_CONFIGURED\", \"EmDash is not initialized\", 500);\n\t}\n\n\ttry {\n\t\tconst maxSize = emdash.config.maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE;\n\t\tif (!Number.isFinite(maxSize) || maxSize <= 0) {\n\t\t\treturn apiError(\n\t\t\t\t\"CONFIGURATION_ERROR\",\n\t\t\t\t\"Invalid maxUploadSize configuration. Expected a positive finite number.\",\n\t\t\t\t500,\n\t\t\t);\n\t\t}\n\t\tconst body = await parseBody(request, mediaUploadUrlBody(maxSize));\n\t\tif (isParseError(body)) return body;\n\t\tconst normalizedContentType = normalizeMime(body.contentType);\n\n\t\t// Validate content type (field-aware widening)\n\t\tconst fieldAllowlist = body.fieldId\n\t\t\t? await resolveFieldAllowlist(emdash.db, body.fieldId)\n\t\t\t: null;\n\t\tconst allowlist = fieldAllowlist ?? [...GLOBAL_UPLOAD_ALLOWLIST];\n\n\t\tif (!matchesMimeAllowlist(body.contentType, allowlist)) {\n\t\t\treturn apiError(\"INVALID_TYPE\", \"File type not allowed\", 400);\n\t\t}\n\n\t\tconst repo = new MediaRepository(emdash.db);\n\n\t\t// Check for existing content with same hash (deduplication)\n\t\tif (body.contentHash && body.size > 0) {\n\t\t\tconst existing = await repo.findByContentHash(body.contentHash);\n\t\t\tif (existing && existing.mimeType === normalizedContentType && existing.size === body.size) {\n\t\t\t\tconst response: ExistingMediaResponse = {\n\t\t\t\t\texisting: true,\n\t\t\t\t\tmediaId: existing.id,\n\t\t\t\t\tstorageKey: existing.storageKey,\n\t\t\t\t\turl: `/_emdash/api/media/file/${existing.storageKey}`,\n\t\t\t\t};\n\t\t\t\treturn apiSuccess(response);\n\t\t\t}\n\t\t}\n\n\t\t// Generate unique storage key\n\t\tconst id = ulid();\n\t\tconst ext = path.extname(body.filename) || \"\";\n\t\tconst storageKey = `${id}${ext}`;\n\n\t\tlet signedUrl: Awaited<ReturnType<typeof emdash.storage.getSignedUploadUrl>> | null;\n\t\ttry {\n\t\t\tsignedUrl = await emdash.storage.getSignedUploadUrl({\n\t\t\t\tkey: storageKey,\n\t\t\t\tcontentType: body.contentType,\n\t\t\t\tsize: body.size,\n\t\t\t\texpiresIn: 3600,\n\t\t\t});\n\t\t} catch (error) {\n\t\t\tif (!isUnsupportedSignedUpload(error)) throw error;\n\t\t\tsignedUrl = null;\n\t\t}\n\n\t\tconst mediaItem = await repo.createPending({\n\t\t\tfilename: body.filename,\n\t\t\tmimeType: normalizedContentType,\n\t\t\tsize: body.size,\n\t\t\tstorageKey,\n\t\t\tauthorId: user?.id,\n\t\t});\n\n\t\tconst response: UploadUrlResponse = {\n\t\t\tuploadUrl: signedUrl?.url ?? `/_emdash/api/media/${mediaItem.id}/upload`,\n\t\t\tmethod: signedUrl?.method ?? \"PUT\",\n\t\t\theaders: signedUrl?.headers ?? {\n\t\t\t\t\"Content-Type\": normalizedContentType,\n\t\t\t\t\"X-EmDash-Request\": \"1\",\n\t\t\t},\n\t\t\tmediaId: mediaItem.id,\n\t\t\tstorageKey,\n\t\t\texpiresAt: signedUrl?.expiresAt ?? new Date(Date.now() + 3600 * 1000).toISOString(),\n\t\t};\n\n\t\treturn apiSuccess(response);\n\t} catch (error) {\n\t\treturn handleError(error, \"Failed to generate upload URL\", \"UPLOAD_URL_ERROR\");\n\t}\n};\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AAsBA,MAAa,YAAY;AAmBzB,SAAS,0BAA0B,OAAyB;AAC3D,QAAO,iBAAiB,SAAS,UAAU,SAAS,MAAM,SAAS;;;;;AAMpE,MAAa,OAAiB,OAAO,EAAE,SAAS,aAAa;CAC5D,MAAM,EAAE,QAAQ,SAAS;CAEzB,MAAM,SAAS,YAAY,MAAM,eAAe;AAChD,KAAI,OAAQ,QAAO;AAEnB,KAAI,CAAC,QAAQ,QACZ,QAAO,SACN,cACA,6EACA,IACA;AAGF,KAAI,CAAC,QAAQ,GACZ,QAAO,SAAS,kBAAkB,6BAA6B,IAAI;AAGpE,KAAI;EACH,MAAM,UAAU,OAAO,OAAO,iBAAiB;AAC/C,MAAI,CAAC,OAAO,SAAS,QAAQ,IAAI,WAAW,EAC3C,QAAO,SACN,uBACA,2EACA,IACA;EAEF,MAAM,OAAO,MAAM,UAAU,SAAS,mBAAmB,QAAQ,CAAC;AAClE,MAAI,aAAa,KAAK,CAAE,QAAO;EAC/B,MAAM,wBAAwB,cAAc,KAAK,YAAY;EAM7D,MAAM,aAHiB,KAAK,UACzB,MAAM,sBAAsB,OAAO,IAAI,KAAK,QAAQ,GACpD,SACiC,CAAC,GAAG,wBAAwB;AAEhE,MAAI,CAAC,qBAAqB,KAAK,aAAa,UAAU,CACrD,QAAO,SAAS,gBAAgB,yBAAyB,IAAI;EAG9D,MAAM,OAAO,IAAI,gBAAgB,OAAO,GAAG;AAG3C,MAAI,KAAK,eAAe,KAAK,OAAO,GAAG;GACtC,MAAM,WAAW,MAAM,KAAK,kBAAkB,KAAK,YAAY;AAC/D,OAAI,YAAY,SAAS,aAAa,yBAAyB,SAAS,SAAS,KAAK,KAOrF,QAAO,WANiC;IACvC,UAAU;IACV,SAAS,SAAS;IAClB,YAAY,SAAS;IACrB,KAAK,2BAA2B,SAAS;IACzC,CAC0B;;EAO7B,MAAM,aAAa,GAFR,MAAM,GACLA,OAAK,QAAQ,KAAK,SAAS,IAAI;EAG3C,IAAI;AACJ,MAAI;AACH,eAAY,MAAM,OAAO,QAAQ,mBAAmB;IACnD,KAAK;IACL,aAAa,KAAK;IAClB,MAAM,KAAK;IACX,WAAW;IACX,CAAC;WACM,OAAO;AACf,OAAI,CAAC,0BAA0B,MAAM,CAAE,OAAM;AAC7C,eAAY;;EAGb,MAAM,YAAY,MAAM,KAAK,cAAc;GAC1C,UAAU,KAAK;GACf,UAAU;GACV,MAAM,KAAK;GACX;GACA,UAAU,MAAM;GAChB,CAAC;AAcF,SAAO,WAZ6B;GACnC,WAAW,WAAW,OAAO,sBAAsB,UAAU,GAAG;GAChE,QAAQ,WAAW,UAAU;GAC7B,SAAS,WAAW,WAAW;IAC9B,gBAAgB;IAChB,oBAAoB;IACpB;GACD,SAAS,UAAU;GACnB;GACA,WAAW,WAAW,aAAa,IAAI,KAAK,KAAK,KAAK,GAAG,OAAO,IAAK,CAAC,aAAa;GACnF,CAE0B;UACnB,OAAO;AACf,SAAO,YAAY,OAAO,iCAAiC,mBAAmB"}