13 changed files with 662 additions and 23 deletions
@ -0,0 +1,176 @@ |
|||
import { NextRequest, NextResponse } from "next/server"; |
|||
import { requireLogin } from "@/app/api/_auth"; |
|||
import { ApiError } from "@/app/api/_errors"; |
|||
|
|||
const POPI_MEDIA_UPLOAD_BASE64_PATH = "/api_client/media/uploadFileBase64"; |
|||
const POPI_ASSET_CREATE_PATH = "/api_client/anime/asset/create"; |
|||
|
|||
type PopiserverResponse<T> = { |
|||
status?: string; |
|||
message?: string; |
|||
data?: T; |
|||
}; |
|||
|
|||
type PopiMedia = { |
|||
path?: string; |
|||
url?: string; |
|||
originPath?: string; |
|||
originUrl?: string; |
|||
}; |
|||
|
|||
type PopiAsset = { |
|||
id?: number; |
|||
type?: number; |
|||
images?: string[]; |
|||
video?: string; |
|||
}; |
|||
|
|||
function getPopiBaseUrl(request: NextRequest): string { |
|||
const baseUrl = |
|||
process.env.POPIART_API_BASE_URL || |
|||
process.env.NEXT_PUBLIC_APP_URL || |
|||
request.nextUrl.origin; |
|||
return baseUrl.replace(/\/+$/, ""); |
|||
} |
|||
|
|||
function parseDataUrl(value: string): { mimeType: string; base64: string } | undefined { |
|||
const match = value.match(/^data:([^;,]+)?(?:;[^,]*)?;base64,(.*)$/); |
|||
if (!match) return undefined; |
|||
return { |
|||
mimeType: match[1] || "application/octet-stream", |
|||
base64: match[2], |
|||
}; |
|||
} |
|||
|
|||
function extensionForMimeType(mimeType: string): string { |
|||
const normalized = mimeType.toLowerCase(); |
|||
if (normalized === "image/jpeg") return "jpg"; |
|||
if (normalized === "image/png") return "png"; |
|||
if (normalized === "image/webp") return "webp"; |
|||
if (normalized === "image/gif") return "gif"; |
|||
if (normalized === "video/mp4") return "mp4"; |
|||
if (normalized === "video/webm") return "webm"; |
|||
return normalized.split("/")[1]?.replace(/[^a-z0-9]/g, "") || "bin"; |
|||
} |
|||
|
|||
function mediaResourceUrl(media: PopiMedia): string | null { |
|||
return media.url || media.originUrl || media.path || media.originPath || null; |
|||
} |
|||
|
|||
function isPopiserverMediaUrl(value: string): boolean { |
|||
if (value.startsWith("/media/") || value.startsWith("/media_private/")) return true; |
|||
try { |
|||
const url = new URL(value); |
|||
return url.pathname.startsWith("/media/") || url.pathname.startsWith("/media_private/"); |
|||
} catch { |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
async function postPopiJson<T>( |
|||
request: NextRequest, |
|||
token: string, |
|||
path: string, |
|||
body: Record<string, unknown> |
|||
): Promise<T> { |
|||
const upstream = await fetch(getPopiBaseUrl(request) + path, { |
|||
method: "POST", |
|||
headers: { |
|||
Accept: "application/json", |
|||
"Content-Type": "application/json", |
|||
Authorization: `Bearer ${token}`, |
|||
token, |
|||
}, |
|||
body: JSON.stringify(body), |
|||
cache: "no-store", |
|||
}); |
|||
|
|||
const payload = (await upstream.json().catch(() => null)) as PopiserverResponse<T> | null; |
|||
if (!upstream.ok) { |
|||
throw ApiError.provider(payload?.message || "Popiserver API request failed.", upstream.status); |
|||
} |
|||
if (!payload || payload.status !== "0000") { |
|||
throw ApiError.provider(payload?.message || "Popiserver API returned an error.", 502); |
|||
} |
|||
return payload.data as T; |
|||
} |
|||
|
|||
async function ensurePopiserverMedia( |
|||
request: NextRequest, |
|||
token: string, |
|||
value: string, |
|||
kind: "image" | "video", |
|||
fileName?: string |
|||
): Promise<string> { |
|||
if (isPopiserverMediaUrl(value)) return value; |
|||
|
|||
const dataUrl = parseDataUrl(value); |
|||
if (!dataUrl) { |
|||
throw ApiError.badRequest("Only popiserver media URLs or base64 data URLs can be saved as assets."); |
|||
} |
|||
|
|||
const extension = extensionForMimeType(dataUrl.mimeType); |
|||
const media = await postPopiJson<PopiMedia>(request, token, POPI_MEDIA_UPLOAD_BASE64_PATH, { |
|||
data: dataUrl.base64, |
|||
fileName: fileName || `canvas-${kind}-${Date.now()}.${extension}`, |
|||
}); |
|||
const url = mediaResourceUrl(media); |
|||
if (!url) { |
|||
throw ApiError.provider("Popiserver media upload response is missing resource URL.", 502); |
|||
} |
|||
return url; |
|||
} |
|||
|
|||
export async function POST(request: NextRequest) { |
|||
try { |
|||
const login = requireLogin(request); |
|||
const body = (await request.json().catch(() => null)) as { |
|||
type?: unknown; |
|||
media?: unknown; |
|||
title?: unknown; |
|||
description?: unknown; |
|||
fileName?: unknown; |
|||
} | null; |
|||
|
|||
const type = body?.type === "video" ? "video" : body?.type === "image" ? "image" : null; |
|||
const media = typeof body?.media === "string" ? body.media : ""; |
|||
if (!type || !media) { |
|||
return NextResponse.json( |
|||
{ success: false, error: "type=image|video and media are required." }, |
|||
{ status: 400 } |
|||
); |
|||
} |
|||
|
|||
const resourceUrl = await ensurePopiserverMedia( |
|||
request, |
|||
login.token, |
|||
media, |
|||
type, |
|||
typeof body?.fileName === "string" ? body.fileName : undefined |
|||
); |
|||
|
|||
const title = typeof body?.title === "string" ? body.title : ""; |
|||
const description = typeof body?.description === "string" ? body.description : ""; |
|||
const asset = await postPopiJson<PopiAsset>(request, login.token, POPI_ASSET_CREATE_PATH, { |
|||
type: type === "video" ? 2 : 1, |
|||
title, |
|||
description, |
|||
images: type === "image" ? [resourceUrl] : undefined, |
|||
video: type === "video" ? resourceUrl : undefined, |
|||
}); |
|||
|
|||
return NextResponse.json({ |
|||
success: true, |
|||
url: resourceUrl, |
|||
asset, |
|||
}); |
|||
} catch (error) { |
|||
if (error instanceof ApiError) { |
|||
return NextResponse.json({ success: false, error: error.message }, { status: error.status }); |
|||
} |
|||
return NextResponse.json( |
|||
{ success: false, error: error instanceof Error ? error.message : "Asset create failed." }, |
|||
{ status: 500 } |
|||
); |
|||
} |
|||
} |
|||
@ -0,0 +1,137 @@ |
|||
import { NextRequest, NextResponse } from "next/server"; |
|||
import { requireLogin } from "@/app/api/_auth"; |
|||
import { ApiError } from "@/app/api/_errors"; |
|||
|
|||
const POPI_MEDIA_UPLOAD_BASE64_PATH = "/api_client/media/uploadFileBase64"; |
|||
|
|||
type PopiserverResponse<T> = { |
|||
status?: string; |
|||
message?: string; |
|||
data?: T; |
|||
}; |
|||
|
|||
type PopiMedia = { |
|||
id?: number; |
|||
path?: string; |
|||
url?: string; |
|||
originPath?: string; |
|||
originUrl?: string; |
|||
thumbPath?: string; |
|||
thumbUrl?: string; |
|||
}; |
|||
|
|||
function getPopiBaseUrl(request: NextRequest): string { |
|||
const baseUrl = |
|||
process.env.POPIART_API_BASE_URL || |
|||
process.env.NEXT_PUBLIC_APP_URL || |
|||
request.nextUrl.origin; |
|||
return baseUrl.replace(/\/+$/, ""); |
|||
} |
|||
|
|||
function parseDataUrl(value: string): { mimeType: string; base64: string } | undefined { |
|||
const match = value.match(/^data:([^;,]+)?(?:;[^,]*)?;base64,(.*)$/); |
|||
if (!match) return undefined; |
|||
return { |
|||
mimeType: match[1] || "application/octet-stream", |
|||
base64: match[2], |
|||
}; |
|||
} |
|||
|
|||
function extensionForMimeType(mimeType: string): string { |
|||
const normalized = mimeType.toLowerCase(); |
|||
if (normalized === "image/jpeg") return "jpg"; |
|||
if (normalized === "image/png") return "png"; |
|||
if (normalized === "image/webp") return "webp"; |
|||
if (normalized === "image/gif") return "gif"; |
|||
if (normalized === "video/mp4") return "mp4"; |
|||
if (normalized === "video/webm") return "webm"; |
|||
if (normalized === "audio/mpeg" || normalized === "audio/mp3") return "mp3"; |
|||
if (normalized === "audio/wav" || normalized === "audio/x-wav") return "wav"; |
|||
if (normalized === "model/gltf-binary") return "glb"; |
|||
return normalized.split("/")[1]?.replace(/[^a-z0-9]/g, "") || "bin"; |
|||
} |
|||
|
|||
function mediaResourceUrl(media: PopiMedia): string | null { |
|||
return media.url || media.originUrl || media.path || media.originPath || null; |
|||
} |
|||
|
|||
export async function POST(request: NextRequest) { |
|||
try { |
|||
const login = requireLogin(request); |
|||
const body = (await request.json().catch(() => null)) as { |
|||
data?: unknown; |
|||
fileName?: unknown; |
|||
contentType?: unknown; |
|||
} | null; |
|||
|
|||
const value = typeof body?.data === "string" ? body.data : ""; |
|||
const dataUrl = parseDataUrl(value); |
|||
if (!dataUrl) { |
|||
return NextResponse.json( |
|||
{ success: false, error: "Only base64 data URLs can be uploaded." }, |
|||
{ status: 400 } |
|||
); |
|||
} |
|||
|
|||
const extension = extensionForMimeType( |
|||
typeof body?.contentType === "string" ? body.contentType : dataUrl.mimeType |
|||
); |
|||
const rawFileName = |
|||
typeof body?.fileName === "string" && body.fileName.trim() |
|||
? body.fileName.trim() |
|||
: `canvas-media-${Date.now()}.${extension}`; |
|||
const fileName = rawFileName.includes(".") ? rawFileName : `${rawFileName}.${extension}`; |
|||
|
|||
const upstream = await fetch(getPopiBaseUrl(request) + POPI_MEDIA_UPLOAD_BASE64_PATH, { |
|||
method: "POST", |
|||
headers: { |
|||
Accept: "application/json", |
|||
"Content-Type": "application/json", |
|||
Authorization: `Bearer ${login.token}`, |
|||
token: login.token, |
|||
}, |
|||
body: JSON.stringify({ |
|||
data: dataUrl.base64, |
|||
fileName, |
|||
}), |
|||
cache: "no-store", |
|||
}); |
|||
|
|||
const payload = (await upstream.json().catch(() => null)) as PopiserverResponse<PopiMedia> | null; |
|||
if (!upstream.ok) { |
|||
return NextResponse.json( |
|||
{ success: false, error: payload?.message || "Popiserver media upload failed." }, |
|||
{ status: upstream.status } |
|||
); |
|||
} |
|||
if (!payload || payload.status !== "0000" || !payload.data) { |
|||
return NextResponse.json( |
|||
{ success: false, error: payload?.message || "Popiserver media upload returned an error." }, |
|||
{ status: 502 } |
|||
); |
|||
} |
|||
|
|||
const resourceUrl = mediaResourceUrl(payload.data); |
|||
if (!resourceUrl) { |
|||
return NextResponse.json( |
|||
{ success: false, error: "Popiserver media upload response is missing resource URL." }, |
|||
{ status: 502 } |
|||
); |
|||
} |
|||
|
|||
return NextResponse.json({ |
|||
success: true, |
|||
url: resourceUrl, |
|||
path: payload.data.path, |
|||
media: payload.data, |
|||
}); |
|||
} catch (error) { |
|||
if (error instanceof ApiError) { |
|||
return NextResponse.json({ success: false, error: error.message }, { status: error.status }); |
|||
} |
|||
return NextResponse.json( |
|||
{ success: false, error: error instanceof Error ? error.message : "Upload failed." }, |
|||
{ status: 500 } |
|||
); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue