akiyama.mizuki.guru/apps/backend/src/routes/post.ts

755 lines
28 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 } from "@/lib/s3";
const inFlightUploads = new Set<string>();
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, "success" | "message">): UploadResponse {
return {
success: true,
message,
...extra,
};
}
function uploadError(message: string, extra?: Omit<UploadResponse, "success" | "message">): 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<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 };
}
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" });
});