Merge pull request #55 from oiov/dev

Refact s3 configs and support muti s3 provider
This commit is contained in:
oiov
2025-07-14 15:40:28 +08:00
committed by GitHub
25 changed files with 912 additions and 711 deletions
-1
View File
@@ -195,4 +195,3 @@ See [How to Trigger Sync](https://wr.do/docs/developer/sync) for details.
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=oiov/wr.do&type=Date" />
</picture>
</a>
+1 -1
View File
@@ -88,7 +88,7 @@ export default function AppConfigs({}: {}) {
return (
<Card>
<Collapsible defaultOpen className="group">
<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("App Configs")}</div>
<Icons.chevronDown className="ml-auto size-4" />
+1 -1
View File
@@ -123,7 +123,7 @@ export default function DomainList({ user, action }: DomainListProps) {
if (res.ok) {
const data = await res.json();
if (data) {
toast.success("Successed!");
toast.success("Saved");
handleRefresh();
}
} else {
+537 -384
View File
@@ -8,7 +8,7 @@ import { useTranslations } from "next-intl";
import { toast } from "sonner";
import useSWR from "swr";
import { CloudStorageCredentials } from "@/lib/r2";
import { BucketItem, CloudStorageCredentials } from "@/lib/r2";
import { cn, fetcher } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -20,6 +20,13 @@ import {
} from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import {
@@ -33,30 +40,7 @@ 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: "",
endpoint: "",
enabled: true,
buckets: [
{
bucket: "",
custom_domain: "",
prefix: "",
file_types: "",
region: "auto",
public: true,
},
],
});
const [s3Configs, setS3Configs] = useState<CloudStorageCredentials[]>([]);
const {
data: configs,
@@ -64,13 +48,40 @@ export default function S3Configs({}: {}) {
mutate,
} = useSWR<Record<string, any>>("/api/admin/s3", fetcher);
const S3_PRVIDERS = [
{
label: "Cloudflare R2",
value: "Cloudflare R2",
platform: "cloudflare",
channel: "r2",
},
{ label: "AWS S3", value: "AWS S3", platform: "aws", channel: "s3" },
{
label: "Tencent COS",
value: "Tencent COS",
platform: "tencent",
channel: "cos",
},
{ label: "Ali OSS", value: "Ali OSS", platform: "ali", channel: "oss" },
{ label: "Minio", value: "Minio", platform: "minio", channel: "s3" },
];
useEffect(() => {
if (configs) {
setR2Credentials(configs.s3_config_01);
if (configs && configs?.s3_config_list) {
setS3Configs(configs.s3_config_list);
}
}, [configs]);
function isProviderNameUnique(array: CloudStorageCredentials[]): boolean {
const names = array.map((item) => item.provider_name);
return new Set(names).size === names.length;
}
const handleSaveConfigs = (value: any, key: string, type: string) => {
if (!isProviderNameUnique(s3Configs)) {
toast.error("Provider name must be unique");
return;
}
startTransition(async () => {
const res = await fetch("/api/admin/s3", {
method: "POST",
@@ -87,43 +98,15 @@ export default function S3Configs({}: {}) {
});
};
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],
return (
Object.keys(s3Configs).some(
(key) => s3Configs[key] !== configs.s3_config_list[key],
) || configs.s3_config_list.length !== s3Configs.length
);
}, [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>
);
}, [s3Configs, configs]);
if (isLoading) {
return <Skeleton className="h-48 w-full rounded-lg" />;
@@ -131,341 +114,511 @@ export default function S3Configs({}: {}) {
return (
<Card>
<Collapsible className="group" defaultOpen>
<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" />
<Collapsible defaultOpen>
<CollapsibleTrigger className="flex w-full items-center justify-between gap-3 bg-neutral-50 px-4 py-5 dark:bg-neutral-900">
<p className="mr-auto text-lg font-bold">
{t("Cloud Storage Configs")}
</p>
{canSaveR2Credentials && (
<Button
className="h-7 px-2 py-1 text-xs"
size={"sm"}
disabled={isPending || !canSaveR2Credentials}
onClick={(e) => {
e.preventDefault();
handleSaveConfigs(s3Configs, "s3_config_list", "OBJECT");
}}
>
{isPending ? (
<Icons.spinner className="mr-1 size-4 animate-spin" />
) : null}
{t("Save Modifications")}
</Button>
)}
<p
className="flex h-[30px] items-center gap-1 rounded-md border bg-primary px-2 py-1 text-xs font-medium text-primary-foreground hover:opacity-80"
onClick={(e) => {
e.preventDefault();
setS3Configs([
...s3Configs,
{
platform: "cloudflare",
channel: "s3",
provider_name: `Cloudflare R2 (${s3Configs.length + 1})`,
account_id: "",
access_key_id: "",
secret_access_key: "",
endpoint: "",
enabled: true,
buckets: [
{
bucket: "",
custom_domain: "",
prefix: "",
file_types: "",
region: "auto",
public: true,
},
],
},
]);
}}
>
<Icons.add className="size-3" />
{t("Add Provider")}
</p>
<Icons.chevronDown className="size-4" />
</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>{t("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>{t("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>{t("Secret Access Key")}</Label>
<Input
value={r2Credentials.secret_access_key}
onChange={(e) =>
setR2Credentials({
...r2Credentials,
secret_access_key: e.target.value,
})
}
/>
</div>
<div className="flex flex-col justify-center space-y-3">
<Label>{t("Enabled")}</Label>
<Switch
checked={r2Credentials.enabled}
onCheckedChange={(e) =>
setR2Credentials({
...r2Credentials,
enabled: e,
})
}
/>
</div>
</div>
{r2Credentials.buckets.map((bucket, index) => (
<motion.div
className="relative grid grid-cols-1 gap-4 rounded-lg border border-dashed border-muted-foreground px-3 pb-3 pt-10 text-neutral-600 dark:text-neutral-400 sm:grid-cols-3"
key={`bucket-${index}`}
layout
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{
layout: { duration: 0.3, ease: "easeInOut" },
opacity: { duration: 0.2 },
scale: { duration: 0.2 },
}}
>
<p className="absolute left-2 top-3 text-xs text-muted-foreground">
{t("Bucket")} {index + 1}
{s3Configs.map((config, index) => {
const updateBucket = (
bucketIndex: number,
updates: Partial<BucketItem>,
) => {
const newBuckets = [...config.buckets];
newBuckets[bucketIndex] = {
...newBuckets[bucketIndex],
...updates,
};
setS3Configs(
s3Configs.map((c, i) => {
if (i === index) {
return {
...c,
buckets: newBuckets,
};
}
return c;
}),
);
};
return (
<Collapsible
className={cn(
index !== s3Configs.length - 1 && "border-b pb-3",
"group",
)}
key={index}
>
<CollapsibleTrigger className="flex w-full items-center justify-between gap-3">
<p className="mr-auto font-semibold group-hover:font-bold">
{config.provider_name}
</p>
<div className="absolute right-2 top-2 flex items-center justify-between space-x-2">
{index > 0 && (
<Button
className="h-[30px] px-1.5"
size={"sm"}
variant={"ghost"}
onClick={() => {
const newBuckets = [...r2Credentials.buckets];
newBuckets.splice(index, 1);
newBuckets.splice(index - 1, 0, bucket);
setR2Credentials({
...r2Credentials,
buckets: newBuckets,
});
<Badge className="text-xs" variant="outline">
{t("{length} Buckets", {
length: config.buckets.length,
})}
</Badge>
<Icons.trash
className="size-6 rounded border p-1 text-muted-foreground hover:border-red-500 hover:bg-red-50 hover:text-red-500"
onClick={() => {
setS3Configs(s3Configs.filter((_, i) => i !== index));
}}
/>
<Icons.chevronDown className="size-4" />
</CollapsibleTrigger>
<CollapsibleContent className="mt-3 space-y-4 rounded-lg border p-6 shadow-md transition-colors duration-75 group-hover:bg-primary-foreground">
{/* Base */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="space-y-1">
<Label>{t("Provider")}*</Label>
<Select
value={`${config.platform} (${config.channel})`}
onValueChange={(v) => {
const provider = S3_PRVIDERS.find(
(p) => `${p.platform} (${p.channel})` === v,
);
setS3Configs(
s3Configs.map((c, i) => {
if (i === index) {
return {
...c,
provider_name: `${provider?.value} (${index + 1})`,
channel: provider?.channel || "",
platform: provider?.platform || "",
};
}
return c;
}),
);
}}
>
<Icons.arrowUp className="size-4" />{" "}
</Button>
)}
{index < r2Credentials.buckets.length - 1 && (
<Button
className="h-[30px] px-1.5"
size={"sm"}
variant={"ghost"}
onClick={() => {
const newBuckets = [...r2Credentials.buckets];
newBuckets.splice(index, 1);
newBuckets.splice(index + 1, 0, bucket);
setR2Credentials({
...r2Credentials,
buckets: newBuckets,
});
}}
>
<Icons.arrowDown className="size-4" />{" "}
</Button>
)}
<Button
className="ml-auto h-[30px] px-1.5"
size={"sm"}
variant={"outline"}
onClick={() => {
const newBuckets = [...r2Credentials.buckets];
newBuckets.splice(index + 1, 0, {
bucket: "",
prefix: "",
file_types: "",
region: "auto",
custom_domain: "",
file_size: "26214400",
public: true,
});
setR2Credentials({
...r2Credentials,
buckets: newBuckets,
});
<SelectTrigger className="bg-neutral-100 dark:bg-neutral-800">
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent>
{S3_PRVIDERS.map((provider) => (
<SelectItem
key={`${provider.platform} (${provider.channel})`}
value={`${provider.platform} (${provider.channel})`}
>
{provider.platform} ({provider.channel})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>
{t("Provider Unique Name")}* ({t("Unique")})
</Label>
<Input
value={config.provider_name}
placeholder="provider display name"
onChange={(e) =>
setS3Configs(
s3Configs.map((c, i) => {
if (i === index) {
return {
...c,
provider_name: e.target.value,
};
}
return c;
}),
)
}
/>
</div>
<div className="space-y-1">
<Label>{t("Endpoint")}*</Label>
<Input
value={config.endpoint}
placeholder="https://<account_id>.r2.cloudflarestorage.com"
onChange={(e) =>
setS3Configs(
s3Configs.map((c, i) => {
if (i === index) {
return {
...c,
endpoint: e.target.value,
};
}
return c;
}),
)
}
/>
</div>
<div className="space-y-1">
<Label>{t("Access Key ID")}*</Label>
<Input
value={config.access_key_id}
onChange={(e) =>
setS3Configs(
s3Configs.map((c, i) => {
if (i === index) {
return {
...c,
access_key_id: e.target.value,
};
}
return c;
}),
)
}
/>
</div>
<div className="space-y-1">
<Label>{t("Secret Access Key")}*</Label>
<Input
value={config.secret_access_key}
onChange={(e) =>
setS3Configs(
s3Configs.map((c, i) => {
if (i === index) {
return {
...c,
secret_access_key: e.target.value,
};
}
return c;
}),
)
}
/>
</div>
<div className="flex flex-col justify-center space-y-3">
<Label>{t("Enabled")}*</Label>
<Switch
checked={config.enabled}
onCheckedChange={(e) =>
setS3Configs(
s3Configs.map((c, i) => {
if (i === index) {
return {
...c,
enabled: e,
};
}
return c;
}),
)
}
/>
</div>
</div>
{/* buckets */}
{config.buckets.map((bucket, index2) => (
<motion.div
className="relative grid grid-cols-1 gap-4 rounded-lg border border-dashed border-muted-foreground px-3 pb-3 pt-10 text-neutral-600 dark:text-neutral-400 sm:grid-cols-3"
key={`bucket-${index2}`}
layout
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{
layout: { duration: 0.3, ease: "easeInOut" },
opacity: { duration: 0.2 },
scale: { duration: 0.2 },
}}
>
<Icons.add className="size-4" />{" "}
</Button>
{index !== 0 && (
<Button
className="h-[30px] px-1.5"
size={"sm"}
variant={"outline"}
>
<Icons.trash
className="size-4"
onClick={() => {
const newBuckets = [...r2Credentials.buckets];
newBuckets.splice(index, 1);
setR2Credentials({
...r2Credentials,
buckets: newBuckets,
});
}}
/>
</Button>
)}
</div>
<p className="absolute left-2 top-3 text-xs text-muted-foreground">
{t("Bucket")} {index2 + 1}
</p>
<div className="space-y-1">
<Label>{t("Bucket Name")}*</Label>
<Input
value={bucket.bucket}
placeholder="bucket name"
onChange={(e) => {
const newBuckets = [...r2Credentials.buckets];
newBuckets[index] = {
...bucket,
bucket: e.target.value,
};
setR2Credentials({
...r2Credentials,
buckets: newBuckets,
});
{/* 按钮部分 */}
<div className="absolute right-2 top-2 flex items-center justify-between space-x-2">
{index2 > 0 && (
<Button
className="h-[30px] px-1.5"
size={"sm"}
variant={"ghost"}
onClick={() => {
const newBuckets = [...config.buckets];
newBuckets.splice(index2, 1);
newBuckets.splice(index2 - 1, 0, bucket);
setS3Configs(
s3Configs.map((c, i) => {
if (i === index) {
return {
...c,
buckets: newBuckets,
};
}
return c;
}),
);
}}
>
<Icons.arrowUp className="size-4" />
</Button>
)}
{index2 < config.buckets.length - 1 && (
<Button
className="h-[30px] px-1.5"
size={"sm"}
variant={"ghost"}
onClick={() => {
const newBuckets = [...config.buckets];
newBuckets.splice(index2, 1);
newBuckets.splice(index2 + 1, 0, bucket);
setS3Configs(
s3Configs.map((c, i) => {
if (i === index) {
return {
...c,
buckets: newBuckets,
};
}
return c;
}),
);
}}
>
<Icons.arrowDown className="size-4" />
</Button>
)}
<Button
className="ml-auto h-[30px] px-1.5"
size={"sm"}
variant={"outline"}
onClick={() => {
const newBuckets = [...config.buckets];
newBuckets.splice(index2 + 1, 0, {
bucket: "",
prefix: "",
file_types: "",
region: "auto",
custom_domain: "",
file_size: "26214400",
public: true,
});
setS3Configs(
s3Configs.map((c, i) => {
if (i === index) {
return {
...c,
buckets: newBuckets,
};
}
return c;
}),
);
}}
>
<Icons.add className="size-4" />
</Button>
{index2 !== 0 && (
<Button
className="h-[30px] px-1.5"
size={"sm"}
variant={"outline"}
onClick={() => {
const newBuckets = [...config.buckets];
newBuckets.splice(index2, 1);
setS3Configs(
s3Configs.map((c, i) => {
if (i === index) {
return {
...c,
buckets: newBuckets,
};
}
return c;
}),
);
}}
>
<Icons.trash className="size-4" />
</Button>
)}
</div>
{/* 使用 updateBucket 函数的输入字段 */}
<div className="space-y-1">
<Label>{t("Bucket Name")}*</Label>
<Input
value={bucket.bucket}
placeholder="bucket name"
onChange={(e) =>
updateBucket(index2, { bucket: e.target.value })
}
/>
</div>
<div className="space-y-1">
<Label>{t("Public Domain")}*</Label>
<Input
value={bucket.custom_domain}
placeholder="https://endpoint or custom domain"
onChange={(e) =>
updateBucket(index2, {
custom_domain: e.target.value,
})
}
/>
</div>
<div className="space-y-1">
<Label>{t("Region")}</Label>
<Input
value={bucket.region}
placeholder="auto"
onChange={(e) =>
updateBucket(index2, { region: e.target.value })
}
/>
</div>
<div className="space-y-1">
<Label>
{t("Prefix")} ({t("Optional")})
</Label>
<Input
value={bucket.prefix}
placeholder="2025/08/08"
onChange={(e) =>
updateBucket(index2, { prefix: e.target.value })
}
/>
</div>
<div className="flex flex-col justify-center space-y-3">
<div className="flex items-center gap-1">
<Label>{t("Public")}</Label>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger>
<Icons.help className="size-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-56 text-wrap">
{t(
"Publicize this storage bucket, all registered users can upload files to this storage bucket; If not public, only administrators can upload files to this storage bucket",
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Switch
checked={bucket.public}
onCheckedChange={(e) =>
updateBucket(index2, { public: e })
}
/>
</div>
</motion.div>
))}
{/* actions */}
<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 S3 credentials?")}
</Link>
{/* <Button
className="ml-auto"
variant="destructive"
onClick={() => {
setS3Configs([
{
platform: "cloudflare",
channel: "r2",
provider_name: "Cloudflare R2",
endpoint: "",
access_key_id: "",
secret_access_key: "",
buckets: [
{
bucket: "",
prefix: "",
file_types: "",
region: "auto",
custom_domain: "",
file_size: "26214400",
public: true,
},
],
account_id: "",
enabled: false,
},
]);
}}
/>
</div>
<div className="space-y-1">
<Label>{t("Public Domain")}*</Label>
<Input
value={bucket.custom_domain}
placeholder="https://endpoint or custom domain"
onChange={(e) => {
const newBuckets = [...r2Credentials.buckets];
newBuckets[index] = {
...bucket,
custom_domain: e.target.value,
};
setR2Credentials({
...r2Credentials,
buckets: newBuckets,
});
>
{t("Clear")}
</Button> */}
<Button
disabled={isPending || !canSaveR2Credentials}
onClick={() => {
handleSaveConfigs(
s3Configs,
"s3_config_list",
"OBJECT",
);
}}
/>
>
{isPending ? (
<Icons.spinner className="mr-1 size-4 animate-spin" />
) : null}
{t("Save")}
</Button>
</div>
<div className="space-y-1">
<Label>{t("Region")}</Label>
<Input
value={bucket.region}
placeholder="auto"
onChange={(e) => {
const newBuckets = [...r2Credentials.buckets];
newBuckets[index] = {
...bucket,
region: e.target.value,
};
setR2Credentials({
...r2Credentials,
buckets: newBuckets,
});
}}
/>
</div>
<div className="space-y-1">
<Label>
{t("Prefix")} ({t("Optional")})
</Label>
<Input
value={bucket.prefix}
placeholder="2025/08/08"
onChange={(e) => {
const newBuckets = [...r2Credentials.buckets];
newBuckets[index] = {
...bucket,
prefix: e.target.value,
};
setR2Credentials({
...r2Credentials,
buckets: newBuckets,
});
}}
/>
</div>
<div className="flex flex-col justify-center space-y-3">
<div className="flex items-center gap-1">
<Label>{t("Public")}</Label>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger>
<Icons.help className="size-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-56 text-wrap">
{t(
"Publicize this storage bucket, all registered users can upload files to this storage bucket; If not public, only administrators can upload files to this storage bucket",
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Switch
checked={bucket.public}
onCheckedChange={(e) =>
setR2Credentials({
...r2Credentials,
buckets: r2Credentials.buckets.map((b, i) => {
if (i === index) {
return {
...b,
public: e,
};
}
return b;
}),
})
}
/>
</div>
{/* <div className="space-y-1">
<Label>
{t("Allowed File Types")} ({t("Optional")})
</Label>
<Input
value={bucket.file_types}
placeholder=""
disabled
onChange={(e) => {
const newBuckets = [...r2Credentials.buckets];
newBuckets[index] = {
...bucket,
file_types: e.target.value,
};
setR2Credentials({
...r2Credentials,
buckets: newBuckets,
});
}}
/>
</div> */}
</motion.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: "",
buckets: [],
account_id: "",
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>
);
})}
</CollapsibleContent>
</Collapsible>
</Card>
+1 -8
View File
@@ -15,14 +15,7 @@ export async function GET(req: NextRequest) {
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);
const configs = await getMultipleConfigs(["s3_config_list"]);
return Response.json(configs, { status: 200 });
} catch (error) {
@@ -1,35 +0,0 @@
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) {
console.error("[Error]", error);
return NextResponse.json("Error listing buckets", { status: 500 });
}
}
@@ -0,0 +1,47 @@
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_list"]);
if (!configs || !configs.s3_config_list) {
return NextResponse.json("Invalid S3 configs", {
status: 400,
});
}
const processedList = configs.s3_config_list
.filter((c) => c.enabled && c.buckets?.length > 0)
.map((c) => ({
provider_name: c.provider_name,
platform: c.platform,
channel: c.channel,
enabled: c.enabled,
buckets: c.buckets.filter((b) => b?.bucket),
}))
.filter((c) => c.buckets.length > 0);
if (processedList.length === 0) {
return NextResponse.json("No buckets found", {
status: 404,
});
}
return NextResponse.json(processedList);
} catch (error) {
console.error("[Error]", error);
return NextResponse.json("Error listing buckets", { status: 500 });
}
}
@@ -21,31 +21,32 @@ export async function GET(req: NextRequest) {
const page = url.searchParams.get("page");
const pageSize = url.searchParams.get("pageSize");
const bucket = url.searchParams.get("bucket") || "";
const provider = url.searchParams.get("provider") || "";
const name = url.searchParams.get("name") || "";
const fileSize = url.searchParams.get("fileSize") || "";
const mimeType = url.searchParams.get("mimeType") || "";
const status = url.searchParams.get("status") || "";
const configs = await getMultipleConfigs(["s3_config_01"]);
if (!configs.s3_config_01.enabled) {
return NextResponse.json("S3 is not enabled", {
status: 403,
const configs = await getMultipleConfigs(["s3_config_list"]);
if (!configs || !configs.s3_config_list) {
return NextResponse.json("Invalid S3 configs", {
status: 400,
});
}
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 providerChannel = configs.s3_config_list.find(
(c) => c.provider_name === provider,
);
if (!providerChannel) {
return NextResponse.json("Provider does not exist", {
status: 400,
});
}
const buckets = configs.s3_config_01.buckets || [];
const buckets = providerChannel.buckets || [];
if (!buckets.find((b) => b.bucket === bucket)) {
return NextResponse.json("Bucket does not exist", {
status: 403,
status: 400,
});
}
@@ -53,18 +54,19 @@ export async function GET(req: NextRequest) {
page: Number(page) || 1,
limit: Number(pageSize) || 20,
bucket,
channel: configs.s3_config_01.channel,
platform: configs.s3_config_01.platform,
name,
size: Number(fileSize || 0),
status: Number(status === "0" ? 0 : 1),
mimeType,
channel: providerChannel.channel,
platform: providerChannel.platform,
providerName: providerChannel.provider_name,
});
return NextResponse.json(res);
} catch (error) {
console.error("Error listing files:", error);
return NextResponse.json({ error: "Error listing files" }, { status: 500 });
return NextResponse.json("Error listing files", { status: 500 });
}
}
@@ -79,51 +81,48 @@ export async function POST(request: NextRequest) {
});
}
const { key, bucket } = await request.json();
if (!key || !bucket) {
const { key, bucket, provider } = await request.json();
if (!key || !bucket || !provider) {
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,
const configs = await getMultipleConfigs(["s3_config_list"]);
if (!configs || !configs.s3_config_list) {
return NextResponse.json("Invalid S3 configs", {
status: 400,
});
}
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 providerChannel = configs.s3_config_list.find(
(c) => c.provider_name === provider,
);
if (!providerChannel) {
return NextResponse.json("Provider does not exist", {
status: 400,
});
}
const buckets = configs.s3_config_01.buckets || [];
const buckets = providerChannel.buckets || [];
if (!buckets.find((b) => b.bucket === bucket)) {
return NextResponse.json("Bucket does not exist", {
status: 403,
status: 400,
});
}
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,
providerChannel.endpoint,
providerChannel.access_key_id,
providerChannel.secret_access_key,
),
bucket,
);
return NextResponse.json({ signedUrl });
} catch (error) {
return NextResponse.json(
{ error: "Error generating download URL" },
{ status: 500 },
);
return NextResponse.json("Error generating download URL", { status: 500 });
}
}
@@ -138,41 +137,41 @@ export async function DELETE(request: NextRequest) {
});
}
const { keys, ids, bucket } = await request.json();
const { keys, ids, bucket, provider } = await request.json();
if (!keys || !ids || !bucket) {
if (!keys || !ids || !bucket || !provider) {
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,
const configs = await getMultipleConfigs(["s3_config_list"]);
if (!configs || !configs.s3_config_list) {
return NextResponse.json("Invalid S3 configs", {
status: 400,
});
}
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 providerChannel = configs.s3_config_list.find(
(c) => c.provider_name === provider,
);
if (!providerChannel) {
return NextResponse.json("Provider does not exist", {
status: 400,
});
}
const buckets = configs.s3_config_01.buckets || [];
const buckets = providerChannel.buckets || [];
if (!buckets.find((b) => b.bucket === bucket)) {
return NextResponse.json("Bucket does not exist", {
status: 403,
status: 400,
});
}
const R2 = createS3Client(
configs.s3_config_01.endpoint,
configs.s3_config_01.access_key_id,
configs.s3_config_01.secret_access_key,
providerChannel.endpoint,
providerChannel.access_key_id,
providerChannel.secret_access_key,
);
for (const key of keys) {
@@ -181,6 +180,6 @@ export async function DELETE(request: NextRequest) {
await softDeleteUserFiles(ids);
return NextResponse.json({ message: "File deleted successfully" });
} catch (error) {
return NextResponse.json({ error: "Error deleting file" }, { status: 500 });
return NextResponse.json("Error deleting file", { status: 500 });
}
}
@@ -23,7 +23,6 @@ export async function POST(request: NextRequest) {
const data = await getUserShortLinksByIds(ids, user.id);
// ids:["cmcrqtql10001zbhynvwfuqza", "", "", "", ""],则返回的短链位置也要一一对应
const dataMap = new Map(data.map((item) => [item.id, item]));
const orderedResults = ids.map((id) => {
@@ -35,10 +34,7 @@ export async function POST(request: NextRequest) {
urls: orderedResults,
});
} catch (error) {
return NextResponse.json(
{ error: "Error generating download URL" },
{ status: 500 },
);
return NextResponse.json("Error generating download URL", { status: 500 });
}
}
-31
View File
@@ -1,31 +0,0 @@
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) {
return NextResponse.json({ error: "Invalid S3 config" }, { status: 400 });
}
return NextResponse.json({
buckets: configs.s3_config_01.buckets.filter((b) => b.public), // public
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 buckets" },
{ status: 500 },
);
}
}
+40
View File
@@ -0,0 +1,40 @@
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_list"]);
if (!configs || !configs.s3_config_list) {
return NextResponse.json("Invalid S3 configs", {
status: 400,
});
}
const processedList = configs.s3_config_list
.filter((c) => c.enabled && c.buckets?.length > 0)
.map((c) => ({
provider_name: c.provider_name,
platform: c.platform,
channel: c.channel,
enabled: c.enabled,
buckets: c.buckets.filter((b) => b?.bucket && b?.public),
}))
.filter((c) => c.buckets.length > 0);
if (processedList.length === 0) {
return NextResponse.json("No buckets found", {
status: 404,
});
}
return NextResponse.json(processedList);
} catch (error) {
return NextResponse.json("Error listing buckets", { status: 500 });
}
}
@@ -15,30 +15,31 @@ export async function GET(req: NextRequest) {
const page = url.searchParams.get("page");
const pageSize = url.searchParams.get("pageSize");
const bucket = url.searchParams.get("bucket") || "";
const provider = url.searchParams.get("provider") || "";
const name = url.searchParams.get("name") || "";
const fileSize = url.searchParams.get("fileSize") || "";
const mimeType = url.searchParams.get("mimeType") || "";
const configs = await getMultipleConfigs(["s3_config_01"]);
if (!configs.s3_config_01.enabled) {
return NextResponse.json("S3 is not enabled", {
status: 403,
const configs = await getMultipleConfigs(["s3_config_list"]);
if (!configs || !configs.s3_config_list) {
return NextResponse.json("Invalid S3 configs", {
status: 400,
});
}
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 providerChannel = configs.s3_config_list.find(
(c) => c.provider_name === provider,
);
if (!providerChannel) {
return NextResponse.json("Provider does not exist", {
status: 400,
});
}
const buckets = configs.s3_config_01.buckets || [];
const buckets = providerChannel.buckets || [];
if (!buckets.find((b) => b.bucket === bucket)) {
return NextResponse.json("Bucket does not exist", {
status: 403,
status: 400,
});
}
@@ -48,8 +49,9 @@ export async function GET(req: NextRequest) {
bucket,
userId: user.id,
status: 1,
channel: configs.s3_config_01.channel,
platform: configs.s3_config_01.platform,
channel: providerChannel.channel,
platform: providerChannel.platform,
providerName: providerChannel.provider_name,
name,
size: Number(fileSize || 0),
mimeType,
@@ -67,51 +69,48 @@ export async function POST(request: NextRequest) {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const { key, bucket } = await request.json();
if (!key || !bucket) {
const { key, bucket, provider } = await request.json();
if (!key || !bucket || !provider) {
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,
const configs = await getMultipleConfigs(["s3_config_list"]);
if (!configs || !configs.s3_config_list) {
return NextResponse.json("Invalid S3 configs", {
status: 400,
});
}
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 providerChannel = configs.s3_config_list.find(
(c) => c.provider_name === provider,
);
if (!providerChannel) {
return NextResponse.json("Provider does not exist", {
status: 400,
});
}
const buckets = configs.s3_config_01.buckets || [];
const buckets = providerChannel.buckets || [];
if (!buckets.find((b) => b.bucket === bucket)) {
return NextResponse.json("Bucket does not exist", {
status: 403,
status: 400,
});
}
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,
providerChannel.endpoint,
providerChannel.access_key_id,
providerChannel.secret_access_key,
),
bucket,
);
return NextResponse.json({ signedUrl });
} catch (error) {
return NextResponse.json(
{ error: "Error generating download URL" },
{ status: 500 },
);
return NextResponse.json("Error generating download URL", { status: 500 });
}
}
@@ -120,41 +119,41 @@ export async function DELETE(request: NextRequest) {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const { keys, ids, bucket } = await request.json();
const { keys, ids, bucket, provider } = await request.json();
if (!keys || !ids || !bucket) {
if (!keys || !ids || !bucket || !provider) {
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,
const configs = await getMultipleConfigs(["s3_config_list"]);
if (!configs || !configs.s3_config_list) {
return NextResponse.json("Invalid S3 configs", {
status: 400,
});
}
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 providerChannel = configs.s3_config_list.find(
(c) => c.provider_name === provider,
);
if (!providerChannel) {
return NextResponse.json("Provider does not exist", {
status: 400,
});
}
const buckets = configs.s3_config_01.buckets || [];
const buckets = providerChannel.buckets || [];
if (!buckets.find((b) => b.bucket === bucket)) {
return NextResponse.json("Bucket does not exist", {
status: 403,
status: 400,
});
}
const R2 = createS3Client(
configs.s3_config_01.endpoint,
configs.s3_config_01.access_key_id,
configs.s3_config_01.secret_access_key,
providerChannel.endpoint,
providerChannel.access_key_id,
providerChannel.secret_access_key,
);
for (const key of keys) {
@@ -163,6 +162,6 @@ export async function DELETE(request: NextRequest) {
await softDeleteUserFiles(ids);
return NextResponse.json({ message: "File deleted successfully" });
} catch (error) {
return NextResponse.json({ error: "Error deleting file" }, { status: 500 });
return NextResponse.json("Error deleting file", { status: 500 });
}
}
@@ -17,7 +17,6 @@ export async function POST(request: NextRequest) {
const data = await getUserShortLinksByIds(ids, user.id);
// ids:["cmcrqtql10001zbhynvwfuqza", "", "", "", ""],则返回的短链位置也要一一对应
const dataMap = new Map(data.map((item) => [item.id, item]));
const orderedResults = ids.map((id) => {
@@ -29,10 +28,7 @@ export async function POST(request: NextRequest) {
urls: orderedResults,
});
} catch (error) {
return NextResponse.json(
{ error: "Error generating download URL" },
{ status: 500 },
);
return NextResponse.json("Error generating download URL", { status: 500 });
}
}
@@ -34,6 +34,8 @@ export async function POST(request: NextRequest) {
);
}
console.log(body);
const userFile = await createUserFile({
userId: body.userId,
name: body.name,
@@ -15,32 +15,32 @@ export async function POST(request: NextRequest) {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const { files, bucket, prefix } = await request.json();
const { provider, bucket, files, prefix } = await request.json();
if (!bucket || !files || !Array.isArray(files)) {
return NextResponse.json("Invalid request parameters", { status: 400 });
}
const configs = await getMultipleConfigs(["s3_config_01"]);
if (!configs.s3_config_01.enabled) {
return NextResponse.json("S3 is not enabled", {
status: 403,
const configs = await getMultipleConfigs(["s3_config_list"]);
if (!configs || !configs.s3_config_list) {
return NextResponse.json("Invalid S3 configs", {
status: 400,
});
}
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 providerChannel = configs.s3_config_list.find(
(c) => c.provider_name === provider,
);
if (!providerChannel) {
return NextResponse.json("Provider does not exist", {
status: 400,
});
}
const buckets = configs.s3_config_01.buckets || [];
const buckets = providerChannel.buckets || [];
if (!buckets.find((b) => b.bucket === bucket)) {
return NextResponse.json("Bucket does not exist", {
status: 403,
status: 400,
});
}
@@ -61,9 +61,9 @@ export async function POST(request: NextRequest) {
if (limit) return Response.json(limit.statusText, { status: limit.status });
const R2 = createS3Client(
configs.s3_config_01.endpoint,
configs.s3_config_01.access_key_id,
configs.s3_config_01.secret_access_key,
providerChannel.endpoint,
providerChannel.access_key_id,
providerChannel.secret_access_key,
);
const signedUrls = await Promise.all(
+15 -11
View File
@@ -108,10 +108,14 @@ export default function UserFileList({
const handlePreviewRawFile = async (key: string) => {
try {
const response = await fetch(`${action}/r2/files`, {
const response = await fetch(`${action}/s3/files`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, bucket: bucketInfo.bucket }),
body: JSON.stringify({
key,
bucket: bucketInfo.bucket,
provider: bucketInfo.provider_name,
}),
});
const { signedUrl } = await response.json();
window.open(signedUrl, "_blank");
@@ -126,13 +130,14 @@ export default function UserFileList({
try {
toast.promise(
fetch(`${action}/r2/files`, {
fetch(`${action}/s3/files`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
keys: [file.path],
ids: [file.id],
bucket: bucketInfo.bucket,
provider: bucketInfo.provider_name,
}),
}),
{
@@ -151,7 +156,7 @@ export default function UserFileList({
const handleGenerateShortLink = async (urlId: string) => {
if (!shortTarget) return;
try {
const response = await fetch(`${action}/r2/files/short`, {
const response = await fetch(`${action}/s3/files/short`, {
method: "PUT",
body: JSON.stringify({ urlId, fileId: shortTarget?.id }),
});
@@ -159,7 +164,6 @@ export default function UserFileList({
toast.error("Error generating short link");
} else {
onRefresh();
// handleGetFileShortLinkByIds();
}
} catch (error) {
console.error("Error generating short link:", error);
@@ -172,7 +176,7 @@ export default function UserFileList({
try {
const ids = files.list.map((f) => f.shortUrlId || "");
if (!ids?.some((id) => id !== "")) return;
const response = await fetch(`${action}/r2/files/short`, {
const response = await fetch(`${action}/s3/files/short`, {
method: "POST",
body: JSON.stringify({ ids }),
});
@@ -251,11 +255,11 @@ export default function UserFileList({
<div className="flex items-center gap-2">
<Icons.type className="size-3 flex-shrink-0" />
<p className="line-clamp-1 truncate rounded-md bg-neutral-100 p-1.5 text-xs hover:text-blue-500 dark:bg-neutral-800">
{`[${file.name}](${getFileUrl(file.path)})`}
{`![${file.name}](${getFileUrl(file.path)})`}
</p>
<CopyButton
className="size-6"
value={`[${file.name}](${getFileUrl(file.path)})`}
value={`![${file.name}](${getFileUrl(file.path)})`}
/>
</div>
</>
@@ -325,7 +329,7 @@ export default function UserFileList({
width={300}
height={300}
src={getFileUrl(file.path)}
alt={`${file.path}`}
alt={`${file.name}`}
/>
)}
{renderFileLinks(file, index)}
@@ -491,7 +495,7 @@ export default function UserFileList({
{React.cloneElement(getFileIcon(file, bucketInfo), { size: 40 })}
<div className="w-full text-center">
<ClickableTooltip
className="mx-auto line-clamp-2 max-w-[60px] cursor-pointer break-all px-2 pb-1 text-left text-xs font-medium text-muted-foreground group-hover:text-blue-500 sm:max-w-[100px]"
className="mx-auto line-clamp-2 max-w-[60px] break-all px-2 pb-1 text-left text-xs font-medium text-muted-foreground group-hover:text-blue-500 sm:max-w-[100px]"
content={
<div className="max-w-[300px] space-y-1 p-3 text-start">
{file.mimeType.startsWith("image/") &&
@@ -501,7 +505,7 @@ export default function UserFileList({
width={300}
height={300}
src={getFileUrl(file.path)}
alt={`${file.path}`}
alt={`${file.name}`}
/>
)}
<p className="mt-1 text-sm font-semibold text-muted-foreground">
+86 -81
View File
@@ -16,17 +16,13 @@ import {
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { ClickableTooltip } from "@/components/ui/tooltip";
import UserFileList from "@/components/file/file-list";
import { Icons } from "@/components/shared/icons";
@@ -84,6 +80,8 @@ export default function UserFileManager({ user, action }: FileListProps) {
provider_name: "",
public: true,
});
const [currentProvider, setCurrentProvider] =
useState<ClientStorageCredentials | null>(null);
const [selectedFiles, setSelectedFiles] = useState<UserFileData[]>([]);
const [isDeleting, startDeleteTransition] = useTransition();
@@ -100,8 +98,8 @@ export default function UserFileManager({ user, action }: FileListProps) {
const { mutate } = useSWRConfig();
const { data: r2Configs, isLoading } = useSWR<ClientStorageCredentials>(
`${action}/r2/files/configs`,
const { data: s3Configs, isLoading } = useSWR<ClientStorageCredentials[]>(
`${action}/s3/files/configs`,
fetcher,
{ revalidateOnFocus: false },
);
@@ -112,7 +110,7 @@ export default function UserFileManager({ user, action }: FileListProps) {
error,
} = useSWR<FileListData>(
currentBucketInfo.bucket
? `${action}/r2/files?bucket=${currentBucketInfo.bucket}&page=${currentPage}&pageSize=${pageSize}&name=${searchParams.name}&fileSize=${searchParams.fileSize}&mimeType=${searchParams.mimeType}&status=${searchParams.status}`
? `${action}/s3/files?provider=${currentBucketInfo.provider_name}&bucket=${currentBucketInfo.bucket}&page=${currentPage}&pageSize=${pageSize}&name=${searchParams.name}&fileSize=${searchParams.fileSize}&mimeType=${searchParams.mimeType}&status=${searchParams.status}`
: null,
fetcher,
{
@@ -127,36 +125,43 @@ export default function UserFileManager({ user, action }: FileListProps) {
);
useEffect(() => {
if (
r2Configs &&
r2Configs.buckets &&
r2Configs.buckets.length > 0 &&
r2Configs.buckets[0].bucket
) {
if (s3Configs && s3Configs.length > 0) {
setCurrentProvider(s3Configs[0]);
setCurrentBucketInfo({
...r2Configs.buckets[0],
platform: r2Configs.platform,
channel: r2Configs.channel,
provider_name: r2Configs.provider_name,
bucket: s3Configs[0].buckets[0].bucket,
custom_domain: s3Configs[0].buckets[0].custom_domain,
prefix: s3Configs[0].buckets[0].prefix,
platform: s3Configs[0].platform,
channel: s3Configs[0].channel,
provider_name: s3Configs[0].provider_name,
public: s3Configs[0].buckets[0].public,
});
}
}, [r2Configs]);
}, [s3Configs]);
const handleRefresh = () => {
setSelectedFiles([]);
mutate(
`${action}/r2/files?bucket=${currentBucketInfo.bucket}&page=${currentPage}&pageSize=${pageSize}&name=${searchParams.name}&fileSize=${searchParams.fileSize}&mimeType=${searchParams.mimeType}&status=${searchParams.status}`,
`${action}/s3/files?provider=${currentBucketInfo.provider_name}&bucket=${currentBucketInfo.bucket}&page=${currentPage}&pageSize=${pageSize}&name=${searchParams.name}&fileSize=${searchParams.fileSize}&mimeType=${searchParams.mimeType}&status=${searchParams.status}`,
undefined,
);
};
const handleChangeBucket = (bucket: string) => {
const newBucketInfo = r2Configs?.buckets?.find(
(item) => item.bucket === bucket,
);
const handleChangeBucket = (
provider: ClientStorageCredentials,
bucket: string,
) => {
console.log(provider, bucket);
setCurrentBucketInfo({
...currentBucketInfo,
...newBucketInfo,
bucket: bucket,
custom_domain: provider.buckets.find((b) => b.bucket === bucket)
?.custom_domain,
prefix: provider.buckets.find((b) => b.bucket === bucket)?.prefix,
platform: provider.platform,
channel: provider.channel,
provider_name: provider.provider_name,
public: true,
});
};
@@ -172,13 +177,14 @@ export default function UserFileManager({ user, action }: FileListProps) {
startDeleteTransition(async () => {
try {
toast.promise(
fetch(`${action}/r2/files`, {
fetch(`${action}/s3/files`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
keys: selectedFiles.map((file) => file.path),
ids: selectedFiles.map((file) => file.id),
bucket: currentBucketInfo.bucket,
provider: currentBucketInfo.provider_name,
}),
}),
{
@@ -252,58 +258,59 @@ export default function UserFileManager({ user, action }: FileListProps) {
</div>
{/* Storage */}
{files && files.totalSize > 0 && plan && (
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger className="flex items-center gap-2">
<CircularStorageIndicator files={files} plan={plan} size={36} />
</TooltipTrigger>
<TooltipContent className="w-80">
<ClickableTooltip
content={
<div className="w-80">
<FileSizeDisplay files={files} plan={plan} t={t} />
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
}
>
<CircularStorageIndicator files={files} plan={plan} size={36} />
</ClickableTooltip>
)}
{/* Bucket Select */}
{isLoading ? (
<Skeleton className="h-9 w-[120px] rounded border-r-0 shadow-inner" />
) : (
r2Configs &&
r2Configs.buckets &&
r2Configs.buckets.length > 0 &&
r2Configs.buckets[0].bucket && (
s3Configs &&
s3Configs.length > 0 && (
<Select
value={currentBucketInfo.bucket}
onValueChange={handleChangeBucket}
value={`${currentBucketInfo.provider_name}|${currentBucketInfo.bucket}`}
onValueChange={(value) => {
const [providerName, bucketName] = value.split("|");
const provider = s3Configs.find(
(p) => p.provider_name === providerName,
);
provider && handleChangeBucket(provider, bucketName);
}}
>
<SelectTrigger className="flex-1 sm:w-[120px] sm:flex-none">
<SelectTrigger className="flex-1 break-all text-left sm:w-[120px] sm:flex-none">
<SelectValue placeholder="Select a bucket" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel className="mx-auto text-center">
{r2Configs?.provider_name}
</SelectLabel>
{r2Configs?.buckets?.map((item) => (
<SelectItem
key={item.bucket}
value={item.bucket}
onClick={() => handleChangeBucket(item.bucket)}
>
{item.bucket}
</SelectItem>
))}
</SelectGroup>
{/* <SelectSeparator /> */}
{s3Configs.map((provider) => (
<SelectGroup>
<SelectLabel>{provider.provider_name}</SelectLabel>
{provider.buckets?.map((item) => (
<SelectItem
key={item.bucket}
value={`${provider.provider_name}|${item.bucket}`}
>
{item.bucket}
</SelectItem>
))}
<SelectSeparator />
</SelectGroup>
))}
</SelectContent>
</Select>
)
)}
{/* Uploader */}
{!isLoading &&
r2Configs &&
r2Configs.buckets &&
r2Configs.buckets.length > 0 &&
r2Configs.buckets[0].bucket && (
s3Configs &&
s3Configs.length > 0 &&
currentBucketInfo && (
<FileUploader
bucketInfo={currentBucketInfo}
action="/api/storage"
@@ -408,27 +415,25 @@ export default function UserFileManager({ user, action }: FileListProps) {
</EmptyPlaceholder>
)}
{!isLoading &&
!error &&
(!r2Configs?.buckets?.length || !r2Configs?.buckets?.[0].bucket) && (
<EmptyPlaceholder className="col-span-full mt-8 shadow-none">
<EmptyPlaceholder.Icon name="storage" />
<EmptyPlaceholder.Title>
{t("No buckets found")}
</EmptyPlaceholder.Title>
<EmptyPlaceholder.Description>
{t(
"The administrator has not configured the storage bucket, no file can be uploaded",
)}
</EmptyPlaceholder.Description>
</EmptyPlaceholder>
)}
{!isLoading && !error && !s3Configs && !currentBucketInfo && (
<EmptyPlaceholder className="col-span-full mt-8 shadow-none">
<EmptyPlaceholder.Icon name="storage" />
<EmptyPlaceholder.Title>
{t("No buckets found")}
</EmptyPlaceholder.Title>
<EmptyPlaceholder.Description>
{t(
"The administrator has not configured the storage bucket, no file can be uploaded",
)}
</EmptyPlaceholder.Description>
</EmptyPlaceholder>
)}
{!isLoading &&
!error &&
r2Configs?.buckets &&
r2Configs.buckets.length > 0 &&
r2Configs?.buckets[0].bucket && (
s3Configs &&
s3Configs.length > 0 &&
currentBucketInfo && (
<UserFileList
user={user}
files={files}
+4 -4
View File
@@ -62,7 +62,7 @@ export function FileSizeDisplay({ files, plan, t }) {
{t("usedSpace")}:
</span>
<span className="text-sm font-medium">
{formatFileSize(totalSize, { precision: 0 })}
{formatFileSize(totalSize, { precision: 2 })}
</span>
</div>
<div className="flex items-center justify-between">
@@ -70,7 +70,7 @@ export function FileSizeDisplay({ files, plan, t }) {
{t("totalCapacity")}:
</span>
<span className="text-sm font-medium">
{formatFileSize(maxSize, { precision: 0 })}
{formatFileSize(maxSize, { precision: 2 })}
</span>
</div>
<div className="flex items-center justify-between">
@@ -78,7 +78,7 @@ export function FileSizeDisplay({ files, plan, t }) {
{t("availableSpace")}:
</span>
<span className="text-sm font-medium">
{formatFileSize(maxSize - totalSize, { precision: 0 })}
{formatFileSize(maxSize - totalSize, { precision: 2 })}
</span>
</div>
</div>
@@ -121,7 +121,7 @@ export function CircularStorageIndicator({ files, plan, size = 32 }) {
return (
<div
className="relative inline-block"
className="relative flex cursor-pointer items-center"
style={{ width: size, height: size }}
>
<svg width={size} height={size} className="-rotate-90 transform">
+1 -1
View File
@@ -46,7 +46,7 @@ export const FileUploader = ({
retryUpload,
startUpload,
clearAll,
} = useFileUpload({ api: `${action}/r2/upload`, bucketInfo, userId });
} = useFileUpload({ api: `${action}/s3/upload`, bucketInfo, userId });
useEffect(() => {
if (selectedFile) {
+6 -2
View File
@@ -5,6 +5,7 @@ import { useState } from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
import { useMediaQuery } from "@/hooks/use-media-query";
const TooltipProvider = TooltipPrimitive.Provider;
@@ -43,6 +44,7 @@ export {
export const ClickableTooltip = ({ children, content, className = "" }) => {
const [open, setOpen] = useState(false);
const { isMobile } = useMediaQuery();
const handleClick = (e) => {
e.preventDefault();
@@ -65,12 +67,14 @@ export const ClickableTooltip = ({ children, content, className = "" }) => {
onFocus={(e) => e.preventDefault()} // 阻止焦点事件
onBlur={(e) => e.preventDefault()}
>
<div onClick={handleClick} className={className}>
<div onClick={handleClick} className={className} title="Details">
{children}
</div>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent className="p-1">{content}</TooltipContent>
<TooltipContent className="p-1" side={isMobile ? "bottom" : "right"}>
{content}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</TooltipProvider>
+2 -1
View File
@@ -139,8 +139,9 @@ export function useFileUpload({ bucketInfo, userId, api }: Props) {
},
body: JSON.stringify({
files: filesData,
bucket: bucketInfo.bucket,
prefix: bucketInfo.prefix,
bucket: bucketInfo.bucket,
provider: bucketInfo.provider_name,
}),
});
+8 -1
View File
@@ -625,6 +625,13 @@
"Optional": "Optional",
"Allowed File Types": "Allowed File Types",
"Public": "Public",
"Publicize this storage bucket, all registered users can upload files to this storage bucket; If not public, only administrators can upload files to this storage bucket": "Publicize this storage bucket, all registered users can upload files to this storage bucket; If not public, only administrators can upload files to this storage bucket"
"Publicize this storage bucket, all registered users can upload files to this storage bucket; If not public, only administrators can upload files to this storage bucket": "Publicize this storage bucket, all registered users can upload files to this storage bucket; If not public, only administrators can upload files to this storage bucket",
"Provider": "Provider",
"How to get the S3 credentials?": "How to get the S3 credentials?",
"Provider Unique Name": "Provider Unique Name",
"Unique": "Unique",
"Add Provider": "Add Provider",
"{length} Buckets": "{length} Buckets",
"Save Modifications": "Save Modifications"
}
}
+8 -1
View File
@@ -625,6 +625,13 @@
"Optional": "可选",
"Allowed File Types": "允许的文件类型",
"Public": "公开",
"Publicize this storage bucket, all registered users can upload files to this storage bucket; If not public, only administrators can upload files to this storage bucket": "公开此存储桶,所有注册用户都可以上传文件到此存储桶; 若不公开,只有管理员可以上传文件到此存储桶"
"Publicize this storage bucket, all registered users can upload files to this storage bucket; If not public, only administrators can upload files to this storage bucket": "公开此存储桶,所有注册用户都可以上传文件到此存储桶; 若不公开,只有管理员可以上传文件到此存储桶",
"Provider": "提供渠道",
"How to get the S3 credentials?": "如何获取 S3 授权配置?",
"Provider Unique Name": "渠道名称",
"Unique": "唯一",
"Add Provider": "添加渠道",
"{length} Buckets": "{length}个存储桶",
"Save Modifications": "保存修改"
}
}
@@ -0,0 +1,15 @@
-- S3 配置
INSERT INTO "system_configs"
(
"key",
"value",
"type",
"description"
)
VALUES
(
's3_config_list',
'[{"enabled":true,"platform":"cloudflare","channel":"r2","provider_name":"Cloudflare R2","account_id":"","access_key_id":"","secret_access_key":"","endpoint":"https://<account_id>.r2.cloudflarestorage.com","buckets":[{"bucket":"","prefix":"","file_types":"","region":"auto","custom_domain":"","file_size":"26214400","public":true}]},{"enabled":false,"platform":"tencent","channel":"cos","provider_name":"腾讯云 COS","endpoint":"","account_id":"","access_key_id":"","secret_access_key":"","buckets":[{"custom_domain":"","prefix":"","bucket":"","file_types":"","file_size":"26214400","region":"","public":true}]}]',
'OBJECT',
'R2 存储桶配置'
);
+1 -1
View File
File diff suppressed because one or more lines are too long