{"version":3,"file":"upload.mjs","names":[],"sources":["../../../../../../src/astro/routes/api/media/[id]/upload.ts"],"sourcesContent":["import type { APIRoute } from \"astro\";\nimport type { Storage } from \"@premium-cms/emdash\";\nimport { ulid } from \"ulidx\";\n\nimport { requireOwnerPerm, requirePerm } from \"#api/authorize.js\";\nimport { apiError, apiSuccess, handleError } from \"#api/error.js\";\nimport { MediaRepository } from \"#db/repositories/media.js\";\nimport { normalizeMime } from \"#media/mime.js\";\nimport { removeUploadAttempt } from \"#media/upload-attempts.js\";\nimport { computeContentHash, MAX_CONTENT_HASH_BYTES } from \"#utils/hash.js\";\n\nexport const prerender = false;\n\nconst INITIAL_HASH_BUFFER_BYTES = 64 * 1024;\n\ntype FixedLengthStreamConstructor = new (\n\texpectedLength: number | bigint,\n) => TransformStream<ArrayBuffer | ArrayBufferView, Uint8Array>;\n\ndeclare const FixedLengthStream: FixedLengthStreamConstructor | undefined;\n\nclass UploadBodyError extends Error {\n\treadonly code: \"PAYLOAD_TOO_LARGE\" | \"UPLOAD_SIZE_MISMATCH\";\n\n\tconstructor(code: \"PAYLOAD_TOO_LARGE\" | \"UPLOAD_SIZE_MISMATCH\") {\n\t\tsuper(code);\n\t\tthis.code = code;\n\t}\n}\n\nfunction findUploadBodyError(error: unknown): UploadBodyError | null {\n\tlet current = error;\n\tfor (let depth = 0; depth < 5 && current instanceof Error; depth++) {\n\t\tif (current instanceof UploadBodyError) return current;\n\t\tcurrent = current.cause;\n\t}\n\treturn null;\n}\n\nfunction preserveKnownLength(\n\tbody: ReadableStream<Uint8Array>,\n\texpectedLength: number,\n): ReadableStream<Uint8Array> {\n\tif (typeof FixedLengthStream === \"undefined\") return body;\n\treturn body.pipeThrough(new FixedLengthStream(expectedLength));\n}\n\nfunction createUploadAttemptKey(key: string): string {\n\tconst pathSeparator = key.lastIndexOf(\"/\");\n\tconst extensionSeparator = key.lastIndexOf(\".\");\n\tif (extensionSeparator > pathSeparator) {\n\t\treturn `${key.slice(0, extensionSeparator)}.${ulid()}${key.slice(extensionSeparator)}`;\n\t}\n\treturn `${key}.${ulid()}`;\n}\n\nasync function getStoredSize(storage: Storage, key: string): Promise<number | null> {\n\tif (!(await storage.exists(key))) return null;\n\tconst download = await storage.download(key);\n\ttry {\n\t\treturn download.size;\n\t} finally {\n\t\ttry {\n\t\t\tawait download.body.cancel();\n\t\t} catch (error) {\n\t\t\tconsole.error(\"[media] upload download cancellation failed:\", error);\n\t\t}\n\t}\n}\n\nexport const PUT: 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\tif (!emdash.storage) {\n\t\treturn apiError(\"NO_STORAGE\", \"Storage not configured\", 500);\n\t}\n\n\ttry {\n\t\tconst repo = new MediaRepository(emdash.db);\n\t\tconst media = await repo.findById(id);\n\t\tif (!media) {\n\t\t\treturn apiError(\"NOT_FOUND\", `Media item not found: ${id}`, 404);\n\t\t}\n\n\t\tif (media.status !== \"pending\") {\n\t\t\treturn apiError(\"INVALID_STATE\", `Media item is not pending: ${media.status}`, 400);\n\t\t}\n\n\t\tconst ownerDenied = requireOwnerPerm(\n\t\t\tuser,\n\t\t\tmedia.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 (!Number.isSafeInteger(media.size) || media.size === null || media.size < 0) {\n\t\t\treturn apiError(\"INVALID_STATE\", \"Pending media item has no valid upload size\", 400);\n\t\t}\n\t\tconst expectedSize = media.size;\n\n\t\tconst contentType = request.headers.get(\"Content-Type\");\n\t\tif (!contentType || normalizeMime(contentType) !== media.mimeType) {\n\t\t\treturn apiError(\"INVALID_TYPE\", \"Upload content type does not match the media item\", 400);\n\t\t}\n\n\t\tconst requestBody =\n\t\t\trequest.body ??\n\t\t\t(expectedSize === 0\n\t\t\t\t? new ReadableStream<Uint8Array>({\n\t\t\t\t\t\tstart(controller) {\n\t\t\t\t\t\t\tcontroller.close();\n\t\t\t\t\t\t},\n\t\t\t\t\t})\n\t\t\t\t: null);\n\t\tif (!requestBody) {\n\t\t\treturn apiError(\"NO_FILE\", \"No file provided\", 400);\n\t\t}\n\n\t\tconst contentLength = request.headers.get(\"Content-Length\");\n\t\tif (contentLength !== null) {\n\t\t\tconst declaredSize = Number(contentLength);\n\t\t\tif (!Number.isSafeInteger(declaredSize) || declaredSize < 0) {\n\t\t\t\treturn apiError(\"INVALID_REQUEST\", \"Invalid Content-Length header\", 400);\n\t\t\t}\n\t\t\tif (declaredSize > expectedSize) {\n\t\t\t\treturn apiError(\"PAYLOAD_TOO_LARGE\", \"Upload exceeds the expected size\", 413);\n\t\t\t}\n\t\t\tif (declaredSize !== expectedSize) {\n\t\t\t\treturn apiError(\"UPLOAD_SIZE_MISMATCH\", \"Upload size does not match the media item\", 400);\n\t\t\t}\n\t\t}\n\n\t\tlet receivedSize = 0;\n\t\tconst shouldHash = expectedSize > 0 && expectedSize <= MAX_CONTENT_HASH_BYTES;\n\t\tlet hashBytes: Uint8Array | null = null;\n\t\tconst checkedBody = requestBody.pipeThrough(\n\t\t\tnew TransformStream<Uint8Array, Uint8Array>({\n\t\t\t\ttransform(chunk, controller) {\n\t\t\t\t\tconst offset = receivedSize;\n\t\t\t\t\treceivedSize += chunk.byteLength;\n\t\t\t\t\tif (receivedSize > expectedSize) {\n\t\t\t\t\t\tcontroller.error(new UploadBodyError(\"PAYLOAD_TOO_LARGE\"));\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tif (shouldHash && chunk.byteLength > 0) {\n\t\t\t\t\t\tif (!hashBytes) {\n\t\t\t\t\t\t\thashBytes = new Uint8Array(\n\t\t\t\t\t\t\t\tMath.min(expectedSize, Math.max(INITIAL_HASH_BUFFER_BYTES, receivedSize)),\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t} else if (receivedSize > hashBytes.byteLength) {\n\t\t\t\t\t\t\tconst grown = new Uint8Array(\n\t\t\t\t\t\t\t\tMath.min(expectedSize, Math.max(receivedSize, hashBytes.byteLength * 2)),\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\tgrown.set(hashBytes);\n\t\t\t\t\t\t\thashBytes = grown;\n\t\t\t\t\t\t}\n\t\t\t\t\t\thashBytes.set(chunk, offset);\n\t\t\t\t\t}\n\t\t\t\t\tcontroller.enqueue(chunk);\n\t\t\t\t},\n\t\t\t\tflush(controller) {\n\t\t\t\t\tif (receivedSize !== expectedSize) {\n\t\t\t\t\t\tcontroller.error(new UploadBodyError(\"UPLOAD_SIZE_MISMATCH\"));\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t}),\n\t\t);\n\t\tconst body = preserveKnownLength(checkedBody, expectedSize);\n\n\t\tconst attemptKey = createUploadAttemptKey(media.storageKey);\n\t\tawait repo.createUploadAttempt(id, attemptKey);\n\n\t\tlet attemptSize: number;\n\t\ttry {\n\t\t\tconst result = await emdash.storage.upload({\n\t\t\t\tkey: attemptKey,\n\t\t\t\tbody,\n\t\t\t\tcontentType: media.mimeType,\n\t\t\t});\n\t\t\tattemptSize = result.size;\n\t\t} catch (error) {\n\t\t\tawait removeUploadAttempt(emdash.storage, repo, attemptKey);\n\t\t\tconst bodyError = findUploadBodyError(error);\n\t\t\tif (bodyError?.code === \"PAYLOAD_TOO_LARGE\") {\n\t\t\t\treturn apiError(\"PAYLOAD_TOO_LARGE\", \"Upload exceeds the expected size\", 413);\n\t\t\t}\n\t\t\tif (bodyError?.code === \"UPLOAD_SIZE_MISMATCH\") {\n\t\t\t\treturn apiError(\"UPLOAD_SIZE_MISMATCH\", \"Upload size does not match the media item\", 400);\n\t\t\t}\n\t\t\treturn handleError(error, \"Upload failed\", \"UPLOAD_ERROR\");\n\t\t}\n\n\t\tif (receivedSize !== expectedSize || attemptSize !== expectedSize) {\n\t\t\tawait removeUploadAttempt(emdash.storage, repo, attemptKey);\n\t\t\treturn apiError(\"UPLOAD_SIZE_MISMATCH\", \"Upload size does not match the media item\", 400);\n\t\t}\n\t\tconst contentHash = hashBytes ? await computeContentHash(hashBytes) : undefined;\n\n\t\tlet published: boolean;\n\t\ttry {\n\t\t\tpublished = await repo.publishPendingStorageKey(\n\t\t\t\tid,\n\t\t\t\tmedia.storageKey,\n\t\t\t\tattemptKey,\n\t\t\t\tcontentHash,\n\t\t\t);\n\t\t} catch (error) {\n\t\t\ttry {\n\t\t\t\tconst current = await repo.findById(id);\n\t\t\t\tif (\n\t\t\t\t\tcurrent?.storageKey === attemptKey &&\n\t\t\t\t\t(current.status === \"pending\" || current.status === \"ready\") &&\n\t\t\t\t\tcurrent.size === expectedSize &&\n\t\t\t\t\t(await getStoredSize(emdash.storage, attemptKey)) === expectedSize\n\t\t\t\t) {\n\t\t\t\t\treturn apiSuccess({ uploaded: true, size: expectedSize });\n\t\t\t\t}\n\t\t\t} catch (verificationError) {\n\t\t\t\tconsole.error(\"[media] upload publication verification failed:\", verificationError);\n\t\t\t}\n\t\t\treturn handleError(error, \"Upload failed\", \"UPLOAD_ERROR\");\n\t\t}\n\n\t\tif (!published) {\n\t\t\tconst current = await repo.findById(id);\n\t\t\tif (\n\t\t\t\tcurrent &&\n\t\t\t\t(current.status === \"pending\" || current.status === \"ready\") &&\n\t\t\t\tcurrent.size === expectedSize &&\n\t\t\t\t(current.storageKey === attemptKey ||\n\t\t\t\t\texpectedSize === 0 ||\n\t\t\t\t\t(contentHash !== undefined && current.contentHash === contentHash)) &&\n\t\t\t\t(await getStoredSize(emdash.storage, current.storageKey)) === expectedSize\n\t\t\t) {\n\t\t\t\tif (current.storageKey !== attemptKey) {\n\t\t\t\t\tawait removeUploadAttempt(emdash.storage, repo, attemptKey);\n\t\t\t\t}\n\t\t\t\treturn apiSuccess({ uploaded: true, size: expectedSize });\n\t\t\t}\n\t\t\tawait removeUploadAttempt(emdash.storage, repo, attemptKey);\n\t\t\treturn apiError(\"INVALID_STATE\", \"Media item is no longer pending\", 400);\n\t\t}\n\n\t\tawait removeUploadAttempt(emdash.storage, repo, media.storageKey);\n\t\treturn apiSuccess({ uploaded: true, size: receivedSize });\n\t} catch (error) {\n\t\treturn handleError(error, \"Upload failed\", \"UPLOAD_ERROR\");\n\t}\n};\n"],"mappings":"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"}