feat: implement direct file upload functionality with S3 integration

This commit is contained in:
암냥 2026-04-23 19:46:43 +09:00
commit b9a522c6d7
No known key found for this signature in database
3 changed files with 220 additions and 39 deletions

View file

@ -37,7 +37,7 @@ async function uploadToS3(fileName: string, mediaUrl: string, maxRetry = 3) {
}
const buffer = await response.arrayBuffer();
// 2. Bun S3 Client만 사용하여 쓰기
// client.write는 내부적으로 효율적인 스트리밍/버퍼 처리를 수행합니다.
await client.write(fileName, buffer, {
@ -67,4 +67,11 @@ async function uploadToS3(fileName: string, mediaUrl: string, maxRetry = 3) {
throw lastError;
}
export { makeS3FileName, uploadToS3, client as s3Client };
async function writeToS3(fileName: string, data: ArrayBuffer | Blob, contentType: string) {
await warmupS3();
await client.write(fileName, data, {
type: contentType,
});
}
export { makeS3FileName, uploadToS3, writeToS3, client as s3Client };

View file

@ -9,7 +9,7 @@ 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";
import { makeS3FileName, s3Client, uploadToS3, writeToS3 } from "@/lib/s3";
const inFlightUploads = new Set<string>();
@ -685,6 +685,91 @@ export default new Elysia({ prefix: "/post" })
})
})
.post("/upload/direct", async ({ body, status, request }) => {
const requester = (request as any).requester;
if (!requester || (requester.role !== "admin" && requester.role !== "writer")) {
return status(401, uploadError("업로드 권한이 없습니다."));
}
const { files, author, tag } = body;
const fileList = Array.isArray(files) ? files : [files];
if (fileList.length === 0) {
return status(400, uploadError("업로드할 파일이 없습니다."));
}
let savedCount = 0;
let failedCount = 0;
const savedIds: string[] = [];
const normalizedTags = normalizeTags(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: author || requester.username || "익명",
uploadedBy: {
userId: requester.userId,
discordId: requester.discordId,
username: requester.username,
role: requester.role,
},
});
if (post) {
savedIds.push(post._id.toString());
savedCount += 1;
}
} catch (error) {
failedCount += 1;
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 uploadOk("업로드가 완료되었습니다.", {
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, jwt, cookie: { mizuki } }) => {
const rawToken = mizuki.value;
if (typeof rawToken !== "string" || rawToken.length === 0) {