feat: add s3 crud and s3 confgs

This commit is contained in:
oiov
2025-07-02 17:25:07 +08:00
parent 5890b78b5b
commit f90ccca8ba
22 changed files with 2407 additions and 20 deletions

View File

@@ -7,6 +7,7 @@ import { DashboardHeader } from "@/components/dashboard/header";
import AppConfigs from "./app-configs";
import DomainList from "./domain-list";
import PlanList from "./plan-list";
import S3Configs from "./s3-list";
export const metadata = constructMetadata({
title: "System Settings",
@@ -22,6 +23,7 @@ export default async function DashboardPage() {
<>
<DashboardHeader heading="System Settings" text="" />
<AppConfigs />
<S3Configs />
<DomainList
user={{
id: user.id,

View File

@@ -0,0 +1,300 @@
"use client";
import { use, useEffect, useMemo, useState, useTransition } from "react";
import Link from "next/link";
import { CloudCog } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import useSWR from "swr";
import { CloudStorageCredentials } from "@/lib/r2";
import { cn, fetcher } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { Icons } from "@/components/shared/icons";
export default function S3Configs({}: {}) {
const t = useTranslations("Setting");
const [isPending, startTransition] = useTransition();
const [isCheckingR2Config, startCheckR2Transition] = useTransition();
const [isCheckingCOSConfig, startCheckCOSTransition] = useTransition();
const [isCheckedR2Config, setIsCheckedR2Config] = useState(false);
const [isCheckedCOSConfig, setIsCheckedCOSConfig] = useState(false);
const [r2Credentials, setR2Credentials] = useState<CloudStorageCredentials>({
platform: "cloudflare",
channel: "r2",
provider_name: "Cloudflare R2",
account_id: "",
access_key_id: "",
secret_access_key: "",
bucket: "",
endpoint: "",
region: "auto",
custom_domain: "",
prefix: "",
enabled: true,
file_types: "",
});
const {
data: configs,
isLoading,
mutate,
} = useSWR<Record<string, any>>("/api/admin/s3", fetcher);
useEffect(() => {
if (configs) {
setR2Credentials(configs.s3_config_01);
}
}, [configs]);
const handleSaveConfigs = (value: any, key: string, type: string) => {
startTransition(async () => {
const res = await fetch("/api/admin/s3", {
method: "POST",
body: JSON.stringify({ key, value, type }),
});
if (res.ok) {
toast.success("Saved");
mutate();
} else {
toast.error("Failed to save", {
description: await res.text(),
});
}
});
};
const handleR2CheckAccess = async (event) => {
event.preventDefault();
event.stopPropagation();
toast.success("Checking");
};
const handleCOSCheckAccess = async (event) => {
event.preventDefault();
event.stopPropagation();
toast.success("Checking");
};
const canSaveR2Credentials = useMemo(() => {
if (!configs) return true;
return Object.keys(r2Credentials).some(
(key) => r2Credentials[key] !== configs.s3_config_01[key],
);
}, [r2Credentials, configs]);
const ReadyBadge = (
isChecked: boolean,
isChecking: boolean,
type: string,
) => (
<Badge
className={cn("ml-auto text-xs font-semibold")}
variant={isChecked ? "green" : "default"}
onClick={(event) =>
type === "r2" ? handleR2CheckAccess(event) : handleCOSCheckAccess(event)
}
>
{isChecking && <Icons.spinner className="mr-1 size-3 animate-spin" />}
{isChecked && !isChecking && <Icons.check className="mr-1 size-3" />}
{isChecked ? t("Verified") : t("Verify Configuration")}
</Badge>
);
if (isLoading) {
return <Skeleton className="h-48 w-full rounded-lg" />;
}
return (
<Card>
<Collapsible className="group">
<CollapsibleTrigger className="flex w-full items-center justify-between bg-neutral-50 px-4 py-5 dark:bg-neutral-900">
<div className="text-lg font-bold">{t("Cloud Storage Configs")}</div>
<Icons.chevronDown className="ml-auto size-4" />
<CloudCog className="ml-3 size-4 transition-all group-hover:scale-110" />
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 bg-neutral-100 p-4 dark:bg-neutral-800">
<Collapsible>
<CollapsibleTrigger className="flex w-full items-center justify-between">
<div className="font-semibold">Cloudflare R2</div>
{ReadyBadge(isCheckedR2Config, isCheckingR2Config, "r2")}
<Icons.chevronDown className="ml-3 size-4" />
</CollapsibleTrigger>
<CollapsibleContent className="mt-3 space-y-4 rounded-lg border p-6 shadow-md">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1">
<Label>Endpoint</Label>
<Input
value={r2Credentials.endpoint}
placeholder="https://<account_id>.r2.cloudflarestorage.com"
onChange={(e) =>
setR2Credentials({
...r2Credentials,
endpoint: e.target.value,
})
}
/>
</div>
<div className="space-y-1">
<Label>Access Key ID</Label>
<Input
value={r2Credentials.access_key_id}
onChange={(e) =>
setR2Credentials({
...r2Credentials,
access_key_id: e.target.value,
})
}
/>
</div>
<div className="space-y-1">
<Label>Secret Access Key</Label>
<Input
value={r2Credentials.secret_access_key}
onChange={(e) =>
setR2Credentials({
...r2Credentials,
secret_access_key: e.target.value,
})
}
/>
</div>
<div className="space-y-1">
<Label>Bucket Name</Label>
<Input
value={r2Credentials.bucket}
placeholder="bucket1,bucket2"
onChange={(e) =>
setR2Credentials({
...r2Credentials,
bucket: e.target.value,
})
}
/>
</div>
<div className="space-y-1">
<Label>Region</Label>
<Input
value={r2Credentials.region}
placeholder="auto"
onChange={(e) =>
setR2Credentials({
...r2Credentials,
region: e.target.value,
})
}
/>
</div>
<div className="space-y-1">
<Label>Custom Domain (Optional)</Label>
<Input
value={r2Credentials.custom_domain}
placeholder="https://example.com,https://example2.com"
onChange={(e) =>
setR2Credentials({
...r2Credentials,
custom_domain: e.target.value,
})
}
/>
</div>
<div className="space-y-1">
<Label>Prefix (Optional)</Label>
<Input
value={r2Credentials.prefix}
placeholder=""
onChange={(e) =>
setR2Credentials({
...r2Credentials,
prefix: e.target.value,
})
}
/>
</div>
<div className="space-y-1">
<Label>File Types</Label>
<Input
value={r2Credentials.file_types}
placeholder="png,jpg"
onChange={(e) =>
setR2Credentials({
...r2Credentials,
file_types: e.target.value,
})
}
/>
</div>
<div className="flex flex-col space-y-2">
<Label>Enabled</Label>
<Switch
checked={r2Credentials.enabled}
onCheckedChange={(e) =>
setR2Credentials({
...r2Credentials,
enabled: e,
})
}
/>
</div>
</div>
<div className="flex items-center justify-between gap-3">
<Link
className="text-sm text-blue-500 hover:underline"
href="/docs/developer/cloud-storage#cloudflare-r2"
target="_blank"
>
{t("How to get the R2 credentials?")}
</Link>
<Button
className="ml-auto"
variant="destructive"
onClick={() => {
setR2Credentials({
platform: "cloudflare",
channel: "r2",
provider_name: "cloudflare",
endpoint: "",
access_key_id: "",
secret_access_key: "",
bucket: "",
region: "",
account_id: "",
custom_domain: "",
prefix: "",
enabled: false,
});
}}
>
{t("Clear")}
</Button>
<Button
disabled={isPending || !canSaveR2Credentials}
onClick={() => {
handleSaveConfigs(r2Credentials, "s3_config_01", "OBJECT");
}}
>
{isPending ? (
<Icons.spinner className="mr-1 size-4 animate-spin" />
) : null}
{t("Save")}
</Button>
</div>
</CollapsibleContent>
</Collapsible>
</CollapsibleContent>
</Collapsible>
</Card>
);
}

View File

@@ -0,0 +1,20 @@
import { Skeleton } from "@/components/ui/skeleton";
import { DashboardHeader } from "@/components/dashboard/header";
export default function DashboardRecordsLoading() {
return (
<>
<DashboardHeader
heading="Manage DNS Records"
text="List and manage records"
/>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-4">
<Skeleton className="h-[102px] w-full rounded-lg" />
<Skeleton className="h-[102px] w-full rounded-lg" />
<Skeleton className="h-[102px] w-full rounded-lg" />
<Skeleton className="h-[102px] w-full rounded-lg" />
</div>
<Skeleton className="h-[400px] w-full rounded-lg" />
</>
);
}

View File

@@ -0,0 +1,30 @@
import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils";
import { DashboardHeader } from "@/components/dashboard/header";
import FileManager from "@/components/shared/file-manager";
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 (
<>
<DashboardHeader
heading="Cloud Storage"
text="List and manage cloud storage."
link="/docs/cloud-storage"
linkText="Cloud Storage"
/>
<FileManager />
</>
);
}

58
app/api/admin/s3/route.ts Normal file
View File

@@ -0,0 +1,58 @@
import { NextRequest } from "next/server";
import {
getMultipleConfigs,
updateSystemConfig,
} 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 });
}
const s3ConfigKeys = [
"s3_config_01",
"s3_config_02",
"s3_config_03",
"s3_config_04",
];
const configs = await getMultipleConfigs(s3ConfigKeys);
return Response.json(configs, { status: 200 });
} catch (error) {
console.error("[Error]", error);
return Response.json(error.message || "Server error", { status: 500 });
}
}
export async function POST(req: NextRequest) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
if (user.role !== "ADMIN") {
return Response.json("Unauthorized", { status: 401 });
}
const { key, value, type } = await req.json();
if (!key || !type) {
return Response.json("key and value is required", { status: 400 });
}
const configs = await getMultipleConfigs([key]);
if (key in configs) {
await updateSystemConfig(key, { value, type });
return Response.json("Success", { status: 200 });
}
return Response.json("Invalid key", { status: 400 });
} catch (error) {
console.error("[Error]", error);
return Response.json(error.message || "Server error", { status: 500 });
}
}

