diff --git a/apps/backend/src/lib/s3.ts b/apps/backend/src/lib/s3.ts index e4cadba..16e368f 100644 --- a/apps/backend/src/lib/s3.ts +++ b/apps/backend/src/lib/s3.ts @@ -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 }; \ No newline at end of file +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 }; \ No newline at end of file diff --git a/apps/backend/src/routes/post.ts b/apps/backend/src/routes/post.ts index d921751..6a13617 100644 --- a/apps/backend/src/routes/post.ts +++ b/apps/backend/src/routes/post.ts @@ -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(); @@ -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) { diff --git a/apps/frontend/src/app/add/page.tsx b/apps/frontend/src/app/add/page.tsx index 6c97cb5..7260793 100644 --- a/apps/frontend/src/app/add/page.tsx +++ b/apps/frontend/src/app/add/page.tsx @@ -1,15 +1,16 @@ "use client"; -import { FormEvent, useEffect, useMemo, useState } from "react"; +import { FormEvent, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import Header from "../../components/header"; -type SourceType = "twitter" | "pixiv"; +type SourceType = "twitter" | "pixiv" | "direct"; type PreviewItem = { url: string; type?: "image" | "video"; + file?: File; }; type TweetApiResponse = { @@ -88,6 +89,8 @@ export default function AddPage() { const [viewerRole, setViewerRole] = useState("guest"); const [loadingRole, setLoadingRole] = useState(true); const [existingDetailId, setExistingDetailId] = useState(null); + const [uploadMode, setUploadMode] = useState<"url" | "direct">("url"); + const fileInputRef = useRef(null); const selectedCount = useMemo( () => selected.filter(Boolean).length, @@ -133,6 +136,9 @@ export default function AddPage() { }, []); function resetPreview() { + previewItems.forEach((item) => { + if (item.file) URL.revokeObjectURL(item.url); + }); setPreviewItems([]); setSelected([]); setSourceType(null); @@ -140,6 +146,7 @@ export default function AddPage() { setError(null); setSuccess(null); setExistingDetailId(null); + if (fileInputRef.current) fileInputRef.current.value = ""; } async function fetchPreview(targetUrl?: string) { @@ -243,6 +250,21 @@ export default function AddPage() { } } + function handleFileChange(e: React.ChangeEvent) { + const files = Array.from(e.target.files || []); + if (files.length === 0) return; + + setSourceType("direct"); + const newItems: PreviewItem[] = files.map((file) => ({ + url: URL.createObjectURL(file), + type: file.type.startsWith("video/") ? "video" : "image", + file, + })); + + setPreviewItems((prev) => [...prev, ...newItems]); + setSelected((prev) => [...prev, ...newItems.map(() => true)]); + } + useEffect(() => { if (loadingPreview) { return; @@ -292,18 +314,36 @@ export default function AddPage() { setSubmitting(true); try { const tags = splitTags(tagsText); - const response = await fetch("/api/post/upload", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - url: url.trim(), - author: author.trim() || undefined, - tag: tags.length > 0 ? tags : undefined, - selected, - }), - }); + let response: Response; + + if (uploadMode === "direct") { + const formData = new FormData(); + previewItems.forEach((item, index) => { + if (selected[index] && item.file) { + formData.append("files", item.file); + } + }); + if (author.trim()) formData.append("author", author.trim()); + tags.forEach((t) => formData.append("tag", t)); + + response = await fetch("/api/post/upload/direct", { + method: "POST", + body: formData, + }); + } else { + response = await fetch("/api/post/upload", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + url: url.trim(), + author: author.trim() || undefined, + tag: tags.length > 0 ? tags : undefined, + selected, + }), + }); + } let data: UploadApiResponse | null = null; try { @@ -359,36 +399,85 @@ export default function AddPage() { -
+ {!loadingRole && !canManagePost ? (
업로드 권한이 없습니다. writer 또는 admin 권한이 필요합니다.
) : null} -
- -
- { - setUrl(event.target.value); - - if (previewItems.length > 0 || sourceType) { - resetPreview(); - } - }} - placeholder="https://x.com/... or https://www.pixiv.net/artworks/..." - className="w-full border-b border-border bg-transparent px-2 py-2 text-sm outline-none ring-0 transition focus:border-foreground/40" - disabled={loadingPreview} - required - /> -
-

URL 변경 시 기존 미리보기는 자동으로 초기화됩니다.

+
+ +
+ {uploadMode === "url" ? ( +
+ +
+ { + setUrl(event.target.value); + + if (previewItems.length > 0 || sourceType) { + resetPreview(); + } + }} + placeholder="https://x.com/... or https://www.pixiv.net/artworks/..." + className="w-full border-b border-border bg-transparent px-2 py-2 text-sm outline-none ring-0 transition focus:border-foreground/40" + disabled={loadingPreview} + required={uploadMode === "url"} + /> +
+

URL 변경 시 기존 미리보기는 자동으로 초기화됩니다.

+
+ ) : ( +
+ +
fileInputRef.current?.click()} + > + + + +
+

클릭하여 이미지나 영상을 선택하세요

+

여러 파일을 동시에 선택할 수 있습니다.

+
+ +
+
+ )} +