{"version":3,"file":"media.mjs","names":["path"],"sources":["../../../../src/astro/routes/api/media.ts"],"sourcesContent":["/**\n * Media list and upload endpoint\n *\n * GET /_emdash/api/media - List all media\n * POST /_emdash/api/media - Upload new media (via configured storage adapter)\n */\n\nimport * as path from \"node:path\";\n\nimport type { APIRoute } from \"astro\";\nimport { ulid } from \"ulidx\";\n\nimport { canReadMediaUsageCount, requirePerm } from \"#api/authorize.js\";\nimport { apiError, apiSuccess, handleError, unwrapResult } from \"#api/error.js\";\nimport { GLOBAL_UPLOAD_ALLOWLIST, resolveFieldAllowlist } from \"#api/handlers/media-allowlist.js\";\nimport { handleMediaUsageSummaries } from \"#api/handlers/media-usage.js\";\nimport { isParseError, parseQuery } from \"#api/parse.js\";\nimport { DEFAULT_MAX_UPLOAD_SIZE, formatFileSize, mediaListQuery } from \"#api/schemas.js\";\nimport { MediaRepository } from \"#db/repositories/media.js\";\nimport { enrichImageMetadata } from \"#media/enrich.js\";\nimport { matchesMimeAllowlist, normalizeMime } from \"#media/mime.js\";\nimport { computeContentHash } from \"#utils/hash.js\";\n\nimport type { MediaItem } from \"../../types.js\";\n\nexport const prerender = false;\n\n/**\n * Add URL to media items\n * Uses relative URLs to ensure portability across deployments\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\n/**\n * List media items\n */\nexport const GET: APIRoute = async ({ request, locals }) => {\n\tconst { emdash, user } = locals;\n\n\tconst denied = requirePerm(user, \"media:read\");\n\tif (denied) return denied;\n\n\tif (!emdash?.handleMediaList) {\n\t\treturn apiError(\"NOT_CONFIGURED\", \"EmDash is not initialized\", 500);\n\t}\n\n\tconst url = new URL(request.url);\n\tconst query = parseQuery(url, mediaListQuery);\n\tif (isParseError(query)) return query;\n\n\tconst result = await emdash.handleMediaList({\n\t\tcursor: query.cursor,\n\t\tlimit: query.limit,\n\t\tmimeType: query.mimeType,\n\t\tq: query.q,\n\t});\n\n\tif (!result.success) {\n\t\treturn unwrapResult(result);\n\t}\n\n\t// Add URL to each media item (relative URLs for portability)\n\tconst itemsWithUrl = result.data.items.map((item) => addUrlToMedia(item));\n\tif (query.includeUsage !== \"1\") {\n\t\treturn apiSuccess({ items: itemsWithUrl, nextCursor: result.data.nextCursor });\n\t}\n\n\tconst includeCount = canReadMediaUsageCount(user);\n\tconst usageResult = await handleMediaUsageSummaries(\n\t\temdash.db,\n\t\titemsWithUrl.map((item) => item.id),\n\t\t{ includeCount },\n\t);\n\tif (!usageResult.success) return unwrapResult(usageResult);\n\n\tconst itemsWithUsage = [];\n\tfor (const item of itemsWithUrl) {\n\t\tconst usage = usageResult.data[item.id];\n\t\tif (!usage) return apiError(\"MEDIA_USAGE_READ_ERROR\", \"Failed to read media usage\", 500);\n\t\titemsWithUsage.push({ ...item, usage });\n\t}\n\n\treturn apiSuccess({ items: itemsWithUsage, nextCursor: result.data.nextCursor });\n};\n\n/**\n * Upload media file\n *\n * Uses the configured storage adapter to store the file.\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?.handleMediaCreate) {\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 rawMax = emdash.config.maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE;\n\t\tif (!Number.isFinite(rawMax) || rawMax <= 0) {\n\t\t\treturn apiError(\"CONFIGURATION_ERROR\", \"Invalid maxUploadSize configuration\", 500);\n\t\t}\n\t\tconst maxUploadSize = rawMax;\n\n\t\t// Best-effort size check before buffering the full multipart body\n\t\tconst contentLength = request.headers.get(\"Content-Length\");\n\t\tif (contentLength && parseInt(contentLength, 10) > maxUploadSize) {\n\t\t\treturn apiError(\"PAYLOAD_TOO_LARGE\", \"Upload too large\", 413);\n\t\t}\n\n\t\tconst formData = await request.formData();\n\t\tconst fileEntry = formData.get(\"file\");\n\t\tconst file = fileEntry instanceof File ? fileEntry : null;\n\n\t\tif (!file) {\n\t\t\treturn apiError(\"NO_FILE\", \"No file provided\", 400);\n\t\t}\n\n\t\t// Validate file type — widen the allowlist when a field-specific list is configured\n\t\tconst fieldIdEntry = formData.get(\"fieldId\");\n\t\tconst fieldId =\n\t\t\ttypeof fieldIdEntry === \"string\" && fieldIdEntry.length > 0 ? fieldIdEntry : null;\n\n\t\tconst fieldAllowlist = fieldId ? await resolveFieldAllowlist(emdash.db, fieldId) : null;\n\t\tconst allowlist = fieldAllowlist ?? [...GLOBAL_UPLOAD_ALLOWLIST];\n\n\t\tif (!matchesMimeAllowlist(file.type, allowlist)) {\n\t\t\treturn apiError(\"INVALID_TYPE\", \"File type not allowed\", 400);\n\t\t}\n\n\t\t// Check file size before buffering\n\t\tif (file.size > maxUploadSize) {\n\t\t\treturn apiError(\n\t\t\t\t\"PAYLOAD_TOO_LARGE\",\n\t\t\t\t`File exceeds maximum size of ${formatFileSize(maxUploadSize)}`,\n\t\t\t\t413,\n\t\t\t);\n\t\t}\n\n\t\t// Get file content and compute hash\n\t\tconst buffer = new Uint8Array(await file.arrayBuffer());\n\t\tconst contentHash = await computeContentHash(buffer);\n\n\t\t// Check for existing media with same content hash (deduplication)\n\t\tconst repo = new MediaRepository(emdash.db);\n\t\tconst existing = await repo.findByContentHash(contentHash);\n\t\tif (existing) {\n\t\t\t// Same content already exists - return existing item\n\t\t\tconst itemWithUrl = addUrlToMedia(existing);\n\t\t\treturn apiSuccess({ item: itemWithUrl, deduplicated: true });\n\t\t}\n\n\t\t// Generate unique storage key\n\t\tconst id = ulid();\n\t\tconst ext = path.extname(file.name) || \"\";\n\t\tconst storageKey = `${id}${ext}`;\n\n\t\t// Upload to storage using the configured adapter\n\t\tawait emdash.storage.upload({\n\t\t\tkey: storageKey,\n\t\t\tbody: buffer,\n\t\t\tcontentType: file.type,\n\t\t});\n\n\t\t// Get image dimensions from form data (sent by client)\n\t\tconst widthEntry = formData.get(\"width\");\n\t\tconst widthStr = typeof widthEntry === \"string\" ? widthEntry : null;\n\t\tconst heightEntry = formData.get(\"height\");\n\t\tconst heightStr = typeof heightEntry === \"string\" ? heightEntry : null;\n\t\tconst width = widthStr ? parseInt(widthStr, 10) : undefined;\n\t\tconst height = heightStr ? parseInt(heightStr, 10) : undefined;\n\n\t\t// Derive dimensions + LQIP placeholders via the shared helper.\n\t\t// If the client sent a downscaled thumbnail, decode that for the blurhash\n\t\t// (avoids OOM on large originals on memory-constrained runtimes).\n\t\tconst thumbnailEntry = formData.get(\"thumbnail\");\n\t\tconst thumbnail = thumbnailEntry instanceof File ? thumbnailEntry : null;\n\t\tconst enriched = await enrichImageMetadata(buffer, file.type, {\n\t\t\tknownDimensions: width != null && height != null ? { width, height } : undefined,\n\t\t\tplaceholder: thumbnail\n\t\t\t\t? { bytes: new Uint8Array(await thumbnail.arrayBuffer()), contentType: thumbnail.type }\n\t\t\t\t: undefined,\n\t\t});\n\n\t\t// Create media record\n\t\tconst result = await emdash.handleMediaCreate({\n\t\t\tfilename: file.name,\n\t\t\tmimeType: normalizeMime(file.type),\n\t\t\tsize: file.size,\n\t\t\t// Client dimensions win over server header dimensions: the browser's\n\t\t\t// naturalWidth/Height apply EXIF orientation, while image-size reports\n\t\t\t// raw (pre-orientation) header dims — swapped for 90°/270° JPEGs.\n\t\t\twidth: width ?? enriched.width,\n\t\t\theight: height ?? enriched.height,\n\t\t\tstorageKey,\n\t\t\tcontentHash,\n\t\t\tblurhash: enriched.blurhash,\n\t\t\tdominantColor: enriched.dominantColor,\n\t\t\tauthorId: user?.id,\n\t\t});\n\n\t\tif (!result.success) {\n\t\t\t// Clean up the uploaded file on failure\n\t\t\ttry {\n\t\t\t\tawait emdash.storage.delete(storageKey);\n\t\t\t} catch {\n\t\t\t\t// Ignore cleanup errors\n\t\t\t}\n\t\t\treturn unwrapResult(result);\n\t\t}\n\n\t\t// Add URL to the response (relative URL for portability)\n\t\tconst itemWithUrl = addUrlToMedia(result.data.item);\n\n\t\treturn apiSuccess({ item: itemWithUrl }, 201);\n\t} catch (error) {\n\t\treturn handleError(error, \"Upload failed\", \"UPLOAD_ERROR\");\n\t}\n};\n"],"mappings":"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"}