View File

@@ -0,0 +1,36 @@
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;
const configs = await getMultipleConfigs(["s3_config_01"]);
if (
!configs.s3_config_01 ||
!configs.s3_config_01.enabled ||
!configs.s3_config_01.bucket
) {
return NextResponse.json({ error: "Invalid S3 config" }, { status: 400 });
}
return NextResponse.json({
buckets: configs.s3_config_01.bucket.split(","),
custom_domain: configs.s3_config_01.custom_domain.split(","),
prefix: configs.s3_config_01.prefix,
enabled: configs.s3_config_01.enabled,
region: configs.s3_config_01.region,
file_types: configs.s3_config_01.file_types.split(","),
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 });
}
}

View File

@@ -0,0 +1,160 @@
import { NextRequest, NextResponse } from "next/server";
import { getMultipleConfigs } from "@/lib/dto/system-config";
import { checkUserStatus } from "@/lib/dto/user";
import {
createS3Client,
deleteFile,
getSignedUrlForDownload,
listFiles,
} 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;
const url = new URL(req.url);
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.bucket.split(",");
if (!buckets.includes(bucket)) {
return NextResponse.json("Bucket does not exist", {
status: 403,
});
}
const files = await listFiles(
configs.s3_config_01.prefix || "",
createS3Client(
configs.s3_config_01.endpoint,
configs.s3_config_01.access_key_id,
configs.s3_config_01.secret_access_key,
),
bucket,
);
return NextResponse.json(files);
} catch (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;
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.bucket.split(",");
if (!buckets.includes(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;
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.bucket.split(",");
if (!buckets.includes(bucket)) {
return NextResponse.json("Bucket does not exist", {
status: 403,
});
}
await deleteFile(
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({ message: "File deleted successfully" });
} catch (error) {
return NextResponse.json({ error: "Error deleting file" }, { status: 500 });
}
}

View File

@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from "next/server";
import { S3Client } from "@aws-sdk/client-s3";
import { getMultipleConfigs } from "@/lib/dto/system-config";
import { createS3Client, getSignedUrlForUpload } from "@/lib/r2";
export async function POST(request: NextRequest) {
try {
const { fileName, fileType, bucket } = await request.json();
if (!fileName || !fileType || !bucket) {
return NextResponse.json("fileName, fileType 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.bucket.split(",");
if (!buckets.includes(bucket)) {
return NextResponse.json("Bucket does not exist", {
status: 403,
});
}
const signedUrl = await getSignedUrlForUpload(
fileName,
fileType,
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 signed URL" },
{ status: 500 },
);
}
}

View File

@@ -290,7 +290,6 @@ export function PlanForm({
className="flex-1 shadow-inner"
size={32}
type="number"
disabled
{...register("slDomains", { valueAsNumber: true })}
/>
</div>

View File

@@ -0,0 +1,255 @@
"use client";
import React, {
ChangeEvent,
FormEvent,
useEffect,
useRef,
useState,
} from "react";
import useSWR from "swr";
import { ClientStorageCredentials, FileObject } from "@/lib/r2";
import { fetcher } from "@/lib/utils";
export default function FileManager() {
const [files, setFiles] = useState<FileObject[]>([]);
const [file, setFile] = useState<File | null>(null);
const [uploadProgress, setUploadProgress] = useState<number>(0);
const [isUploading, setIsUploading] = useState<boolean>(false);
const abortControllerRef = useRef<AbortController | null>(null);
const [currentBucket, setCurrentBucket] = useState<string>("");
const { data: configs, isLoading } = useSWR<ClientStorageCredentials>(
"/api/s3/r2/configs",
fetcher,
);
useEffect(() => {
if (configs && configs.buckets && configs.buckets.length > 0) {
setCurrentBucket(configs.buckets[0]);
}
if (currentBucket) {
fetchFiles();
}
}, [configs, currentBucket]);
const fetchFiles = async () => {
try {
const response = await fetch(`/api/s3/r2/files?bucket=${currentBucket}`);
const data = await response.json();
setFiles(Array.isArray(data) ? data : []);
} catch (error) {
console.error("Error fetching files:", error);
setFiles([]);
}
};
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
setFile(e.target.files[0]);
}
};
const handleUpload = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!file) return;
setIsUploading(true);
setUploadProgress(0);
abortControllerRef.current = new AbortController();
try {
const response = await fetch("/api/s3/r2/upload", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
fileName: file.name,
fileType: file.type,
bucket: currentBucket,
}),
});
const { signedUrl } = await response.json();
await uploadFileWithProgress(
file,
signedUrl,
abortControllerRef.current.signal,
);
alert("File uploaded successfully!");
setFile(null); // Clear the file input
fetchFiles();
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
console.log("Upload cancelled");
} else {
console.error("Error uploading file:", error);
alert("Error uploading file");
}
} finally {
setIsUploading(false);
setUploadProgress(0);
abortControllerRef.current = null;
}
};
const uploadFileWithProgress = (
file: File,
signedUrl: string,
signal: AbortSignal,
): Promise<void> => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", signedUrl);
xhr.setRequestHeader("Content-Type", file.type);
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const percentComplete = (event.loaded / event.total) * 100;
setUploadProgress(percentComplete);
}
};
xhr.onload = () => {
if (xhr.status === 200) {
resolve();
} else {
reject(new Error(`Upload failed with status ${xhr.status}`));
}
};
xhr.onerror = () => {
reject(new Error("Upload failed"));
};
xhr.send(file);
signal.addEventListener("abort", () => {
xhr.abort();
reject(new Error("Upload cancelled"));
});
});
};
const handleCancelUpload = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
const handleDownload = async (key: string) => {
try {
const response = await fetch("/api/s3/r2/files", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, bucket: currentBucket }),
});
const { signedUrl } = await response.json();
window.open(signedUrl, "_blank");
} catch (error) {
console.error("Error downloading file:", error);
alert("Error downloading file");
}
};
const handleDelete = async (key: string) => {
try {
await fetch("/api/s3/r2/files", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, bucket: currentBucket }),
});
alert("File deleted successfully!");
fetchFiles();
} catch (error) {
console.error("Error deleting file:", error);
alert("Error deleting file");
}
};
return (
<div className="mx-auto mt-24 max-w-2xl rounded-lg bg-white p-6 shadow-lg">
<h1 className="mb-6 text-center text-3xl font-semibold text-gray-600">
Cloudflare R2 with Next.js: Upload, Download, Delete
</h1>
<h2 className="mb-6 text-2xl font-semibold text-gray-800">Upload File</h2>
<form onSubmit={handleUpload} className="mb-8">
<div className="flex items-center space-x-4">
<label className="flex-1">
<input
type="file"
onChange={handleFileChange}
disabled={isUploading}
className="hidden"
id="file-upload"
/>
<div className="cursor-pointer rounded-lg border border-blue-300 bg-blue-50 px-4 py-2 text-blue-500 transition duration-300 hover:bg-blue-100">
{file ? file.name : "Choose a file"}
</div>
</label>
<button
type="submit"
disabled={!file || isUploading}
className="rounded-lg bg-blue-500 px-6 py-2 text-white transition duration-300 hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{isUploading ? "Uploading..." : "Upload"}
</button>
</div>
</form>
{isUploading && (
<div className="mb-8">
<div className="mb-4 h-2.5 w-full rounded-full bg-gray-200">
<div
className="h-2.5 rounded-full bg-blue-600"
style={{ width: `${uploadProgress}%` }}
></div>
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-gray-600">
{uploadProgress.toFixed(2)}% uploaded
</p>
<button
onClick={handleCancelUpload}
className="text-red-500 transition duration-300 hover:text-red-600"
>
Cancel Upload
</button>
</div>
</div>
)}
<h2 className="mb-4 text-2xl font-semibold text-gray-800">Files</h2>
{files.length === 0 ? (
<p className="italic text-gray-500">No files found.</p>
) : (
<ul className="space-y-4">
{files.map((file) => (
<li
key={file.Key}
className="flex items-center justify-between rounded-lg bg-gray-50 p-4"
>
<span className="flex-1 truncate text-gray-700">{file.Key}</span>
<div className="flex space-x-2">
<button
onClick={() => file.Key && handleDownload(file.Key)}
className="text-blue-500 transition duration-300 hover:text-blue-600"
>
Download
</button>
<button
onClick={() => file.Key && handleDelete(file.Key)}
className="text-red-500 transition duration-300 hover:text-red-600"
>
Delete
</button>
</div>
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -15,6 +15,7 @@ import {
ChevronLeft,
ChevronRight,
CirclePlay,
CloudUpload,
Copy,
Crown,
Download,
@@ -87,6 +88,7 @@ export const Icons = {
camera: Camera,
calendar: Calendar,
crown: Crown,
cloudUpload: CloudUpload,
eye: Eye,
lock: LockKeyhole,
list: List,

View File

@@ -10,9 +10,13 @@ export const sidebarLinks: SidebarNavItem[] = [
items: [
{ href: "/dashboard", icon: "dashboard", title: "Dashboard" },
{ href: "/dashboard/urls", icon: "link", title: "Short Urls" },
{ href: "/emails", icon: "mail", title: "Emails" },
{ href: "/dashboard/records", icon: "globe", title: "DNS Records" },
{ href: "/chat", icon: "messages", title: "WRoom" },
{ href: "/emails", icon: "mail", title: "Emails" },
{
href: "/dashboard/storage",
icon: "cloudUpload",
title: "Cloud Storage",
},
],
},
{

View File

@@ -156,6 +156,11 @@ export const docsConfig: DocsConfig = {
href: "/docs/developer/database",
icon: "page",
},
{
title: "Cloud Storage",
href: "/docs/developer/cloud-storage",
icon: "page",
},
{
title: "Telegram Bot",
href: "/docs/developer/telegram-bot",

View File

@@ -0,0 +1,10 @@
---
title: S3 Configs
description: How to config the s3 api.
---
## Overview
Administrators can manage s3 configurations at `/admin/system`, including adding, deleting, and modifying s3 configurations.
## Cloudflare R2

157
lib/r2.ts Normal file
View File

@@ -0,0 +1,157 @@
import {
DeleteObjectCommand,
GetObjectCommand,
ListObjectsV2Command,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { getMultipleConfigs } from "./dto/system-config";
export interface CloudStorageCredentials {
enabled?: boolean;
platform?: string;
channel?: string;
provider_name?: string;
account_id?: string;
access_key_id?: string;
secret_access_key?: string;
bucket?: string;
endpoint?: string;
region?: string;
custom_domain?: string;
prefix?: string;
file_types?: string;
}
export interface ClientStorageCredentials {
enabled?: boolean;
platform?: string;
channel?: string;
provider_name?: string;
buckets?: string[];
region?: string;
custom_domain?: string[];
prefix?: string;
file_types?: string[];
}
export interface FileObject {
Key?: string;
LastModified?: Date;
ETag?: string;
Size?: number;
StorageClass?: string;
}
export function createS3Client(
endpoint: string,
accessKeyId: string,
secretAccessKey: string,
region: string = "auto",
) {
return new S3Client({
region: region,
endpoint: endpoint,
credentials: {
accessKeyId,
secretAccessKey,
},
});
}
export async function uploadFile(
file: Buffer,
key: string,
s3: S3Client,
bucket: string,
) {
const command = new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: file,
});
try {
const response = await s3.send(command);
return response;
} catch (error) {
console.error("Error uploading file:", error);
throw error;
}
}
export async function getSignedUrlForUpload(
key: string,
contentType: string,
s3: S3Client,
bucket: string,
): Promise<string> {
const command = new PutObjectCommand({
Bucket: bucket,
Key: key,
ContentType: contentType,
});
try {
const signedUrl = await getSignedUrl(s3, command, { expiresIn: 3600 });
return signedUrl;
} catch (error) {
console.error("Error generating signed URL:", error);
throw error;
}
}
export async function getSignedUrlForDownload(
key: string,
s3: S3Client,
bucket: string,
): Promise<string> {
const command = new GetObjectCommand({
Bucket: bucket,
Key: key,
});
try {
const signedUrl = await getSignedUrl(s3, command, { expiresIn: 3600 });
return signedUrl;
} catch (error) {
console.error("Error generating signed URL:", error);
throw error;
}
}
export async function listFiles(
prefix: string = "",
s3: S3Client,
bucket: string,
): Promise<FileObject[]> {
const command = new ListObjectsV2Command({
Bucket: bucket,
Prefix: prefix,
});
try {
const response = await s3.send(command);
return response.Contents || [];
} catch (error) {
console.error("Error listing files:", error);
throw error;
}
}
export async function deleteFile(key: string, s3: S3Client, bucket: string) {
const command = new DeleteObjectCommand({
Bucket: bucket,
Key: key,
});
try {
const response = await s3.send(command);
return response;
} catch (error) {
console.error("Error deleting file:", error);
throw error;
}
}

View File

@@ -424,7 +424,8 @@
"Admin": "Admin",
"Sign in": "Sign in",
"Log out": "Log out",
"System Settings": "System Settings"
"System Settings": "System Settings",
"Cloud Storage": "Cloud Storage"
},
"Email": {
"Search emails": "Search emails",
@@ -554,6 +555,11 @@
"Email Suffix White List": "Email Suffix White List",
"Set email suffix white list, split by comma, such as: gmail-com,yahoo-com,hotmail-com": "Set email suffix white list, split by comma, such as: gmail.com,yahoo.com,hotmail.com",
"Application Status Email Notifications": "Application Status Email Notifications",
"Send email notifications for subdomain application status updates; Notifies administrators when users submit applications and notifies users of approval results; Only available when subdomain application mode is enabled": "Send email notifications for subdomain application status updates; Notifies administrators when users submit applications and notifies users of approval results; Only available when subdomain application mode is enabled"
"Send email notifications for subdomain application status updates; Notifies administrators when users submit applications and notifies users of approval results; Only available when subdomain application mode is enabled": "Send email notifications for subdomain application status updates; Notifies administrators when users submit applications and notifies users of approval results; Only available when subdomain application mode is enabled",
"Cloud Storage Configs": "Cloud Storage Configs",
"Verified": "Verified",
"Verify Configuration": "Verify Configuration",
"Clear": "Clear",
"How to get the R2 credentials?": "How to get the R2 credentials?"
}
}

View File

@@ -89,7 +89,7 @@
"The record is currently pending for admin approval": "正在等待管理员审核",
"The target is currently inaccessible": "目标链接目前无法访问",
"Please check the target and try again": "请检查解析记录并重试",
"If the target is not activated within 3 days": "如果目标链接在 3 天依然无法访问",
"If the target is not activated within 3 days": "如果目标链接在 3 天依然无法访问",
"the administrator will": "管理员将",
"delete this record": "删除此记录",
"Review": "审核",
@@ -424,7 +424,8 @@
"Admin": "管理面板",
"Sign in": "登录",
"Log out": "退出登录",
"System Settings": "系统设置"
"System Settings": "系统设置",
"Cloud Storage": "云存储"
},
"Email": {
"Search emails": "搜索邮箱...",
@@ -554,6 +555,11 @@
"Email Suffix White List": "白名单",
"Set email suffix white list, split by comma, such as: gmail-com,yahoo-com,hotmail-com": "设置邮箱后缀白名单多个后缀请用逗号分隔例如gmail.com,yahoo.com,hotmail.com",
"Application Status Email Notifications": "申请状态邮件通知",
"Send email notifications for subdomain application status updates; Notifies administrators when users submit applications and notifies users of approval results; Only available when subdomain application mode is enabled": "开启后,用户申请子域名时将邮件通知管理员审核,审核完成后邮件通知用户结果。此功能仅在子域申请模式开启时有效"
"Send email notifications for subdomain application status updates; Notifies administrators when users submit applications and notifies users of approval results; Only available when subdomain application mode is enabled": "开启后,用户申请子域名时将邮件通知管理员审核,审核完成后邮件通知用户结果。此功能仅在子域申请模式开启时有效",
"Cloud Storage Configs": "云存储配置",
"Verified": "已就绪",
"Verify Configuration": "验证配置",
"Clear": "清空",
"How to get the R2 credentials?": "如何获取 R2 授权配置?"
}
}

View File

@@ -26,6 +26,8 @@
},
"dependencies": {
"@auth/prisma-adapter": "^2.4.1",
"@aws-sdk/client-s3": "^3.840.0",
"@aws-sdk/s3-request-presigner": "^3.840.0",
"@hookform/resolvers": "^3.9.0",
"@mantine/hooks": "^8.0.1",
"@prisma/client": "^5.17.0",

1234
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,63 @@
-- CF R2 配置
INSERT INTO "system_configs"
(
"key",
"value",
"type",
"description"
)
VALUES
(
's3_config_01',
'{"enabled":true,"platform":"cloudflare","channel":"r2","provider_name":"Cloudflare R2","account_id":"","access_key_id":"","secret_access_key":"","bucket":"","region":"auto","endpoint":"https://<account_id>.r2.cloudflarestorage.com","custom_domain":""}',
'OBJECT',
'R2 存储桶配置'
);
-- AWS S3 配置
INSERT INTO "system_configs"
(
"key",
"value",
"type",
"description"
)
VALUES
(
's3_config_02',
'{"enabled":true,"platform":"aws","channel":"s3","provider_name":"Amazon S3","endpoint":"https://s3.<region>.amazonaws.com","account_id":"","access_key_id":"","secret_access_key":"","bucket":"","region":"us-east-1","custom_domain":""}',
'OBJECT',
'Amazon S3 存储桶配置'
);
-- 阿里云 OSS 配置
INSERT INTO "system_configs"
(
"key",
"value",
"type",
"description"
)
VALUES
(
's3_config_03',
'{"enabled":true,"platform":"aliyun","channel":"oss","provider_name":"阿里云 OSS","endpoint":"https://oss-cn-hangzhou.aliyuncs.com","account_id":"","access_key_id":"","secret_access_key":"","bucket":"","region":"oss-cn-hangzhou","custom_domain":""}',
'OBJECT',
'阿里云 OSS 存储桶配置'
);
-- 腾讯云 COS 配置
INSERT INTO "system_configs"
(
"key",
"value",
"type",
"description"
)
VALUES
(
's3_config_04',
'{"enabled":true,"platform":"tencent","channel":"cos","provider_name":"腾讯云 COS","endpoint":"https://cos.ap-beijing.myqcloud.com","account_id":"","access_key_id":"","secret_access_key":"","bucket":"","region":"ap-beijing","custom_domain":""}',
'OBJECT',
'腾讯云 COS 存储桶配置'
);

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
<url><loc>https://wr.do/manifest.json</loc><lastmod>2025-06-29T11:28:45.465Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/robots.txt</loc><lastmod>2025-06-29T11:28:45.465Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/opengraph-image.jpg</loc><lastmod>2025-06-29T11:28:45.465Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/robots.txt</loc><lastmod>2025-07-01T11:16:19.769Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/manifest.json</loc><lastmod>2025-07-01T11:16:19.769Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/opengraph-image.jpg</loc><lastmod>2025-07-01T11:16:19.769Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
</urlset>

File diff suppressed because one or more lines are too long