feat: add s3 crud and s3 confgs
This commit is contained in:
@@ -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,
|
||||
|
||||
300
app/(protected)/admin/system/s3-list.tsx
Normal file
300
app/(protected)/admin/system/s3-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
app/(protected)/dashboard/storage/loading.tsx
Normal file
20
app/(protected)/dashboard/storage/loading.tsx
Normal 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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
30
app/(protected)/dashboard/storage/page.tsx
Normal file
30
app/(protected)/dashboard/storage/page.tsx
Normal 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
58
app/api/admin/s3/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
36
app/api/s3/r2/configs/route.ts
Normal file
36
app/api/s3/r2/configs/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
160
app/api/s3/r2/files/route.ts
Normal file
160
app/api/s3/r2/files/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
54
app/api/s3/r2/upload/route.ts
Normal file
54
app/api/s3/r2/upload/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -290,7 +290,6 @@ export function PlanForm({
|
||||
className="flex-1 shadow-inner"
|
||||
size={32}
|
||||
type="number"
|
||||
disabled
|
||||
{...register("slDomains", { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
255
components/shared/file-manager.tsx
Normal file
255
components/shared/file-manager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
10
content/docs/developer/s3.mdx
Normal file
10
content/docs/developer/s3.mdx
Normal 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
157
lib/r2.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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?"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 授权配置?"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
1234
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
63
prisma/migrations/20250702103024/migration.sql
Normal file
63
prisma/migrations/20250702103024/migration.sql
Normal 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 存储桶配置'
|
||||
);
|
||||
@@ -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
Reference in New Issue
Block a user