{"version":3,"file":"confirm.mjs","names":[],"sources":["../../../../../../src/astro/routes/api/media/[id]/confirm.ts"],"sourcesContent":["/**\n * Confirm media upload endpoint\n *\n * POST /_emdash/api/media/{id}/confirm\n *\n * Confirms that the client has successfully uploaded the file to storage.\n * Marks the media record as ready and optionally updates metadata.\n */\n\nimport type { APIRoute } from \"astro\";\nimport type { DownloadResult } from \"@premium-cms/emdash\";\n\nimport { requireOwnerPerm, requirePerm } from \"#api/authorize.js\";\nimport { apiError, apiSuccess, handleError } from \"#api/error.js\";\nimport { isParseError, parseOptionalBody } from \"#api/parse.js\";\nimport { mediaConfirmBody } from \"#api/schemas.js\";\nimport { MediaRepository } from \"#db/repositories/media.js\";\nimport { enrichImageMetadata } from \"#media/enrich.js\";\nimport type { MediaItem } from \"#types\";\nimport { computeContentHash, MAX_CONTENT_HASH_BYTES } from \"#utils/hash.js\";\n\nexport const prerender = false;\n\n/**\n * Max raw bytes to buffer for server-side LQIP generation at confirm time. The\n * signed-URL upload flow exists so large files bypass server buffering — re-reading\n * the whole object into a Worker's 128 MB heap to compute a blurhash would OOM\n * on the very uploads that flow was designed for. LQIP is progressive\n * enhancement: large images simply ship without a server-generated placeholder.\n */\nconst MAX_PLACEHOLDER_DOWNLOAD_BYTES = MAX_CONTENT_HASH_BYTES;\n\n/**\n * Add URL to media item (relative URL for portability)\n */\nfunction addUrlToMedia(item: MediaItem): MediaItem & { url: string } {\n\treturn {\n\t\t...item,\n\t\turl: `/_emdash/api/media/file/${item.storageKey}`,\n\t};\n}\n\nasync function cancelDownload(download: DownloadResult): Promise<void> {\n\ttry {\n\t\tawait download.body.cancel();\n\t} catch (error) {\n\t\tconsole.error(\"[media] confirm download cancellation failed:\", error);\n\t}\n}\n\nasync function forgetUploadAttempt(repo: MediaRepository, storageKey: string): Promise<void> {\n\ttry {\n\t\tawait repo.deleteUploadAttempt(storageKey);\n\t} catch (error) {\n\t\tconsole.error(\"[media] confirm upload attempt cleanup failed:\", error);\n\t}\n}\n\nasync function confirmationConflict(repo: MediaRepository, id: string): Promise<Response> {\n\tconst current = await repo.findById(id);\n\tif (!current) {\n\t\treturn apiError(\"NOT_FOUND\", `Media item not found: ${id}`, 404);\n\t}\n\tif (current.status === \"ready\") {\n\t\tawait forgetUploadAttempt(repo, current.storageKey);\n\t\treturn apiSuccess({ item: addUrlToMedia(current) });\n\t}\n\tif (current.status === \"pending\") {\n\t\treturn apiError(\"INVALID_STATE\", \"Media item changed during confirmation\", 409);\n\t}\n\treturn apiError(\"INVALID_STATE\", `Media item is not pending: ${current.status}`, 400);\n}\n\nasync function consumeDownload(download: DownloadResult): Promise<Uint8Array> {\n\tconst reader = download.body.getReader();\n\ttry {\n\t\tconst bytes = new Uint8Array(download.size);\n\t\tlet receivedSize = 0;\n\t\twhile (true) {\n\t\t\tconst { done, value } = await reader.read();\n\t\t\tif (done) break;\n\t\t\tif (receivedSize + value.byteLength > bytes.byteLength) {\n\t\t\t\tthrow new Error(\"Stored file exceeds its reported size\");\n\t\t\t}\n\t\t\tbytes.set(value, receivedSize);\n\t\t\treceivedSize += value.byteLength;\n\t\t}\n\t\tif (receivedSize !== download.size) {\n\t\t\tthrow new Error(\"Stored file size does not match its reported size\");\n\t\t}\n\t\treturn bytes;\n\t} catch (error) {\n\t\ttry {\n\t\t\tawait reader.cancel(error);\n\t\t} catch (cancelError) {\n\t\t\tconsole.error(\"[media] confirm download cancellation failed:\", cancelError);\n\t\t}\n\t\tthrow error;\n\t} finally {\n\t\treader.releaseLock();\n\t}\n}\n\n/**\n * Confirm upload completion\n */\nexport const POST: APIRoute = async ({ params, request, locals }) => {\n\tconst { emdash, user } = locals;\n\tconst { id } = params;\n\n\tconst denied = requirePerm(user, \"media:upload\");\n\tif (denied) return denied;\n\n\tif (!id) {\n\t\treturn apiError(\"INVALID_REQUEST\", \"Media ID is required\", 400);\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 body = await parseOptionalBody(request, mediaConfirmBody, {});\n\t\tif (isParseError(body)) return body;\n\n\t\tconst repo = new MediaRepository(emdash.db);\n\n\t\t// Get the media item first to check status\n\t\tconst existing = await repo.findById(id);\n\t\tif (!existing) {\n\t\t\treturn apiError(\"NOT_FOUND\", `Media item not found: ${id}`, 404);\n\t\t}\n\n\t\t// Only the uploader or a user with media:edit_any can confirm/fail a pending upload\n\t\tconst ownerDenied = requireOwnerPerm(\n\t\t\tuser,\n\t\t\texisting.authorId ?? \"\",\n\t\t\t\"media:upload\",\n\t\t\t\"media:edit_any\",\n\t\t);\n\t\tif (ownerDenied) return ownerDenied;\n\n\t\tif (existing.status === \"ready\") {\n\t\t\tawait forgetUploadAttempt(repo, existing.storageKey);\n\t\t\treturn apiSuccess({ item: addUrlToMedia(existing) });\n\t\t}\n\t\tif (existing.status !== \"pending\") {\n\t\t\treturn apiError(\"INVALID_STATE\", `Media item is not pending: ${existing.status}`, 400);\n\t\t}\n\n\t\tif (body.size !== undefined && existing.size !== null && body.size !== existing.size) {\n\t\t\treturn apiError(\n\t\t\t\t\"UPLOAD_SIZE_MISMATCH\",\n\t\t\t\t\"Confirmed size does not match the pending media item\",\n\t\t\t\t400,\n\t\t\t);\n\t\t}\n\n\t\tlet confirmedSize = existing.size ?? body.size;\n\t\tlet contentHash = existing.contentHash;\n\t\tlet imageBytes: Uint8Array | undefined;\n\n\t\tif (emdash.storage) {\n\t\t\tconst exists = await emdash.storage.exists(existing.storageKey);\n\t\t\tif (!exists) {\n\t\t\t\tconst failed = await repo.markFailed(id, existing.storageKey);\n\t\t\t\tif (!failed) return await confirmationConflict(repo, id);\n\t\t\t\treturn apiError(\"FILE_NOT_FOUND\", \"File was not uploaded to storage\", 400);\n\t\t\t}\n\n\t\t\tconst storedFile = await emdash.storage.download(existing.storageKey);\n\t\t\tif (confirmedSize !== undefined && storedFile.size !== confirmedSize) {\n\t\t\t\tawait cancelDownload(storedFile);\n\t\t\t\treturn apiError(\n\t\t\t\t\t\"UPLOAD_SIZE_MISMATCH\",\n\t\t\t\t\t\"Stored file size does not match the pending media item\",\n\t\t\t\t\t400,\n\t\t\t\t);\n\t\t\t}\n\t\t\tconfirmedSize = storedFile.size;\n\n\t\t\tconst isImage = existing.mimeType.startsWith(\"image/\");\n\t\t\tconst canBuffer = storedFile.size <= MAX_PLACEHOLDER_DOWNLOAD_BYTES;\n\t\t\tconst hasServerHash =\n\t\t\t\tcontentHash !== null && (await repo.hasUploadAttempt(existing.storageKey));\n\t\t\tif (canBuffer && (isImage || !hasServerHash)) {\n\t\t\t\tconst bytes = await consumeDownload(storedFile);\n\t\t\t\tcontentHash = bytes.byteLength > 0 ? await computeContentHash(bytes) : null;\n\t\t\t\tif (isImage && bytes.byteLength > 0) imageBytes = bytes;\n\t\t\t} else {\n\t\t\t\tif (!hasServerHash) contentHash = null;\n\t\t\t\tawait cancelDownload(storedFile);\n\t\t\t}\n\n\t\t\tif (isImage && !canBuffer) {\n\t\t\t\tconsole.warn(\n\t\t\t\t\t`[media] confirm skipping placeholder: object ${existing.storageKey} reported size ${storedFile.size} bytes (> ${MAX_PLACEHOLDER_DOWNLOAD_BYTES})`,\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\n\t\t// LQIP is best-effort; oversized images skip server-side placeholders.\n\t\tlet blurhash: string | undefined;\n\t\tlet dominantColor: string | undefined;\n\t\tlet width = body.width;\n\t\tlet height = body.height;\n\t\tif (imageBytes) {\n\t\t\ttry {\n\t\t\t\tconst enriched = await enrichImageMetadata(imageBytes, existing.mimeType, {\n\t\t\t\t\tknownDimensions:\n\t\t\t\t\t\tbody.width != null && body.height != null\n\t\t\t\t\t\t\t? { width: body.width, height: body.height }\n\t\t\t\t\t\t\t: undefined,\n\t\t\t\t});\n\t\t\t\tblurhash = enriched.blurhash;\n\t\t\t\tdominantColor = enriched.dominantColor;\n\t\t\t\twidth = width ?? enriched.width;\n\t\t\t\theight = height ?? enriched.height;\n\t\t\t} catch (error) {\n\t\t\t\tconsole.error(\"[media] confirm placeholder generation failed:\", error);\n\t\t\t}\n\t\t}\n\n\t\t// Confirm the upload\n\t\tconst item = await repo.confirmUpload(\n\t\t\tid,\n\t\t\t{\n\t\t\t\tsize: confirmedSize,\n\t\t\t\twidth,\n\t\t\t\theight,\n\t\t\t\tblurhash,\n\t\t\t\tdominantColor,\n\t\t\t\tcontentHash,\n\t\t\t},\n\t\t\texisting.storageKey,\n\t\t);\n\n\t\tif (!item) {\n\t\t\treturn await confirmationConflict(repo, id);\n\t\t}\n\n\t\tawait forgetUploadAttempt(repo, item.storageKey);\n\n\t\t// Add URL to the response (relative URL for portability)\n\t\tconst itemWithUrl = addUrlToMedia(item);\n\n\t\treturn apiSuccess({ item: itemWithUrl });\n\t} catch (error) {\n\t\treturn handleError(error, \"Failed to confirm upload\", \"CONFIRM_ERROR\");\n\t}\n};\n"],"mappings":";;;;;;;;;;;;;;;;;AAqBA,MAAa,YAAY;;;;;;;;AASzB,MAAM,iCAAiC;;;;AAKvC,SAAS,cAAc,MAA8C;AACpE,QAAO;EACN,GAAG;EACH,KAAK,2BAA2B,KAAK;EACrC;;AAGF,eAAe,eAAe,UAAyC;AACtE,KAAI;AACH,QAAM,SAAS,KAAK,QAAQ;UACpB,OAAO;AACf,UAAQ,MAAM,iDAAiD,MAAM;;;AAIvE,eAAe,oBAAoB,MAAuB,YAAmC;AAC5F,KAAI;AACH,QAAM,KAAK,oBAAoB,WAAW;UAClC,OAAO;AACf,UAAQ,MAAM,kDAAkD,MAAM;;;AAIxE,eAAe,qBAAqB,MAAuB,IAA+B;CACzF,MAAM,UAAU,MAAM,KAAK,SAAS,GAAG;AACvC,KAAI,CAAC,QACJ,QAAO,SAAS,aAAa,yBAAyB,MAAM,IAAI;AAEjE,KAAI,QAAQ,WAAW,SAAS;AAC/B,QAAM,oBAAoB,MAAM,QAAQ,WAAW;AACnD,SAAO,WAAW,EAAE,MAAM,cAAc,QAAQ,EAAE,CAAC;;AAEpD,KAAI,QAAQ,WAAW,UACtB,QAAO,SAAS,iBAAiB,0CAA0C,IAAI;AAEhF,QAAO,SAAS,iBAAiB,8BAA8B,QAAQ,UAAU,IAAI;;AAGtF,eAAe,gBAAgB,UAA+C;CAC7E,MAAM,SAAS,SAAS,KAAK,WAAW;AACxC,KAAI;EACH,MAAM,QAAQ,IAAI,WAAW,SAAS,KAAK;EAC3C,IAAI,eAAe;AACnB,SAAO,MAAM;GACZ,MAAM,EAAE,MAAM,UAAU,MAAM,OAAO,MAAM;AAC3C,OAAI,KAAM;AACV,OAAI,eAAe,MAAM,aAAa,MAAM,WAC3C,OAAM,IAAI,MAAM,wCAAwC;AAEzD,SAAM,IAAI,OAAO,aAAa;AAC9B,mBAAgB,MAAM;;AAEvB,MAAI,iBAAiB,SAAS,KAC7B,OAAM,IAAI,MAAM,oDAAoD;AAErE,SAAO;UACC,OAAO;AACf,MAAI;AACH,SAAM,OAAO,OAAO,MAAM;WAClB,aAAa;AACrB,WAAQ,MAAM,iDAAiD,YAAY;;AAE5E,QAAM;WACG;AACT,SAAO,aAAa;;;;;;AAOtB,MAAa,OAAiB,OAAO,EAAE,QAAQ,SAAS,aAAa;CACpE,MAAM,EAAE,QAAQ,SAAS;CACzB,MAAM,EAAE,OAAO;CAEf,MAAM,SAAS,YAAY,MAAM,eAAe;AAChD,KAAI,OAAQ,QAAO;AAEnB,KAAI,CAAC,GACJ,QAAO,SAAS,mBAAmB,wBAAwB,IAAI;AAGhE,KAAI,CAAC,QAAQ,GACZ,QAAO,SAAS,kBAAkB,6BAA6B,IAAI;AAGpE,KAAI;EACH,MAAM,OAAO,MAAM,kBAAkB,SAAS,kBAAkB,EAAE,CAAC;AACnE,MAAI,aAAa,KAAK,CAAE,QAAO;EAE/B,MAAM,OAAO,IAAI,gBAAgB,OAAO,GAAG;EAG3C,MAAM,WAAW,MAAM,KAAK,SAAS,GAAG;AACxC,MAAI,CAAC,SACJ,QAAO,SAAS,aAAa,yBAAyB,MAAM,IAAI;EAIjE,MAAM,cAAc,iBACnB,MACA,SAAS,YAAY,IACrB,gBACA,iBACA;AACD,MAAI,YAAa,QAAO;AAExB,MAAI,SAAS,WAAW,SAAS;AAChC,SAAM,oBAAoB,MAAM,SAAS,WAAW;AACpD,UAAO,WAAW,EAAE,MAAM,cAAc,SAAS,EAAE,CAAC;;AAErD,MAAI,SAAS,WAAW,UACvB,QAAO,SAAS,iBAAiB,8BAA8B,SAAS,UAAU,IAAI;AAGvF,MAAI,KAAK,SAAS,UAAa,SAAS,SAAS,QAAQ,KAAK,SAAS,SAAS,KAC/E,QAAO,SACN,wBACA,wDACA,IACA;EAGF,IAAI,gBAAgB,SAAS,QAAQ,KAAK;EAC1C,IAAI,cAAc,SAAS;EAC3B,IAAI;AAEJ,MAAI,OAAO,SAAS;AAEnB,OAAI,CADW,MAAM,OAAO,QAAQ,OAAO,SAAS,WAAW,EAClD;AAEZ,QAAI,CADW,MAAM,KAAK,WAAW,IAAI,SAAS,WAAW,CAChD,QAAO,MAAM,qBAAqB,MAAM,GAAG;AACxD,WAAO,SAAS,kBAAkB,oCAAoC,IAAI;;GAG3E,MAAM,aAAa,MAAM,OAAO,QAAQ,SAAS,SAAS,WAAW;AACrE,OAAI,kBAAkB,UAAa,WAAW,SAAS,eAAe;AACrE,UAAM,eAAe,WAAW;AAChC,WAAO,SACN,wBACA,0DACA,IACA;;AAEF,mBAAgB,WAAW;GAE3B,MAAM,UAAU,SAAS,SAAS,WAAW,SAAS;GACtD,MAAM,YAAY,WAAW,QAAQ;GACrC,MAAM,gBACL,gBAAgB,QAAS,MAAM,KAAK,iBAAiB,SAAS,WAAW;AAC1E,OAAI,cAAc,WAAW,CAAC,gBAAgB;IAC7C,MAAM,QAAQ,MAAM,gBAAgB,WAAW;AAC/C,kBAAc,MAAM,aAAa,IAAI,MAAM,mBAAmB,MAAM,GAAG;AACvE,QAAI,WAAW,MAAM,aAAa,EAAG,cAAa;UAC5C;AACN,QAAI,CAAC,cAAe,eAAc;AAClC,UAAM,eAAe,WAAW;;AAGjC,OAAI,WAAW,CAAC,UACf,SAAQ,KACP,gDAAgD,SAAS,WAAW,iBAAiB,WAAW,KAAK,YAAY,+BAA+B,GAChJ;;EAKH,IAAI;EACJ,IAAI;EACJ,IAAI,QAAQ,KAAK;EACjB,IAAI,SAAS,KAAK;AAClB,MAAI,WACH,KAAI;GACH,MAAM,WAAW,MAAM,oBAAoB,YAAY,SAAS,UAAU,EACzE,iBACC,KAAK,SAAS,QAAQ,KAAK,UAAU,OAClC;IAAE,OAAO,KAAK;IAAO,QAAQ,KAAK;IAAQ,GAC1C,QACJ,CAAC;AACF,cAAW,SAAS;AACpB,mBAAgB,SAAS;AACzB,WAAQ,SAAS,SAAS;AAC1B,YAAS,UAAU,SAAS;WACpB,OAAO;AACf,WAAQ,MAAM,kDAAkD,MAAM;;EAKxE,MAAM,OAAO,MAAM,KAAK,cACvB,IACA;GACC,MAAM;GACN;GACA;GACA;GACA;GACA;GACA,EACD,SAAS,WACT;AAED,MAAI,CAAC,KACJ,QAAO,MAAM,qBAAqB,MAAM,GAAG;AAG5C,QAAM,oBAAoB,MAAM,KAAK,WAAW;AAKhD,SAAO,WAAW,EAAE,MAFA,cAAc,KAAK,EAEA,CAAC;UAChC,OAAO;AACf,SAAO,YAAY,OAAO,4BAA4B,gBAAgB"}