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

@ -67,4 +67,11 @@ async function uploadToS3(fileName: string, mediaUrl: string, maxRetry = 3) {
throw lastError; 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 { fetchTweetData } from "@/lib/tweet";
import { fetchPixivData } from "@/lib/pixiv"; import { fetchPixivData } from "@/lib/pixiv";
import { checkExistingPostByUrl } from "@/lib/post"; 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>(); 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 } }) => { .post("/bulk-delete", async ({ body, status, jwt, cookie: { mizuki } }) => {
const rawToken = mizuki.value; const rawToken = mizuki.value;
if (typeof rawToken !== "string" || rawToken.length === 0) { if (typeof rawToken !== "string" || rawToken.length === 0) {

View file

@ -1,15 +1,16 @@
"use client"; "use client";
import { FormEvent, useEffect, useMemo, useState } from "react"; import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Header from "../../components/header"; import Header from "../../components/header";
type SourceType = "twitter" | "pixiv"; type SourceType = "twitter" | "pixiv" | "direct";
type PreviewItem = { type PreviewItem = {
url: string; url: string;
type?: "image" | "video"; type?: "image" | "video";
file?: File;
}; };
type TweetApiResponse = { type TweetApiResponse = {
@ -88,6 +89,8 @@ export default function AddPage() {
const [viewerRole, setViewerRole] = useState<Me["role"] | "guest">("guest"); const [viewerRole, setViewerRole] = useState<Me["role"] | "guest">("guest");
const [loadingRole, setLoadingRole] = useState(true); const [loadingRole, setLoadingRole] = useState(true);
const [existingDetailId, setExistingDetailId] = useState<string | null>(null); const [existingDetailId, setExistingDetailId] = useState<string | null>(null);
const [uploadMode, setUploadMode] = useState<"url" | "direct">("url");
const fileInputRef = useRef<HTMLInputElement>(null);
const selectedCount = useMemo( const selectedCount = useMemo(
() => selected.filter(Boolean).length, () => selected.filter(Boolean).length,
@ -133,6 +136,9 @@ export default function AddPage() {
}, []); }, []);
function resetPreview() { function resetPreview() {
previewItems.forEach((item) => {
if (item.file) URL.revokeObjectURL(item.url);
});
setPreviewItems([]); setPreviewItems([]);
setSelected([]); setSelected([]);
setSourceType(null); setSourceType(null);
@ -140,6 +146,7 @@ export default function AddPage() {
setError(null); setError(null);
setSuccess(null); setSuccess(null);
setExistingDetailId(null); setExistingDetailId(null);
if (fileInputRef.current) fileInputRef.current.value = "";
} }
async function fetchPreview(targetUrl?: string) { 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(() => { useEffect(() => {
if (loadingPreview) { if (loadingPreview) {
return; return;
@ -292,7 +314,24 @@ export default function AddPage() {
setSubmitting(true); setSubmitting(true);
try { try {
const tags = splitTags(tagsText); const tags = splitTags(tagsText);
const response = await fetch("/api/post/upload", { 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", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -304,6 +343,7 @@ export default function AddPage() {
selected, selected,
}), }),
}); });
}
let data: UploadApiResponse | null = null; let data: UploadApiResponse | null = null;
try { try {
@ -359,13 +399,37 @@ export default function AddPage() {
</div> </div>
</div> </div>
<form className="mt-5 space-y-4" onSubmit={submit}> <form className="mt-5 space-y-6" onSubmit={submit}>
{!loadingRole && !canManagePost ? ( {!loadingRole && !canManagePost ? (
<div className="border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"> <div className="border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
. writer admin . . writer admin .
</div> </div>
) : null} ) : null}
<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"> <div className="space-y-2">
<label className="block text-sm text-foreground/80" htmlFor="url">URL</label> <label className="block text-sm text-foreground/80" htmlFor="url">URL</label>
<div className="flex flex-col gap-2 sm:flex-row"> <div className="flex flex-col gap-2 sm:flex-row">
@ -383,11 +447,36 @@ export default function AddPage() {
placeholder="https://x.com/... or https://www.pixiv.net/artworks/..." 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" 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} disabled={loadingPreview}
required required={uploadMode === "url"}
/> />
</div> </div>
<p className="text-xs text-foreground/55">URL .</p> <p className="text-xs text-foreground/55">URL .</p>
</div> </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="grid gap-4 sm:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">