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
|
|
@ -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 };
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,7 +314,24 @@ export default function AddPage() {
|
|||
setSubmitting(true);
|
||||
try {
|
||||
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",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
|
@ -304,6 +343,7 @@ export default function AddPage() {
|
|||
selected,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
let data: UploadApiResponse | null = null;
|
||||
try {
|
||||
|
|
@ -359,13 +399,37 @@ 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="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">
|
||||
|
|
@ -383,11 +447,36 @@ export default function AddPage() {
|
|||
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
|
||||
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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue