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 } from "@/lib/s3"; const inFlightUploads = new Set(); 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; }; function uploadOk(message: string, extra?: Omit): UploadResponse { return { success: true, message, ...extra, }; } function uploadError(message: string, extra?: Omit): UploadResponse { return { success: false, message, ...extra, }; } function existsResponse(message: string, exists: boolean, source: ExistsResponse["source"], postId: string | null, documentId: string | null): ExistsResponse { return { success: true, message, exists, source, postId, documentId, }; } function buildUploadKey(url: string, selected: boolean[]) { 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}`; } 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() }, }, ), ), ); } } function makePixivFileName(authorId: string, illustId: string, mediaUrl: string, index: number) { const rawName = mediaUrl.split("/").pop() || `media_${Date.now()}_${index}`; const withoutQuery = rawName.split("?")[0]?.split("#")[0] || `media_${Date.now()}_${index}`; const safeName = withoutQuery.replace(/[^a-zA-Z0-9._-]/g, "_"); return `pixiv/${authorId}/${illustId}/${safeName || `media_${Date.now()}_${index}`}`; } 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 }; } 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, s-maxage=0"; set.headers["Pragma"] = "no-cache"; set.headers["Expires"] = "0"; set.headers["Surrogate-Control"] = "no-store"; }) .get("/total", async ({ query }) => { const filterTags = normalizeQueryTags(query.tags); const filter = filterTags.length > 0 ? { tags: { $in: filterTags } } : {}; const count = await MediaUpload.countDocuments(filter); return count; }, { query: t.Object({ tags: t.Optional(t.Union([t.String(), t.Array(t.String())])), }) }) .get("/list", async ({ query }) => { const page = query.page; const pageSize = query.size || 10; const filterTags = normalizeQueryTags(query.tags); const filter = filterTags.length > 0 ? { tags: { $in: filterTags } } : {}; const uploads = await MediaUpload.find(filter) .sort({ createdAt: -1 }) .skip((page - 1) * pageSize) .limit(pageSize); return uploads; }, { 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 () => { const tags = await Tag.find().sort({ usageCount: -1, lastUsedAt: -1 }); return tags; }) .get("/exists", async ({ query }) => { const result = await checkExistingPostByUrl(query.url); return existsResponse( result.exists ? "이미 저장된 게시물입니다." : "저장된 게시물이 없습니다.", result.exists, result.source, result.postId, 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, jwt, cookie: { mizuki } }) => { const rawToken = mizuki.value; if (typeof rawToken !== "string" || rawToken.length === 0) { return status(401, "Unauthorized"); } const payload = await jwt.verify(rawToken); if (!payload || typeof payload !== "object" || !("id" in payload) || typeof payload.id !== "string") { return status(401, "Unauthorized"); } const requester = await User.findOne({ userId: payload.id }); 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) { 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 "삭제되었습니다."; } else { return status(404, "포스트를 찾을 수 없습니다."); } }, { params: t.Object({ id: t.String(), }) }) .patch("/edit/:id", async ({ params, body, status, jwt, cookie: { mizuki } }) => { const rawToken = mizuki.value; if (typeof rawToken !== "string" || rawToken.length === 0) { return status(401, "Unauthorized"); } const payload = await jwt.verify(rawToken); if (!payload || typeof payload !== "object" || !("id" in payload) || typeof payload.id !== "string") { return status(401, "Unauthorized"); } const requester = await User.findOne({ userId: payload.id }); 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, jwt, cookie: { mizuki } }) => { const rawToken = mizuki.value; if (typeof rawToken !== "string" || rawToken.length === 0) { return status(401, "Unauthorized"); } const payload = await jwt.verify(rawToken); if (!payload || typeof payload !== "object" || !("id" in payload) || typeof payload.id !== "string") { return status(401, "Unauthorized"); } const requester = await User.findOne({ userId: payload.id }); 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 uploadOk("이미 저장된 게시물입니다.", { savedCount: 0, failedCount: 0, ids: existingPost.documentId ? [existingPost.documentId] : [], }); } if (body.url.startsWith("https://www.pixiv.net/")) { 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, uploadError("이미 처리 중인 업로드입니다.")); } inFlightUploads.add(uploadKey); console.log(`[Pixiv upload started] requestId=${requestId} key=${uploadKey}`); try { 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, uploadError("No media found in the Pixiv artwork.")); } const normalizedTags = normalizeTags(body.tag ?? ["미분류"]); let savedCount = 0; let failedCount = 0; const hasExplicitSelection = body.selected.length > 0; const savedIds: string[] = []; for (const [index, mediaUrl] of mediaUrls.entries()) { const isSelected = hasExplicitSelection ? body.selected[index] === true : true; if (!isSelected) { 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: async () => { return await 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 += 1; console.error(`[Pixiv upload failed] index=${index} url=${mediaUrl} key=${fileName}`, result.error); continue; } if (result.id) { savedIds.push(result.id); } if (result.created) { await saveTags(normalizedTags); savedCount += 1; } } if (savedCount === 0 && failedCount === 0) { console.warn("No Pixiv media uploaded: selected[] did not include any upload target."); } console.log(`Saved ${savedCount} Pixiv media records to MongoDB. Failed: ${failedCount}`); await createAuditLog({ actor: { userId: requester.userId, discordId: requester.discordId, username: requester.username, role: requester.role, }, action: "post.upload.pixiv", targetId: pixivData.illust_id, targetType: "post", summary: `${requester.username} uploaded Pixiv media`, detail: { url: body.url, savedCount, failedCount, }, }); console.log(`[Pixiv upload finished] requestId=${requestId} key=${uploadKey}`); if (savedCount === 0 && failedCount > 0) { return status(502, uploadError("업로드에 실패했습니다. 잠시 후 다시 시도해 주세요.", { savedCount, failedCount, })); } if (savedCount === 0) { return status(400, uploadError("선택된 이미지가 없습니다.", { savedCount, failedCount, })); } return uploadOk("업로드가 완료되었습니다.", { savedCount, failedCount, ids: savedIds, }); } catch (error) { console.error(`[Pixiv upload aborted] requestId=${requestId} key=${uploadKey}`, error); return status(500, uploadError("Failed to fetch Pixiv data")); } finally { inFlightUploads.delete(uploadKey); } } else if (body.url.startsWith("https://x.com/") || body.url.startsWith("https://twitter.com/") || body.url.startsWith("https://fxtwitter.com/") || body.url.startsWith("https://fixupx.com/") || body.url.startsWith("https://vxwitter.com/")) { 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, uploadError("이미 처리 중인 업로드입니다.")); } inFlightUploads.add(uploadKey); console.log(`[Upload started] requestId=${requestId} key=${uploadKey}`); try { const tweetData = await fetchTweetData(body.url); let savedCount = 0; let failedCount = 0; if (tweetData.tweet) { const media = tweetData.tweet.media.all || tweetData.tweet.media.photos || []; if (media.length > 0) { const hasExplicitSelection = body.selected.length > 0; const savedIds: string[] = []; for (const [index, mediaItem] of media.entries()) { const isSelected = hasExplicitSelection ? body.selected[index] === true : true; if (!isSelected) { continue; } const fileName = makeS3FileName(tweetData.tweet.author.id, tweetData.tweet.id, mediaItem.url, index); const { media: _media, ...tweetWithoutMedia } = tweetData.tweet; const normalizedTags = normalizeTags(body.tag || ["미분류"]); 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: async () => { return await 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 += 1; console.error(`[Upload failed] index=${index} url=${mediaItem.url} key=${fileName}`, result.error); continue; } if (result.id) { savedIds.push(result.id); } if (result.created) { await saveTags(normalizedTags); savedCount += 1; } } if (savedCount === 0 && failedCount === 0) { console.warn("No media uploaded: selected[] did not include any upload target."); } console.log(`Saved ${savedCount} media records to MongoDB. Failed: ${failedCount}`); await createAuditLog({ actor: { userId: requester.userId, discordId: requester.discordId, username: requester.username, role: requester.role, }, action: "post.upload.twitter", targetId: tweetData.tweet.id, targetType: "post", summary: `${requester.username} uploaded Twitter media`, detail: { url: body.url, savedCount, failedCount, }, }); } else { return status(400, uploadError("트윗에서 이미지를 찾지 못했습니다.")); } } console.log(`[Upload finished] requestId=${requestId} key=${uploadKey}`); if (savedCount === 0 && failedCount > 0) { return status(502, uploadError("업로드에 실패했습니다. 잠시 후 다시 시도해 주세요.", { savedCount, failedCount, })); } if (savedCount === 0) { return status(400, uploadError("선택된 이미지가 없습니다.", { savedCount, failedCount, })); } return uploadOk("업로드가 완료되었습니다.", { savedCount, failedCount, ids: savedIds, }); } catch (error) { console.error(`[Upload aborted] requestId=${requestId} key=${uploadKey}`, error); console.error(error); return status(500, uploadError("Failed to fetch tweet data")); } finally { inFlightUploads.delete(uploadKey); } } else { return status(400, uploadError("어...")); } }, { 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("/bulk-delete", async ({ body, status, jwt, cookie: { mizuki } }) => { const rawToken = mizuki.value; if (typeof rawToken !== "string" || rawToken.length === 0) { return status(401, "Unauthorized"); } const payload = await jwt.verify(rawToken); if (!payload || typeof payload !== "object" || !("id" in payload) || typeof payload.id !== "string") { return status(401, "Unauthorized"); } const requester = await User.findOne({ userId: payload.id }); if (!requester) { return status(401, "Unauthorized"); } if (requester.role !== "admin" && requester.role !== "writer") { return status(403, "Forbidden"); } if (!Array.isArray(body.ids) || body.ids.length === 0) { return status(400, "삭제할 게시물이 없습니다."); } 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(); console.log(`Total posts count: ${count}`); if (count === 0) { return status(404, "포스트를 찾을 수 없습니다."); } const randomIndex = Math.floor(Math.random() * count); console.log(`Random index: ${randomIndex}`); const randomPost = await MediaUpload.findOne().skip(randomIndex); console.log(`Random post: ${randomPost?._id}`); if (!randomPost) { return status(404, "포스트를 찾을 수 없습니다."); } return fetch(randomPost.mediaUrl, { cache: "no-store" }); });