feat: implement direct file upload functionality with S3 integration
This commit is contained in:
parent
3519e1307b
commit
b9a522c6d7
3 changed files with 220 additions and 39 deletions
|
|
@ -37,7 +37,7 @@ async function uploadToS3(fileName: string, mediaUrl: string, maxRetry = 3) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const buffer = await response.arrayBuffer();
|
const buffer = await response.arrayBuffer();
|
||||||
|
|
||||||
// 2. Bun S3 Client만 사용하여 쓰기
|
// 2. Bun S3 Client만 사용하여 쓰기
|
||||||
// client.write는 내부적으로 효율적인 스트리밍/버퍼 처리를 수행합니다.
|
// client.write는 내부적으로 효율적인 스트리밍/버퍼 처리를 수행합니다.
|
||||||
await client.write(fileName, buffer, {
|
await client.write(fileName, buffer, {
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,18 +314,36 @@ 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;
|
||||||
method: "POST",
|
|
||||||
headers: {
|
if (uploadMode === "direct") {
|
||||||
"Content-Type": "application/json",
|
const formData = new FormData();
|
||||||
},
|
previewItems.forEach((item, index) => {
|
||||||
body: JSON.stringify({
|
if (selected[index] && item.file) {
|
||||||
url: url.trim(),
|
formData.append("files", item.file);
|
||||||
author: author.trim() || undefined,
|
}
|
||||||
tag: tags.length > 0 ? tags : undefined,
|
});
|
||||||
selected,
|
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;
|
let data: UploadApiResponse | null = null;
|
||||||
try {
|
try {
|
||||||
|
|
@ -359,36 +399,85 @@ 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="space-y-2">
|
<div className="flex gap-6 border-b border-border/40">
|
||||||
<label className="block text-sm text-foreground/80" htmlFor="url">URL</label>
|
<button
|
||||||
<div className="flex flex-col gap-2 sm:flex-row">
|
type="button"
|
||||||
<input
|
onClick={() => {
|
||||||
id="url"
|
setUploadMode("url");
|
||||||
type="url"
|
resetPreview();
|
||||||
value={url}
|
}}
|
||||||
onChange={(event) => {
|
className={`pb-2 text-sm font-medium transition ${uploadMode === "url" ? "border-b-2 border-foreground text-foreground" : "text-foreground/40 hover:text-foreground/60"}`}
|
||||||
setUrl(event.target.value);
|
>
|
||||||
|
URL 가져오기
|
||||||
if (previewItems.length > 0 || sourceType) {
|
</button>
|
||||||
resetPreview();
|
<button
|
||||||
}
|
type="button"
|
||||||
}}
|
onClick={() => {
|
||||||
placeholder="https://x.com/... or https://www.pixiv.net/artworks/..."
|
setUploadMode("direct");
|
||||||
className="w-full border-b border-border bg-transparent px-2 py-2 text-sm outline-none ring-0 transition focus:border-foreground/40"
|
resetPreview();
|
||||||
disabled={loadingPreview}
|
}}
|
||||||
required
|
className={`pb-2 text-sm font-medium transition ${uploadMode === "direct" ? "border-b-2 border-foreground text-foreground" : "text-foreground/40 hover:text-foreground/60"}`}
|
||||||
/>
|
>
|
||||||
</div>
|
직접 업로드
|
||||||
<p className="text-xs text-foreground/55">URL 변경 시 기존 미리보기는 자동으로 초기화됩니다.</p>
|
</button>
|
||||||
</div>
|
</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="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block text-sm text-foreground/80" htmlFor="author">작가</label>
|
<label className="block text-sm text-foreground/80" htmlFor="author">작가</label>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue