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) {

View file

@ -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<Me["role"] | "guest">("guest");
const [loadingRole, setLoadingRole] = useState(true);
const [existingDetailId, setExistingDetailId] = useState<string | null>(null);
const [uploadMode, setUploadMode] = useState<"url" | "direct">("url");
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) {
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() {
</div>
</div>
<form className="mt-5 space-y-4" onSubmit={submit}>
<form className="mt-5 space-y-6" onSubmit={submit}>
{!loadingRole && !canManagePost ? (
<div className="border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
. writer admin .
</div>
) : null}
<div className="space-y-2">
<label className="block text-sm text-foreground/80" htmlFor="url">URL</label>
<div className="flex flex-col gap-2 sm:flex-row">
<input
id="url"
type="url"
value={url}
onChange={(event) => {
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
/>
</div>
<p className="text-xs text-foreground/55">URL .</p>
<div className="flex gap-6 border-b border-border/40">
<button
type="button"
onClick={() => {
setUploadMode("url");
resetPreview();
}}
className={`pb-2 text-sm font-medium transition ${uploadMode === "url" ? "border-b-2 border-foreground text-foreground" : "text-foreground/40 hover:text-foreground/60"}`}
>
URL
</button>
<button
type="button"
onClick={() => {
setUploadMode("direct");
resetPreview();
}}
className={`pb-2 text-sm font-medium transition ${uploadMode === "direct" ? "border-b-2 border-foreground text-foreground" : "text-foreground/40 hover:text-foreground/60"}`}
>
</button>
</div>
{uploadMode === "url" ? (
<div className="space-y-2">
<label className="block text-sm text-foreground/80" htmlFor="url">URL</label>
<div className="flex flex-col gap-2 sm:flex-row">
<input
id="url"
type="url"
value={url}
onChange={(event) => {
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"}
/>
</div>
<p className="text-xs text-foreground/55">URL .</p>
</div>
) : (
<div className="space-y-2">
<label className="block text-sm text-foreground/80"> </label>
<div
className="flex flex-col items-center justify-center gap-3 border-2 border-dashed border-border/60 bg-white/5 py-12 transition hover:border-foreground/30 hover:bg-white/10 cursor-pointer"
onClick={() => fileInputRef.current?.click()}
>
<svg className="h-10 w-10 text-foreground/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 4v16m8-8H4" />
</svg>
<div className="text-center">
<p className="text-sm font-medium"> </p>
<p className="mt-1 text-xs text-foreground/40"> .</p>
</div>
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*,video/*"
className="hidden"
onChange={handleFileChange}
/>
</div>
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<label className="block text-sm text-foreground/80" htmlFor="author"></label>