568 lines
No EOL
24 KiB
TypeScript
568 lines
No EOL
24 KiB
TypeScript
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<string>();
|
|
|
|
// ==========================================
|
|
// 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, "success" | "message">
|
|
): 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<any>;
|
|
}) {
|
|
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" });
|
|
}); |