import { Elysia, t } from "elysia"; import config from "@/../config.toml"; import { jwt } from "@elysiajs/jwt"; import { MediaUpload } from "@/models/media"; import { Tag } from "@/models/tag"; import { User } from "@/models/user"; import { createAuditLog } from "@/lib/audit"; import { normalizeQueryTags, normalizeTags } from "@/lib/tag"; import { fetchTweetData } from "@/lib/tweet"; import { fetchPixivData } from "@/lib/pixiv"; import { checkExistingPostByUrl } from "@/lib/post"; import { makeS3FileName, s3Client, uploadToS3, writeToS3 } from "@/lib/s3"; // 중복 요청 방지용 인플라이트 락 const inFlightUploads = new Set(); // ========================================== // Type Definitions // ========================================== type UploadResponse = { success: boolean; message: string; savedCount?: number; failedCount?: number; ids?: string[]; }; type ExistsResponse = { success: boolean; message: string; exists: boolean; source: "twitter" | "pixiv" | null; postId: string | null; documentId: string | null; }; // ========================================== // Utility Functions (Pure Helpers) // ========================================== function createUploadResponse( success: boolean, message: string, extra?: Omit ): UploadResponse { return { success, message, ...extra }; } function buildUploadKey(url: string, selected: boolean[]): string { const match = url.match(/\/status\/(\d+)/); const tweetId = match?.[1] ?? url; const selectedIndices = selected .map((isSelected, index) => (isSelected ? index : -1)) .filter((index) => index >= 0) .join(","); return `${tweetId}:${selectedIndices}`; } function makePixivFileName(authorId: string, illustId: string, mediaUrl: string, index: number): string { const rawName = mediaUrl.split("/").pop() || `media_${Date.now()}_${index}`; const withoutQuery = rawName.split("?")[0]?.split("#")[0] || `media_${Date.now()}_${index}`; return `pixiv/${authorId}/${illustId}/${withoutQuery.replace(/[^a-zA-Z0-9._-]/g, "_")}`; } async function sendDiscordNotification(payload: { title: string; url: string; author: string; tags: string[]; imageUrl?: string; }) { const webhookUrl = config.discord.webhook; if (!webhookUrl) return; try { await fetch(webhookUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ embeds: [{ title: payload.title, url: payload.url, author: { name: payload.author }, fields: [{ name: "Tags", value: payload.tags.join(", ") || "None" }], image: payload.imageUrl ? { url: payload.imageUrl } : undefined, color: 0x00ff00, }], }), }); } catch (error) { console.error("[Discord Webhook Error]", error); } } async function saveTags(tags: string[]) { await Promise.all( tags.map((tag) => Tag.updateOne( { name: tag }, { $inc: { usageCount: 1 }, $set: { lastUsedAt: new Date() }, $setOnInsert: { name: tag }, }, { upsert: true }, ), ), ); } async function syncTagUsage(previousTags: string[], nextTags: string[]) { const previous = new Set(previousTags); const next = new Set(nextTags); const removed = Array.from(previous).filter((tag) => !next.has(tag)); const added = Array.from(next).filter((tag) => !previous.has(tag)); if (added.length > 0) await saveTags(added); if (removed.length > 0) { await Promise.all( removed.map((tag) => Tag.updateOne({ name: tag }, { $inc: { usageCount: -1 }, $set: { lastUsedAt: new Date() } }) ) ); } } async function uploadAndCreateWithRetry(options: { fileName: string; mediaUrl: string; mediaIndex: number; createDocument: () => Promise; }) { const { fileName, mediaUrl, mediaIndex, createDocument } = options; const existingBefore = await MediaUpload.findOne({ s3Key: fileName, mediaIndex }); if (existingBefore) return { ok: true as const, created: false, id: existingBefore._id.toString() }; let lastError: unknown; for (let attempt = 1; attempt <= 2; attempt++) { try { if (!(await s3Client.exists(fileName))) { await uploadToS3(fileName, mediaUrl); } const existing = await MediaUpload.findOne({ s3Key: fileName, mediaIndex }); if (existing) return { ok: true as const, created: false, id: existing._id.toString() }; const doc = await createDocument(); return { ok: true as const, created: true, id: doc._id.toString() }; } catch (error) { lastError = error; const existingAfterError = await MediaUpload.findOne({ s3Key: fileName, mediaIndex }); if (existingAfterError) return { ok: true as const, created: false, id: existingAfterError._id.toString() }; if (attempt < 2) console.warn(`[Upload retry] key=${fileName} mediaIndex=${mediaIndex}`); } } return { ok: false as const, error: lastError }; } // ========================================== // Elysia Application Engine Setup // ========================================== export default new Elysia({ prefix: "/post" }) .use(jwt({ name: "jwt", secret: config.auth.jwt_secret })) .onAfterHandle(({ set }) => { set.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; set.headers["Pragma"] = "no-cache"; set.headers["Expires"] = "0"; set.headers["Surrogate-Control"] = "no-store"; }) .derive(async ({ jwt, cookie: { mizuki } }) => { return { getAuthenticatedUser: async () => { const token = mizuki.value; if (typeof token !== "string" || !token) return null; const payload = await jwt.verify(token); if (!payload || typeof payload !== "object" || !("id" in payload)) return null; return await User.findOne({ userId: payload.id }); } }; }) .get("/total", async ({ query }) => { const filterTags = normalizeQueryTags(query.tags); return await MediaUpload.countDocuments(filterTags.length > 0 ? { tags: { $in: filterTags } } : {}); }, { query: t.Object({ tags: t.Optional(t.Union([t.String(), t.Array(t.String())])) }) }) .get("/list", async ({ query, getAuthenticatedUser, status }) => { const requester = await getAuthenticatedUser(); if (!requester) return status(401, "Unauthorized"); const page = query.page; const pageSize = query.size || 10; const filterTags = normalizeQueryTags(query.tags); const filter = filterTags.length > 0 ? { tags: { $in: filterTags } } : {}; return await MediaUpload.find(filter) .sort({ createdAt: -1 }) .skip((page - 1) * pageSize) .limit(pageSize); }, { query: t.Object({ page: t.Number({ default: 1, minimum: 1 }), size: t.Optional(t.Number({ default: 20, minimum: 1, maximum: 100 })), tags: t.Optional(t.Union([t.String(), t.Array(t.String())])), }) }) .get("/tags", async () => { return await Tag.find().sort({ usageCount: -1, lastUsedAt: -1 }); }) .get("/exists", async ({ query }) => { const result = await checkExistingPostByUrl(query.url); return { success: true, message: result.exists ? "이미 저장된 게시물입니다." : "저장된 게시물이 없습니다.", exists: result.exists, source: result.source, postId: result.postId, documentId: result.documentId, }; }, { query: t.Object({ url: t.String() }), }) .get("/detail/:id", async ({ params, status }) => { const post = await MediaUpload.findById(params.id); if (!post) return status(404, "포스트를 찾을 수 없습니다."); const tweetData = typeof post.tweet === "object" && post.tweet !== null ? (post.tweet as { text?: string; title?: string; description?: string }) : undefined; return { _id: post._id, type: post.type, url: post.tweet?.url, author: post.author, tags: Array.isArray(post.tags) ? post.tags : [], tweet: tweetData ? { text: tweetData.text ?? tweetData.description, title: tweetData.title } : undefined, mediaUrl: post.mediaUrl, mediaIndex: post.mediaIndex, mediaType: post.mediaType, thumbnailUrl: post.thumbnailUrl, }; }, { params: t.Object({ id: t.String() }) }) .delete("/delete/:id", async ({ params, status, getAuthenticatedUser }) => { const requester = await getAuthenticatedUser(); if (!requester) return status(401, "Unauthorized"); if (requester.role !== "admin" && requester.role !== "writer") return status(403, "Forbidden"); const targetPost = await MediaUpload.findById(params.id); const result = await MediaUpload.deleteOne({ _id: params.id }); if (result.deletedCount !== 1) return status(404, "포스트를 찾을 수 없습니다."); await createAuditLog({ actor: { userId: requester.userId, discordId: requester.discordId, username: requester.username, role: requester.role }, action: "post.delete", targetType: "post", targetId: params.id, summary: `${requester.username} deleted a post`, detail: { id: params.id, mediaUrl: targetPost?.mediaUrl }, }); return "삭제되었습니다."; }, { params: t.Object({ id: t.String() }) }) .patch("/edit/:id", async ({ params, body, status, getAuthenticatedUser }) => { const requester = await getAuthenticatedUser(); if (!requester) return status(401, "Unauthorized"); if (requester.role !== "admin" && requester.role !== "writer") return status(403, "Forbidden"); const post = await MediaUpload.findById(params.id); if (!post) return status(404, "포스트를 찾을 수 없습니다."); const nextTags = normalizeTags(body.tag ?? post.tags ?? ["미분류"]); const previousTags = Array.isArray(post.tags) ? post.tags : []; post.tags = nextTags; if (body.author !== undefined) post.author = body.author.trim() || post.author; await post.save(); await syncTagUsage(previousTags, nextTags); await createAuditLog({ actor: { userId: requester.userId, discordId: requester.discordId, username: requester.username, role: requester.role }, action: "post.edit", targetType: "post", targetId: params.id, summary: `${requester.username} edited a post`, detail: { id: params.id, author: post.author, tags: nextTags }, }); return "수정되었습니다."; }, { params: t.Object({ id: t.String() }), body: t.Object({ tag: t.Optional(t.Array(t.String())), author: t.Optional(t.String()) }), }) .post("/upload", async ({ body, status, getAuthenticatedUser }) => { const requester = await getAuthenticatedUser(); if (!requester) return status(401, "Unauthorized"); if (requester.role !== "admin" && requester.role !== "writer") return status(403, "Forbidden"); const existingPost = await checkExistingPostByUrl(body.url); if (existingPost.exists) { return createUploadResponse(true, "이미 저장된 게시물입니다.", { savedCount: 0, failedCount: 0, ids: existingPost.documentId ? [existingPost.documentId] : [], }); } const isPixiv = body.url.startsWith("https://www.pixiv.net/"); const isTwitter = /https:\/\/(x|twitter|fxtwitter|fixupx|vxwitter)\.com\//.test(body.url); if (!isPixiv && !isTwitter) return status(400, createUploadResponse(false, "어...")); const requestId = crypto.randomUUID(); const uploadKey = buildUploadKey(body.url, body.selected); if (inFlightUploads.has(uploadKey)) { console.warn(`[Upload skipped-duplicate] requestId=${requestId} key=${uploadKey}`); return status(202, createUploadResponse(false, "이미 처리 중인 업로드입니다.")); } inFlightUploads.add(uploadKey); console.log(`[Upload started] type=${isPixiv ? "Pixiv" : "Twitter"} requestId=${requestId} key=${uploadKey}`); try { let savedCount = 0; let failedCount = 0; const savedIds: string[] = []; const normalizedTags = normalizeTags(body.tag ?? ["미분류"]); const hasExplicitSelection = body.selected.length > 0; if (isPixiv) { const pixivData = await fetchPixivData(body.url); const mediaUrls: string[] = Array.isArray(pixivData.image_proxy_urls) ? pixivData.image_proxy_urls.filter((url: unknown): url is string => typeof url === "string") : []; if (mediaUrls.length === 0) return status(400, createUploadResponse(false, "No media found in the Pixiv artwork.")); for (const [index, mediaUrl] of mediaUrls.entries()) { if (hasExplicitSelection && !body.selected[index]) continue; const authorId = String(pixivData.author_id || "unknown"); const illustId = String(pixivData.illust_id || `pixiv_${Date.now()}`); const fileName = makePixivFileName(authorId, illustId, mediaUrl, index); const result = await uploadAndCreateWithRetry({ fileName, mediaUrl, mediaIndex: index, createDocument: () => MediaUpload.create({ type: "pixiv", tweet: { id: illustId, url: pixivData.url, title: pixivData.title, authorId, authorName: pixivData.author_name, createDate: pixivData.create_date, }, mediaIndex: index, mediaUrl: `${config.s3.endpoint}/${config.s3.bucket}/${fileName}`, s3Key: fileName, tags: normalizedTags, author: body.author ? body.author : (pixivData.author_name || "unknown"), uploadedBy: { userId: requester.userId, discordId: requester.discordId, username: requester.username, role: requester.role }, }), }); if (!result.ok) { failedCount++; continue; } if (result.id) savedIds.push(result.id); if (result.created) { await saveTags(normalizedTags); savedCount++; } } if (savedCount > 0) { await sendDiscordNotification({ title: pixivData.title || "Pixiv Artwork", url: savedIds[0] ? `${config.base_url}/detail/${savedIds[0]}` : body.url, author: body.author ? body.author : (pixivData.author_name || "unknown"), tags: normalizedTags, imageUrl: mediaUrls[0], }); } } else { const tweetData = await fetchTweetData(body.url); if (!tweetData.tweet) { return status(404, createUploadResponse(false, `트윗을 찾을 수 없거나 비공개 계정입니다. (code: ${tweetData.code ?? "unknown"})`)); } const media = tweetData.tweet.media?.all || tweetData.tweet.media?.photos || []; if (media.length === 0) return status(400, createUploadResponse(false, "트윗에서 이미지를 찾지 못했습니다.")); for (const [index, mediaItem] of media.entries()) { if (hasExplicitSelection && !body.selected[index]) continue; const fileName = makeS3FileName(tweetData.tweet.author.id, tweetData.tweet.id, mediaItem.url, index); const { media: _media, ...tweetWithoutMedia } = tweetData.tweet; const mediaType = (mediaItem.type === "video" || mediaItem.type === "gif" || (mediaItem.format && mediaItem.format.includes("video"))) ? "video" : "image"; const result = await uploadAndCreateWithRetry({ fileName, mediaUrl: mediaItem.url, mediaIndex: index, createDocument: () => MediaUpload.create({ type: "twitter", tweet: tweetWithoutMedia, mediaIndex: index, mediaUrl: `${config.s3.endpoint}/${config.s3.bucket}/${fileName}`, s3Key: fileName, mediaType, thumbnailUrl: mediaItem.thumbnail_url, tags: normalizedTags, author: body.author ? body.author : tweetData.tweet.author.name, uploadedBy: { userId: requester.userId, discordId: requester.discordId, username: requester.username, role: requester.role }, }), }); if (!result.ok) { failedCount++; continue; } if (result.id) savedIds.push(result.id); if (result.created) { await saveTags(normalizedTags); savedCount++; } } if (savedCount > 0) { await sendDiscordNotification({ title: tweetData.tweet.text?.substring(0, 100) || "Twitter Post", url: savedIds[0] ? `${config.base_url}/detail/${savedIds[0]}` : body.url, author: body.author ? body.author : tweetData.tweet.author.name, tags: normalizedTags, imageUrl: media[0].url, }); } } await createAuditLog({ actor: { userId: requester.userId, discordId: requester.discordId, username: requester.username, role: requester.role }, action: `post.upload.${isPixiv ? "pixiv" : "twitter"}`, targetId: requestId, targetType: "post", summary: `${requester.username} uploaded ${isPixiv ? "Pixiv" : "Twitter"} media`, detail: { url: body.url, savedCount, failedCount }, }); if (savedCount === 0) { return status(failedCount > 0 ? 502 : 400, createUploadResponse(false, failedCount > 0 ? "업로드에 실패했습니다. 잠시 후 다시 시도해 주세요." : "선택된 이미지가 없습니다.", { savedCount, failedCount })); } return createUploadResponse(true, "업로드가 완료되었습니다.", { savedCount, failedCount, ids: savedIds }); } catch (error) { console.error(`[Upload aborted] requestId=${requestId} key=${uploadKey}`, error); return status(500, createUploadResponse(false, `Failed to fetch ${isPixiv ? "Pixiv" : "Twitter"} data`)); } finally { inFlightUploads.delete(uploadKey); } }, { body: t.Object({ url: t.String(), tag: t.Optional(t.Array(t.String({ default: "미분류" }))), author: t.Optional(t.String()), selected: t.Array(t.Boolean()), }) }) .post("/upload/direct", async ({ body, status, getAuthenticatedUser }) => { const requester = await getAuthenticatedUser(); if (!requester || (requester.role !== "admin" && requester.role !== "writer")) { return status(401, createUploadResponse(false, "업로드 권한이 없습니다.")); } const fileList = Array.isArray(body.files) ? body.files : [body.files]; if (fileList.length === 0) return status(400, createUploadResponse(false, "업로드할 파일이 없습니다.")); let savedCount = 0; let failedCount = 0; const savedIds: string[] = []; const normalizedTags = normalizeTags(body.tag || ["미분류"]); for (const [index, file] of fileList.entries()) { try { const extension = file.name.split(".").pop(); const fileName = `direct/${crypto.randomUUID()}.${extension}`; const mediaType = file.type.startsWith("video/") ? "video" : "image"; await writeToS3(fileName, file, file.type); const post = await MediaUpload.create({ type: "direct", mediaIndex: index, mediaUrl: `${config.s3.endpoint}/${config.s3.bucket}/${fileName}`, s3Key: fileName, mediaType, tags: normalizedTags, author: body.author || requester.username || "익명", uploadedBy: { userId: requester.userId, discordId: requester.discordId, username: requester.username, role: requester.role }, }); if (post) { savedIds.push(post._id.toString()); savedCount++; } } catch (error) { failedCount++; console.error(`[Direct upload failed] name=${file.name}`, error); } } if (savedCount > 0) { await saveTags(normalizedTags); await createAuditLog({ actor: { userId: requester.userId, discordId: requester.discordId, username: requester.username, role: requester.role }, action: "post.upload.direct", targetType: "post", summary: `${requester.username} uploaded ${savedCount} files directly`, detail: { savedCount, failedCount, ids: savedIds }, }); } return createUploadResponse(true, "업로드가 완료되었습니다.", { savedCount, failedCount, ids: savedIds }); }, { body: t.Object({ files: t.Files(), author: t.Optional(t.String()), tag: t.Optional(t.Array(t.String())), }) }) .post("/bulk-delete", async ({ body, status, getAuthenticatedUser }) => { const requester = await getAuthenticatedUser(); if (!requester) return status(401, "Unauthorized"); if (requester.role !== "admin" && requester.role !== "writer") return status(403, "Forbidden"); const result = await MediaUpload.deleteMany({ _id: { $in: body.ids } }); await createAuditLog({ actor: { userId: requester.userId, discordId: requester.discordId, username: requester.username, role: requester.role }, action: "post.bulkDelete", targetType: "post", summary: `${requester.username} deleted multiple posts`, detail: { ids: body.ids, deletedCount: result.deletedCount }, }); return { deletedCount: result.deletedCount }; }, { body: t.Object({ ids: t.Array(t.String({ minLength: 1 }), { minItems: 1 }) }), }) .get("/random", async ({ status }) => { const count = await MediaUpload.countDocuments(); if (count === 0) return status(404, "포스트를 찾을 수 없습니다."); const randomIndex = Math.floor(Math.random() * count); const randomPost = await MediaUpload.findOne().skip(randomIndex); if (!randomPost) return status(404, "포스트를 찾을 수 없습니다."); return fetch(randomPost.mediaUrl, { cache: "no-store" }); });