{"version":3,"file":"media.mjs","names":["path"],"sources":["../../../../../../src/astro/routes/api/import/wordpress/media.ts"],"sourcesContent":["/**\n * WordPress media import endpoint\n *\n * POST /_emdash/api/import/wordpress/media\n *\n * Downloads media attachments from WordPress URLs and uploads to EmDash storage.\n * Streams progress updates as newline-delimited JSON (NDJSON).\n * Each line is either a progress update or the final result.\n */\n\nimport * as path from \"node:path\";\n\nimport type { APIRoute } from \"astro\";\nimport { MediaRepository, computeContentHash } from \"@premium-cms/emdash\";\nimport mime from \"mime/lite\";\nimport { ulid } from \"ulidx\";\n\nimport { requirePerm } from \"#api/authorize.js\";\nimport { apiError, apiSuccess, handleError } from \"#api/error.js\";\nimport { isParseError, parseBody } from \"#api/parse.js\";\nimport { wpMediaImportBody } from \"#api/schemas.js\";\nimport { validateExternalUrl, ssrfSafeFetch, SsrfError } from \"#import/ssrf.js\";\nimport { enrichImageMetadata } from \"#media/enrich.js\";\nimport type { EmDashHandlers } from \"#types\";\n\nimport type { AttachmentInfo } from \"./analyze.js\";\n\nexport const prerender = false;\n\n/** Progress update sent during streaming */\nexport interface MediaImportProgress {\n\ttype: \"progress\";\n\tcurrent: number;\n\ttotal: number;\n\tfilename?: string;\n\tstatus: \"downloading\" | \"uploading\" | \"done\" | \"skipped\" | \"failed\";\n\terror?: string;\n}\n\n/** Final result sent at end of stream */\nexport interface MediaImportResult {\n\ttype?: \"result\";\n\t/** Successfully imported items */\n\timported: Array<{\n\t\twpId?: number;\n\t\toriginalUrl: string;\n\t\tnewUrl: string;\n\t\tmediaId: string;\n\t}>;\n\t/** Failed items */\n\tfailed: Array<{\n\t\twpId?: number;\n\t\toriginalUrl: string;\n\t\terror: string;\n\t}>;\n\t/** Map of old URLs to new URLs (for content rewriting) */\n\turlMap: Record<string, string>;\n}\n\nexport const POST: APIRoute = async ({ request, locals }) => {\n\tconst { emdash, user } = locals;\n\n\tconst denied = requirePerm(user, \"import:execute\");\n\tif (denied) return denied;\n\n\tif (!emdash?.storage) {\n\t\treturn apiError(\"NO_STORAGE\", \"Storage not configured. Media import requires storage.\", 501);\n\t}\n\n\tif (!emdash?.db) {\n\t\treturn apiError(\"NO_DB\", \"Database not initialized\", 500);\n\t}\n\n\ttry {\n\t\tconst body = await parseBody(request, wpMediaImportBody);\n\t\tif (isParseError(body)) return body;\n\n\t\tconst attachments = body.attachments as AttachmentInfo[];\n\n\t\t// Check if streaming is requested (default: true)\n\t\tconst shouldStream = body.stream !== false;\n\n\t\tif (shouldStream) {\n\t\t\t// Stream progress updates as NDJSON\n\t\t\tconst stream = new ReadableStream({\n\t\t\t\tasync start(controller) {\n\t\t\t\t\tconst encoder = new TextEncoder();\n\t\t\t\t\tconst sendProgress = (progress: MediaImportProgress) => {\n\t\t\t\t\t\tcontroller.enqueue(encoder.encode(JSON.stringify(progress) + \"\\n\"));\n\t\t\t\t\t};\n\n\t\t\t\t\tconst result = await importMediaWithProgress(\n\t\t\t\t\t\tattachments,\n\t\t\t\t\t\temdash.db,\n\t\t\t\t\t\temdash.storage,\n\t\t\t\t\t\tsendProgress,\n\t\t\t\t\t);\n\n\t\t\t\t\t// Send final result\n\t\t\t\t\tcontroller.enqueue(encoder.encode(JSON.stringify({ ...result, type: \"result\" }) + \"\\n\"));\n\t\t\t\t\tcontroller.close();\n\t\t\t\t},\n\t\t\t});\n\n\t\t\treturn new Response(stream, {\n\t\t\t\tstatus: 200,\n\t\t\t\theaders: {\n\t\t\t\t\t\"Content-Type\": \"application/x-ndjson\",\n\t\t\t\t\t\"Cache-Control\": \"private, no-store\",\n\t\t\t\t\t\"Transfer-Encoding\": \"chunked\",\n\t\t\t\t},\n\t\t\t});\n\t\t}\n\n\t\t// Non-streaming mode\n\t\tconst result = await importMediaWithProgress(\n\t\t\tattachments,\n\t\t\temdash.db,\n\t\t\temdash.storage,\n\t\t\t() => {}, // No-op progress callback\n\t\t);\n\n\t\treturn apiSuccess(result);\n\t} catch (error) {\n\t\treturn handleError(error, \"Failed to import media\", \"IMPORT_ERROR\");\n\t}\n};\n\nexport async function importMediaWithProgress(\n\tattachments: AttachmentInfo[],\n\tdb: NonNullable<EmDashHandlers[\"db\"]>,\n\tstorage: NonNullable<EmDashHandlers[\"storage\"]>,\n\tonProgress: (progress: MediaImportProgress) => void,\n): Promise<MediaImportResult> {\n\tconst repo = new MediaRepository(db);\n\tconst total = attachments.length;\n\n\tconst result: MediaImportResult = {\n\t\timported: [],\n\t\tfailed: [],\n\t\turlMap: {},\n\t};\n\n\tfor (let i = 0; i < attachments.length; i++) {\n\t\tconst attachment = attachments[i];\n\t\tconst current = i + 1;\n\t\tconst filename = attachment.filename || `file-${attachment.id}`;\n\n\t\tif (!attachment.url) {\n\t\t\tresult.failed.push({\n\t\t\t\twpId: attachment.id,\n\t\t\t\toriginalUrl: \"\",\n\t\t\t\terror: \"No URL provided\",\n\t\t\t});\n\t\t\tonProgress({\n\t\t\t\ttype: \"progress\",\n\t\t\t\tcurrent,\n\t\t\t\ttotal,\n\t\t\t\tfilename,\n\t\t\t\tstatus: \"failed\",\n\t\t\t\terror: \"No URL provided\",\n\t\t\t});\n\t\t\tcontinue;\n\t\t}\n\n\t\ttry {\n\t\t\t// SSRF: validate URL before fetching\n\t\t\ttry {\n\t\t\t\tvalidateExternalUrl(attachment.url);\n\t\t\t} catch (e) {\n\t\t\t\tconst msg = e instanceof SsrfError ? e.message : \"Invalid URL\";\n\t\t\t\tresult.failed.push({\n\t\t\t\t\twpId: attachment.id,\n\t\t\t\t\toriginalUrl: attachment.url,\n\t\t\t\t\terror: `Blocked: ${msg}`,\n\t\t\t\t});\n\t\t\t\tonProgress({\n\t\t\t\t\ttype: \"progress\",\n\t\t\t\t\tcurrent,\n\t\t\t\t\ttotal,\n\t\t\t\t\tfilename,\n\t\t\t\t\tstatus: \"failed\",\n\t\t\t\t\terror: `Blocked: ${msg}`,\n\t\t\t\t});\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// Report downloading\n\t\t\tonProgress({\n\t\t\t\ttype: \"progress\",\n\t\t\t\tcurrent,\n\t\t\t\ttotal,\n\t\t\t\tfilename,\n\t\t\t\tstatus: \"downloading\",\n\t\t\t});\n\n\t\t\t// Download from WordPress (ssrfSafeFetch re-validates redirect targets)\n\t\t\tconst response = await ssrfSafeFetch(attachment.url, {\n\t\t\t\theaders: {\n\t\t\t\t\t\"User-Agent\": \"EmDash-Importer/1.0\",\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tif (!response.ok) {\n\t\t\t\tresult.failed.push({\n\t\t\t\t\twpId: attachment.id,\n\t\t\t\t\toriginalUrl: attachment.url,\n\t\t\t\t\terror: `HTTP ${response.status}: ${response.statusText}`,\n\t\t\t\t});\n\t\t\t\tonProgress({\n\t\t\t\t\ttype: \"progress\",\n\t\t\t\t\tcurrent,\n\t\t\t\t\ttotal,\n\t\t\t\t\tfilename,\n\t\t\t\t\tstatus: \"failed\",\n\t\t\t\t\terror: `HTTP ${response.status}`,\n\t\t\t\t});\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// Get content type from response or guess from filename\n\t\t\tconst contentType =\n\t\t\t\tresponse.headers.get(\"content-type\") || attachment.mimeType || \"application/octet-stream\";\n\n\t\t\t// Get the file data\n\t\t\tconst buffer = await response.arrayBuffer();\n\t\t\tconst size = buffer.byteLength;\n\n\t\t\t// Compute content hash for deduplication\n\t\t\tconst contentHash = await computeContentHash(buffer);\n\n\t\t\t// Check if we already have this exact content\n\t\t\tconst existing = await repo.findByContentHash(contentHash);\n\t\t\tif (existing) {\n\t\t\t\t// Same content already exists - reuse it\n\t\t\t\tconst existingUrl = `/_emdash/api/media/file/${existing.storageKey}`;\n\t\t\t\tresult.urlMap[attachment.url] = existingUrl;\n\t\t\t\tresult.imported.push({\n\t\t\t\t\twpId: attachment.id,\n\t\t\t\t\toriginalUrl: attachment.url,\n\t\t\t\t\tnewUrl: existingUrl,\n\t\t\t\t\tmediaId: existing.id,\n\t\t\t\t});\n\t\t\t\tonProgress({\n\t\t\t\t\ttype: \"progress\",\n\t\t\t\t\tcurrent,\n\t\t\t\t\ttotal,\n\t\t\t\t\tfilename,\n\t\t\t\t\tstatus: \"skipped\",\n\t\t\t\t});\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// Report uploading\n\t\t\tonProgress({\n\t\t\t\ttype: \"progress\",\n\t\t\t\tcurrent,\n\t\t\t\ttotal,\n\t\t\t\tfilename,\n\t\t\t\tstatus: \"uploading\",\n\t\t\t});\n\n\t\t\t// Generate storage key\n\t\t\tconst id = ulid();\n\t\t\tconst ext = attachment.filename\n\t\t\t\t? path.extname(attachment.filename)\n\t\t\t\t: getExtensionFromMimeType(contentType);\n\t\t\tconst storageKey = `${id}${ext}`;\n\n\t\t\t// Upload to storage\n\t\t\tawait storage.upload({\n\t\t\t\tkey: storageKey,\n\t\t\t\tbody: new Uint8Array(buffer),\n\t\t\t\tcontentType,\n\t\t\t});\n\n\t\t\t// Derive dimensions + LQIP placeholders (no-op for non-images).\n\t\t\tconst enriched = await enrichImageMetadata(new Uint8Array(buffer), contentType);\n\n\t\t\t// Create media record with content hash\n\t\t\tconst mediaItem = await repo.create({\n\t\t\t\tfilename: attachment.filename || `media-${attachment.id}${ext}`,\n\t\t\t\tmimeType: contentType,\n\t\t\t\tsize,\n\t\t\t\tstorageKey,\n\t\t\t\tcontentHash,\n\t\t\t\twidth: enriched.width,\n\t\t\t\theight: enriched.height,\n\t\t\t\tblurhash: enriched.blurhash,\n\t\t\t\tdominantColor: enriched.dominantColor,\n\t\t\t});\n\n\t\t\t// Build the new URL\n\t\t\tconst newUrl = `/_emdash/api/media/file/${storageKey}`;\n\n\t\t\tresult.imported.push({\n\t\t\t\twpId: attachment.id,\n\t\t\t\toriginalUrl: attachment.url,\n\t\t\t\tnewUrl,\n\t\t\t\tmediaId: mediaItem.id,\n\t\t\t});\n\n\t\t\t// Add to URL map\n\t\t\tresult.urlMap[attachment.url] = newUrl;\n\n\t\t\t// Report done\n\t\t\tonProgress({\n\t\t\t\ttype: \"progress\",\n\t\t\t\tcurrent,\n\t\t\t\ttotal,\n\t\t\t\tfilename,\n\t\t\t\tstatus: \"done\",\n\t\t\t});\n\t\t} catch (error) {\n\t\t\tconsole.error(`Media import error for \"${filename}\":`, error);\n\t\t\tconst errorMsg = \"Failed to import media\";\n\t\t\tresult.failed.push({\n\t\t\t\twpId: attachment.id,\n\t\t\t\toriginalUrl: attachment.url,\n\t\t\t\terror: errorMsg,\n\t\t\t});\n\t\t\tonProgress({\n\t\t\t\ttype: \"progress\",\n\t\t\t\tcurrent,\n\t\t\t\ttotal,\n\t\t\t\tfilename,\n\t\t\t\tstatus: \"failed\",\n\t\t\t\terror: errorMsg,\n\t\t\t});\n\t\t}\n\t}\n\n\treturn result;\n}\n\nfunction getExtensionFromMimeType(mimeType: string): string {\n\tconst ext = mime.getExtension(mimeType);\n\treturn ext ? `.${ext}` : \"\";\n}\n"],"mappings":"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"}