Merge pull request #55 from oiov/dev
Refact s3 configs and support muti s3 provider
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
+1
-5
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
@@ -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)})`}
|
||||
{`})`}
|
||||
</p>
|
||||
<CopyButton
|
||||
className="size-6"
|
||||
value={`[${file.name}](${getFileUrl(file.path)})`}
|
||||
value={`})`}
|
||||
/>
|
||||
</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
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user