From 709a56609e1fdfa895f27bf139af69184da9be99 Mon Sep 17 00:00:00 2001 From: oiov Date: Tue, 8 Jul 2025 21:22:10 +0800 Subject: [PATCH] feat: add admin storage manager --- app/(protected)/admin/storage/loading.tsx | 14 ++ app/(protected)/admin/storage/page.tsx | 39 +++++ app/api/storage/admin/r2/configs/route.ts | 34 +++++ app/api/storage/admin/r2/files/route.ts | 178 ++++++++++++++++++++++ app/api/storage/r2/files/route.ts | 1 + components/file/file-list.tsx | 29 ++-- components/file/index.tsx | 16 +- components/shared/icons.tsx | 1 + config/dashboard.ts | 6 + lib/dto/files.ts | 4 +- locales/en.json | 3 +- locales/zh.json | 3 +- public/sw.js.map | 2 +- 13 files changed, 308 insertions(+), 22 deletions(-) create mode 100644 app/(protected)/admin/storage/loading.tsx create mode 100644 app/(protected)/admin/storage/page.tsx create mode 100644 app/api/storage/admin/r2/configs/route.ts create mode 100644 app/api/storage/admin/r2/files/route.ts diff --git a/app/(protected)/admin/storage/loading.tsx b/app/(protected)/admin/storage/loading.tsx new file mode 100644 index 0000000..2123322 --- /dev/null +++ b/app/(protected)/admin/storage/loading.tsx @@ -0,0 +1,14 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { DashboardHeader } from "@/components/dashboard/header"; + +export default function DashboardRecordsLoading() { + return ( + <> + + + + ); +} diff --git a/app/(protected)/admin/storage/page.tsx b/app/(protected)/admin/storage/page.tsx new file mode 100644 index 0000000..29f6e0f --- /dev/null +++ b/app/(protected)/admin/storage/page.tsx @@ -0,0 +1,39 @@ +import { redirect } from "next/navigation"; + +import { getCurrentUser } from "@/lib/session"; +import { constructMetadata } from "@/lib/utils"; +import { DashboardHeader } from "@/components/dashboard/header"; +import UserFileList from "@/components/file"; + +export const metadata = constructMetadata({ + title: "Cloud Storage", + description: "List and manage cloud storage.", +}); + +export default async function DashboardPage() { + const user = await getCurrentUser(); + + if (!user?.id) redirect("/login"); + + return ( + <> + + + + ); +} diff --git a/app/api/storage/admin/r2/configs/route.ts b/app/api/storage/admin/r2/configs/route.ts new file mode 100644 index 0000000..fa11c71 --- /dev/null +++ b/app/api/storage/admin/r2/configs/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { getMultipleConfigs } from "@/lib/dto/system-config"; +import { checkUserStatus } from "@/lib/dto/user"; +import { getCurrentUser } from "@/lib/session"; + +export async function GET(req: NextRequest) { + try { + const user = checkUserStatus(await getCurrentUser()); + if (user instanceof Response) return user; + if (user.role !== "ADMIN") { + return Response.json("Unauthorized", { + status: 401, + statusText: "Unauthorized", + }); + } + + const configs = await getMultipleConfigs(["s3_config_01"]); + + if (!configs.s3_config_01 || !configs.s3_config_01.enabled) { + return NextResponse.json({ error: "Invalid S3 config" }, { status: 400 }); + } + + return NextResponse.json({ + buckets: configs.s3_config_01.buckets, + enabled: configs.s3_config_01.enabled, + provider_name: configs.s3_config_01.provider_name, + platform: configs.s3_config_01.platform, + channel: configs.s3_config_01.channel, + }); + } catch (error) { + return NextResponse.json({ error: "Error listing files" }, { status: 500 }); + } +} diff --git a/app/api/storage/admin/r2/files/route.ts b/app/api/storage/admin/r2/files/route.ts new file mode 100644 index 0000000..429c55b --- /dev/null +++ b/app/api/storage/admin/r2/files/route.ts @@ -0,0 +1,178 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { getUserFiles, softDeleteUserFiles } from "@/lib/dto/files"; +import { getMultipleConfigs } from "@/lib/dto/system-config"; +import { checkUserStatus } from "@/lib/dto/user"; +import { createS3Client, deleteFile, getSignedUrlForDownload } from "@/lib/r2"; +import { getCurrentUser } from "@/lib/session"; + +export async function GET(req: NextRequest) { + try { + const user = checkUserStatus(await getCurrentUser()); + if (user instanceof Response) return user; + if (user.role !== "ADMIN") { + return Response.json("Unauthorized", { + status: 401, + statusText: "Unauthorized", + }); + } + + const url = new URL(req.url); + const page = url.searchParams.get("page"); + const size = url.searchParams.get("size"); + const bucket = url.searchParams.get("bucket") || ""; + + const configs = await getMultipleConfigs(["s3_config_01"]); + if (!configs.s3_config_01.enabled) { + return NextResponse.json("S3 is not enabled", { + status: 403, + }); + } + if ( + !configs.s3_config_01 || + !configs.s3_config_01.access_key_id || + !configs.s3_config_01.secret_access_key || + !configs.s3_config_01.endpoint + ) { + return NextResponse.json("Invalid S3 config", { + status: 403, + }); + } + const buckets = configs.s3_config_01.buckets || []; + if (!buckets.find((b) => b.bucket === bucket)) { + return NextResponse.json("Bucket does not exist", { + status: 403, + }); + } + + const res = await getUserFiles({ + page: Number(page) || 1, + limit: Number(size) || 20, + bucket, + channel: configs.s3_config_01.channel, + platform: configs.s3_config_01.platform, + }); + + return NextResponse.json(res); + } catch (error) { + console.error("Error listing files:", error); + return NextResponse.json({ error: "Error listing files" }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const user = checkUserStatus(await getCurrentUser()); + if (user instanceof Response) return user; + if (user.role !== "ADMIN") { + return Response.json("Unauthorized", { + status: 401, + statusText: "Unauthorized", + }); + } + + const { key, bucket } = await request.json(); + if (!key || !bucket) { + return NextResponse.json("key and bucket is required", { + status: 400, + }); + } + + const configs = await getMultipleConfigs(["s3_config_01"]); + if (!configs.s3_config_01.enabled) { + return NextResponse.json("S3 is not enabled", { + status: 403, + }); + } + if ( + !configs.s3_config_01 || + !configs.s3_config_01.access_key_id || + !configs.s3_config_01.secret_access_key || + !configs.s3_config_01.endpoint + ) { + return NextResponse.json("Invalid S3 config", { + status: 403, + }); + } + const buckets = configs.s3_config_01.buckets || []; + if (!buckets.find((b) => b.bucket === bucket)) { + return NextResponse.json("Bucket does not exist", { + status: 403, + }); + } + + const signedUrl = await getSignedUrlForDownload( + key, + createS3Client( + configs.s3_config_01.endpoint, + configs.s3_config_01.access_key_id, + configs.s3_config_01.secret_access_key, + ), + bucket, + ); + return NextResponse.json({ signedUrl }); + } catch (error) { + return NextResponse.json( + { error: "Error generating download URL" }, + { status: 500 }, + ); + } +} + +export async function DELETE(request: NextRequest) { + try { + const user = checkUserStatus(await getCurrentUser()); + if (user instanceof Response) return user; + if (user.role !== "ADMIN") { + return Response.json("Unauthorized", { + status: 401, + statusText: "Unauthorized", + }); + } + + const { keys, ids, bucket } = await request.json(); + + if (!keys || !ids || !bucket) { + return NextResponse.json("key and bucket is required", { + status: 400, + }); + } + + const configs = await getMultipleConfigs(["s3_config_01"]); + if (!configs.s3_config_01.enabled) { + return NextResponse.json("S3 is not enabled", { + status: 403, + }); + } + if ( + !configs.s3_config_01 || + !configs.s3_config_01.access_key_id || + !configs.s3_config_01.secret_access_key || + !configs.s3_config_01.endpoint + ) { + return NextResponse.json("Invalid S3 config", { + status: 403, + }); + } + const buckets = configs.s3_config_01.buckets || []; + if (!buckets.find((b) => b.bucket === bucket)) { + return NextResponse.json("Bucket does not exist", { + status: 403, + }); + } + + const R2 = createS3Client( + configs.s3_config_01.endpoint, + configs.s3_config_01.access_key_id, + configs.s3_config_01.secret_access_key, + ); + + for (const key of keys) { + await deleteFile(key, R2, bucket); + } + await softDeleteUserFiles(ids); + return NextResponse.json({ message: "File deleted successfully" }); + } catch (error) { + return NextResponse.json({ error: "Error deleting file" }, { status: 500 }); + } +} diff --git a/app/api/storage/r2/files/route.ts b/app/api/storage/r2/files/route.ts index 8d877d2..81b5f64 100644 --- a/app/api/storage/r2/files/route.ts +++ b/app/api/storage/r2/files/route.ts @@ -44,6 +44,7 @@ export async function GET(req: NextRequest) { limit: Number(size) || 20, bucket, userId: user.id, + status: 1, channel: configs.s3_config_01.channel, platform: configs.s3_config_01.platform, }); diff --git a/components/file/file-list.tsx b/components/file/file-list.tsx index 625f09e..911ddaa 100644 --- a/components/file/file-list.tsx +++ b/components/file/file-list.tsx @@ -53,6 +53,7 @@ import { } from "../ui/dropdown-menu"; import { Modal } from "../ui/modal"; import { Skeleton } from "../ui/skeleton"; +import { Switch } from "../ui/switch"; import { TableCell, TableRow } from "../ui/table"; interface Props { @@ -100,6 +101,8 @@ export default function UserFileList({ const [currentSelectFile, setCurrentSelectFile] = useState(); + const isAdmin = action.includes("/admin"); + const getFileUrl = (key: string) => { return `${bucketInfo.custom_domain}/${key}`; }; @@ -214,7 +217,7 @@ export default function UserFileList({ const renderFileLinks = (file: UserFileData, index: number) => ( <> - {file.shortUrlId && ( + {!isAdmin && file.shortUrlId && (
(
-
+
{showMutiCheckBox && (
)} -
+
{t("Name")}
{t("Size")}
-
{t("Type")}
-
{t("User")}
-
{t("Date")}
+
{t("Type")}
+
{t("User")}
+
{t("Date")}
+
{t("Active")}
{t("Actions")}
{isLoading ? ( @@ -298,7 +302,7 @@ export default function UserFileList({ {files?.list.map((file, index) => (
{showMutiCheckBox && (
@@ -350,12 +354,12 @@ export default function UserFileList({
{formatFileSize(file.size || 0)}
-
+
{file.mimeType || "-"}
-
+
@@ -368,9 +372,12 @@ export default function UserFileList({
-
+
+
+ +
diff --git a/components/file/index.tsx b/components/file/index.tsx index 6d60286..8605f5d 100644 --- a/components/file/index.tsx +++ b/components/file/index.tsx @@ -83,6 +83,8 @@ export default function UserFileManager({ user, action }: FileListProps) { const [selectedFiles, setSelectedFiles] = useState([]); const [isDeleting, startDeleteTransition] = useTransition(); + const isAdmin = action.includes("/admin"); + const { mutate } = useSWRConfig(); const { data: r2Configs, isLoading } = useSWR( @@ -230,12 +232,14 @@ export default function UserFileManager({ user, action }: FileListProps) { )} - + {!isAdmin && ( + + )}