Merge pull request #42 from oiov/s3/cloudflare-r2

S3/cloudflare r2
This commit is contained in:
oiov
2025-07-09 16:33:19 +08:00
committed by GitHub
73 changed files with 5829 additions and 128 deletions
+1
View File
@@ -4,6 +4,7 @@ on:
push:
branches:
- main
- s3/cloudflare-r2
tags:
- "v*.*.*"
pull_request:
+11 -1
View File
@@ -13,7 +13,7 @@
## 简介
WR.DO 是一个一站式网络工具平台,集成短链服务、临时邮箱、子域名管理和开放API接口。支持自定义链接、密码保护、访问统计;提供无限制临时邮箱收发;管理多域名DNS记录;内置网站截图、元数据提取等实用API。完整的管理后台,支持用户权限控制和服务配置。
WR.DO 是一个一站式网络工具平台,集成短链服务、临时邮箱、子域名管理、文件存储和开放API接口。支持自定义链接、密码保护、访问统计;提供无限制临时邮箱收发;管理多域名DNS记录;支持云存储,对接 S3 API内置网站截图、元数据提取等实用API。完整的管理后台,支持用户权限控制和服务配置。
- 官网: [https://wr.do](https://wr.do)
- Demo: [https://699399.xyz](https://699399.xyz) (账号: `admin@admin.com`, 密码: `123456`)
@@ -45,6 +45,16 @@ WR.DO 是一个一站式网络工具平台,集成短链服务、临时邮箱
- 支持开启申请模式(用户提交、管理员审批)
- 支持邮件通知管理员、用户域名申请状态
- 💳 **云存储服务**
- 接入多渠道(S3 API)云存储平台(Cloudflare R2、AWS S3
- 支持单渠道多存储桶配置
- 动态配置(用户配额设置)文件上传大小限制
- 支持拖拽、批量、分块上传文件
- 支持批量删除文件
- 快捷生成文件短链、二维码
- 支持部分文件在线预览内容
- 支持调用 API 上传文件
- 📡 **开放接口模块**
- 获取网站元数据 API
- 获取网站截图 API
+11 -1
View File
@@ -11,7 +11,7 @@
## Introduction
WR.DO is a all-in-one web utility platform featuring short links with analytics, temporary email service, subdomain management, open APIs for screenshots and metadata extraction, plus comprehensive admin dashboard.
WR.DO is a all-in-one web utility platform featuring short links with analytics, temporary email service, subdomain management, file storage, open APIs for screenshots and metadata extraction, and comprehensive admin dashboard.
- Official website: [https://wr.do](https://wr.do)
- Demo: [https://699399.xyz](https://699399.xyz) (Account: `admin@admin.com`, Password: `123456`)
@@ -43,6 +43,16 @@ WR.DO is a all-in-one web utility platform featuring short links with analytics,
- Support enabling application mode (user submission, admin approval)
- Support email notification of administrator and user domain application status
- 💳 **Cloud Storage Service**
- Connects to multiple channels (S3 API) cloud storage platforms (Cloudflare R2, AWS S3)
- Supports single-channel multi-bucket configuration
- Dynamic configuration (user quota settings) for file upload size limits
- Supports drag-and-drop, batch, and chunked file uploads
- Supports batch file deletion
- Quickly generates short links and QR codes for files
- Supports online preview of certain file types
- Supports file uploads via API calls
- 📡 **Open API Module**:
- Website metadata extraction API
- Website screenshot capture API
+14
View File
@@ -0,0 +1,14 @@
import { Skeleton } from "@/components/ui/skeleton";
import { DashboardHeader } from "@/components/dashboard/header";
export default function DashboardRecordsLoading() {
return (
<>
<DashboardHeader
heading="Manage DNS Records"
text="List and manage records"
/>
<Skeleton className="h-[400px] w-full rounded-lg" />
</>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils";
import { DashboardHeader } from "@/components/dashboard/header";
import UserFileList from "@/components/file";
export const metadata = constructMetadata({
title: "Cloud Storage",
description: "List and manage cloud storage.",
});
export default async function DashboardPage() {
const user = await getCurrentUser();
if (!user?.id) redirect("/login");
return (
<>
<DashboardHeader
heading="Cloud Storage"
text="List and manage cloud storage"
link="/docs/cloud-storage"
linkText="Cloud Storage"
/>
<UserFileList
user={{
id: user.id,
name: user.name || "",
apiKey: user.apiKey || "",
email: user.email || "",
role: user.role,
team: user.team,
}}
action="/api/storage/admin"
/>
</>
);
}
+2 -3
View File
@@ -3,7 +3,6 @@
import { useState, useTransition } from "react";
import Link from "next/link";
import { User } from "@prisma/client";
import { PenLine, RefreshCwIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import useSWR, { useSWRConfig } from "swr";
@@ -172,9 +171,9 @@ export default function DomainList({ user, action }: DomainListProps) {
disabled={isLoading}
>
{isLoading ? (
<RefreshCwIcon className="size-4 animate-spin" />
<Icons.refreshCw className="size-4 animate-spin" />
) : (
<RefreshCwIcon className="size-4" />
<Icons.refreshCw className="size-4" />
)}
</Button>
<Button
+2
View File
@@ -7,6 +7,7 @@ import { DashboardHeader } from "@/components/dashboard/header";
import AppConfigs from "./app-configs";
import DomainList from "./domain-list";
import PlanList from "./plan-list";
import S3Configs from "./s3-list";
export const metadata = constructMetadata({
title: "System Settings",
@@ -22,6 +23,7 @@ export default async function DashboardPage() {
<>
<DashboardHeader heading="System Settings" text="" />
<AppConfigs />
<S3Configs />
<DomainList
user={{
id: user.id,
+3 -3
View File
@@ -2,7 +2,7 @@
import { useState } from "react";
import { User } from "@prisma/client";
import { PenLine, RefreshCwIcon } from "lucide-react";
import { PenLine } from "lucide-react";
import { useTranslations } from "next-intl";
import useSWR, { useSWRConfig } from "swr";
@@ -111,9 +111,9 @@ export default function PlanList({ user, action }: PlanListProps) {
disabled={isLoading}
>
{isLoading ? (
<RefreshCwIcon className="size-4 animate-spin" />
<Icons.refreshCw className="size-4 animate-spin" />
) : (
<RefreshCwIcon className="size-4" />
<Icons.refreshCw className="size-4" />
)}
</Button>
<Button
+473
View File
@@ -0,0 +1,473 @@
"use client";
import { useEffect, useMemo, useState, useTransition } from "react";
import Link from "next/link";
import { motion } from "framer-motion";
import { CloudCog } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import useSWR from "swr";
import { CloudStorageCredentials } from "@/lib/r2";
import { cn, fetcher } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
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 {
data: configs,
isLoading,
mutate,
} = useSWR<Record<string, any>>("/api/admin/s3", fetcher);
useEffect(() => {
if (configs) {
setR2Credentials(configs.s3_config_01);
}
}, [configs]);
const handleSaveConfigs = (value: any, key: string, type: string) => {
startTransition(async () => {
const res = await fetch("/api/admin/s3", {
method: "POST",
body: JSON.stringify({ key, value, type }),
});
if (res.ok) {
toast.success("Saved");
mutate();
} else {
toast.error("Failed to save", {
description: await res.text(),
});
}
});
};
const handleR2CheckAccess = async (event) => {
event.preventDefault();
event.stopPropagation();
toast.success("Checking");
};
const handleCOSCheckAccess = async (event) => {
event.preventDefault();
event.stopPropagation();
toast.success("Checking");
};
const canSaveR2Credentials = useMemo(() => {
if (!configs) return true;
return Object.keys(r2Credentials).some(
(key) => r2Credentials[key] !== configs.s3_config_01[key],
);
}, [r2Credentials, configs]);
const ReadyBadge = (
isChecked: boolean,
isChecking: boolean,
type: string,
) => (
<Badge
className={cn("ml-auto text-xs font-semibold")}
variant={isChecked ? "green" : "default"}
onClick={(event) =>
type === "r2" ? handleR2CheckAccess(event) : handleCOSCheckAccess(event)
}
>
{isChecking && <Icons.spinner className="mr-1 size-3 animate-spin" />}
{isChecked && !isChecking && <Icons.check className="mr-1 size-3" />}
{isChecked ? t("Verified") : t("Verify Configuration")}
</Badge>
);
if (isLoading) {
return <Skeleton className="h-48 w-full rounded-lg" />;
}
return (
<Card>
<Collapsible className="group" 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" />
</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}
</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,
});
}}
>
<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,
});
}}
>
<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>
<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>
<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,
});
}}
/>
</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>
</Card>
);
}
+3 -3
View File
@@ -2,7 +2,7 @@
import { useState } from "react";
import { User } from "@prisma/client";
import { PenLine, RefreshCwIcon } from "lucide-react";
import { PenLine } from "lucide-react";
import { useTranslations } from "next-intl";
import useSWR, { useSWRConfig } from "swr";
@@ -117,9 +117,9 @@ export default function UsersList({ user }: UrlListProps) {
disabled={isLoading}
>
{isLoading ? (
<RefreshCwIcon className="size-4 animate-spin" />
<Icons.refreshCw className="size-4 animate-spin" />
) : (
<RefreshCwIcon className="size-4" />
<Icons.refreshCw className="size-4" />
)}
</Button>
<Button
@@ -3,7 +3,7 @@
import { useState } from "react";
import Link from "next/link";
import { User } from "@prisma/client";
import { PenLine, RefreshCwIcon } from "lucide-react";
import { PenLine } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import useSWR, { useSWRConfig } from "swr";
@@ -181,9 +181,9 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
disabled={isLoading}
>
{isLoading ? (
<RefreshCwIcon className="size-4 animate-spin" />
<Icons.refreshCw className="size-4 animate-spin" />
) : (
<RefreshCwIcon className="size-4" />
<Icons.refreshCw className="size-4" />
)}
</Button>
<Button
+3 -3
View File
@@ -1,7 +1,6 @@
"use client";
import { useState } from "react";
import { RefreshCwIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import useSWR, { useSWRConfig } from "swr";
@@ -17,6 +16,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Icons } from "@/components/shared/icons";
import { PaginationWrapper } from "@/components/shared/pagination";
export interface LogsTableData {
@@ -132,9 +132,9 @@ const LogsTable = ({ userId, target }) => {
className="ml-2 h-8 px-2 py-0"
>
{isLoading ? (
<RefreshCwIcon className={`size-4 animate-spin`} />
<Icons.refreshCw className={`size-4 animate-spin`} />
) : (
<RefreshCwIcon className={`size-4`} />
<Icons.refreshCw className={`size-4`} />
)}
</Button>
</div>
@@ -0,0 +1,14 @@
import { Skeleton } from "@/components/ui/skeleton";
import { DashboardHeader } from "@/components/dashboard/header";
export default function DashboardRecordsLoading() {
return (
<>
<DashboardHeader
heading="Manage DNS Records"
text="List and manage records"
/>
<Skeleton className="h-[400px] w-full rounded-lg" />
</>
);
}
@@ -0,0 +1,39 @@
import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils";
import { DashboardHeader } from "@/components/dashboard/header";
import UserFileList from "@/components/file";
export const metadata = constructMetadata({
title: "Cloud Storage",
description: "List and manage cloud storage.",
});
export default async function DashboardPage() {
const user = await getCurrentUser();
if (!user?.id) redirect("/login");
return (
<>
<DashboardHeader
heading="Cloud Storage"
text="List and manage cloud storage"
link="/docs/cloud-storage"
linkText="Cloud Storage"
/>
<UserFileList
user={{
id: user.id,
name: user.name || "",
apiKey: user.apiKey || "",
email: user.email || "",
role: user.role,
team: user.team,
}}
action="/api/storage"
/>
</>
);
}
+2 -2
View File
@@ -180,9 +180,9 @@ export default function LiveLog({ admin = false }: { admin?: boolean }) {
disabled={!isLive}
>
{isLoading ? (
<RefreshCwIcon className="size-4 animate-spin" />
<Icons.refreshCw className="size-4 animate-spin" />
) : (
<RefreshCwIcon className="size-4" />
<Icons.refreshCw className="size-4" />
)}
</Button>
<Button
+3 -3
View File
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useState, useTransition } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { User } from "@prisma/client";
import { PenLine, RefreshCwIcon } from "lucide-react";
import { PenLine } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import useSWR, { useSWRConfig } from "swr";
@@ -702,9 +702,9 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
disabled={isLoading}
>
{isLoading ? (
<RefreshCwIcon className="size-4 animate-spin" />
<Icons.refreshCw className="size-4 animate-spin" />
) : (
<RefreshCwIcon className="size-4" />
<Icons.refreshCw className="size-4" />
)}
</Button>
{action.indexOf("admin") === -1 && (
+6
View File
@@ -52,6 +52,9 @@ export async function POST(req: NextRequest) {
rcNewRecords: plan.rcNewRecords,
emEmailAddresses: plan.emEmailAddresses,
emDomains: plan.emDomains,
stMaxFileSize: plan.stMaxFileSize,
stMaxTotalSize: plan.stMaxTotalSize,
stMaxFileCount: plan.stMaxFileCount,
emSendEmails: plan.emSendEmails,
appSupport: plan.appSupport.toUpperCase() as any,
appApiAccess: plan.appApiAccess,
@@ -95,6 +98,9 @@ export async function PUT(req: NextRequest) {
emEmailAddresses: plan.emEmailAddresses,
emDomains: plan.emDomains,
emSendEmails: plan.emSendEmails,
stMaxFileSize: plan.stMaxFileSize,
stMaxTotalSize: plan.stMaxTotalSize,
stMaxFileCount: plan.stMaxFileCount,
appSupport: plan.appSupport.toUpperCase() as any,
appApiAccess: plan.appApiAccess,
isActive: plan.isActive,
+58
View File
@@ -0,0 +1,58 @@
import { NextRequest } from "next/server";
import {
getMultipleConfigs,
updateSystemConfig,
} from "@/lib/dto/system-config";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
export async function GET(req: NextRequest) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
if (user.role !== "ADMIN") {
return Response.json("Unauthorized", { status: 401 });
}
const s3ConfigKeys = [
"s3_config_01",
"s3_config_02",
"s3_config_03",
"s3_config_04",
];
const configs = await getMultipleConfigs(s3ConfigKeys);
return Response.json(configs, { status: 200 });
} catch (error) {
console.error("[Error]", error);
return Response.json(error.message || "Server error", { status: 500 });
}
}
export async function POST(req: NextRequest) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
if (user.role !== "ADMIN") {
return Response.json("Unauthorized", { status: 401 });
}
const { key, value, type } = await req.json();
if (!key || !type) {
return Response.json("key and value is required", { status: 400 });
}
const configs = await getMultipleConfigs([key]);
if (key in configs) {
await updateSystemConfig(key, { value, type });
return Response.json("Success", { status: 200 });
}
return Response.json("Invalid key", { status: 400 });
} catch (error) {
console.error("[Error]", error);
return Response.json(error.message || "Server error", { status: 500 });
}
}
+3
View File
@@ -2,6 +2,8 @@ import { getUserRecords } from "@/lib/dto/cloudflare-dns-record";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
export const dynamic = "force-dynamic";
export async function GET(req: Request) {
try {
const user = checkUserStatus(await getCurrentUser());
@@ -26,6 +28,7 @@ export async function GET(req: Request) {
return Response.json(data);
} catch (error) {
console.error("[Error]", error);
return Response.json(error?.statusText || error, {
status: error.status || 500,
});
+34
View File
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import { getMultipleConfigs } from "@/lib/dto/system-config";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
export async function GET(req: NextRequest) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
if (user.role !== "ADMIN") {
return Response.json("Unauthorized", {
status: 401,
statusText: "Unauthorized",
});
}
const configs = await getMultipleConfigs(["s3_config_01"]);
if (!configs.s3_config_01 || !configs.s3_config_01.enabled) {
return NextResponse.json({ error: "Invalid S3 config" }, { status: 400 });
}
return NextResponse.json({
buckets: configs.s3_config_01.buckets,
enabled: configs.s3_config_01.enabled,
provider_name: configs.s3_config_01.provider_name,
platform: configs.s3_config_01.platform,
channel: configs.s3_config_01.channel,
});
} catch (error) {
return NextResponse.json({ error: "Error listing files" }, { status: 500 });
}
}
+178
View File
@@ -0,0 +1,178 @@
import { NextRequest, NextResponse } from "next/server";
import { getUserFiles, softDeleteUserFiles } from "@/lib/dto/files";
import { getMultipleConfigs } from "@/lib/dto/system-config";
import { checkUserStatus } from "@/lib/dto/user";
import { createS3Client, deleteFile, getSignedUrlForDownload } from "@/lib/r2";
import { getCurrentUser } from "@/lib/session";
export async function GET(req: NextRequest) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
if (user.role !== "ADMIN") {
return Response.json("Unauthorized", {
status: 401,
statusText: "Unauthorized",
});
}
const url = new URL(req.url);
const page = url.searchParams.get("page");
const size = url.searchParams.get("size");
const bucket = url.searchParams.get("bucket") || "";
const configs = await getMultipleConfigs(["s3_config_01"]);
if (!configs.s3_config_01.enabled) {
return NextResponse.json("S3 is not enabled", {
status: 403,
});
}
if (
!configs.s3_config_01 ||
!configs.s3_config_01.access_key_id ||
!configs.s3_config_01.secret_access_key ||
!configs.s3_config_01.endpoint
) {
return NextResponse.json("Invalid S3 config", {
status: 403,
});
}
const buckets = configs.s3_config_01.buckets || [];
if (!buckets.find((b) => b.bucket === bucket)) {
return NextResponse.json("Bucket does not exist", {
status: 403,
});
}
const res = await getUserFiles({
page: Number(page) || 1,
limit: Number(size) || 20,
bucket,
channel: configs.s3_config_01.channel,
platform: configs.s3_config_01.platform,
});
return NextResponse.json(res);
} catch (error) {
console.error("Error listing files:", error);
return NextResponse.json({ error: "Error listing files" }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
if (user.role !== "ADMIN") {
return Response.json("Unauthorized", {
status: 401,
statusText: "Unauthorized",
});
}
const { key, bucket } = await request.json();
if (!key || !bucket) {
return NextResponse.json("key and bucket is required", {
status: 400,
});
}
const configs = await getMultipleConfigs(["s3_config_01"]);
if (!configs.s3_config_01.enabled) {
return NextResponse.json("S3 is not enabled", {
status: 403,
});
}
if (
!configs.s3_config_01 ||
!configs.s3_config_01.access_key_id ||
!configs.s3_config_01.secret_access_key ||
!configs.s3_config_01.endpoint
) {
return NextResponse.json("Invalid S3 config", {
status: 403,
});
}
const buckets = configs.s3_config_01.buckets || [];
if (!buckets.find((b) => b.bucket === bucket)) {
return NextResponse.json("Bucket does not exist", {
status: 403,
});
}
const signedUrl = await getSignedUrlForDownload(
key,
createS3Client(
configs.s3_config_01.endpoint,
configs.s3_config_01.access_key_id,
configs.s3_config_01.secret_access_key,
),
bucket,
);
return NextResponse.json({ signedUrl });
} catch (error) {
return NextResponse.json(
{ error: "Error generating download URL" },
{ status: 500 },
);
}
}
export async function DELETE(request: NextRequest) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
if (user.role !== "ADMIN") {
return Response.json("Unauthorized", {
status: 401,
statusText: "Unauthorized",
});
}
const { keys, ids, bucket } = await request.json();
if (!keys || !ids || !bucket) {
return NextResponse.json("key and bucket is required", {
status: 400,
});
}
const configs = await getMultipleConfigs(["s3_config_01"]);
if (!configs.s3_config_01.enabled) {
return NextResponse.json("S3 is not enabled", {
status: 403,
});
}
if (
!configs.s3_config_01 ||
!configs.s3_config_01.access_key_id ||
!configs.s3_config_01.secret_access_key ||
!configs.s3_config_01.endpoint
) {
return NextResponse.json("Invalid S3 config", {
status: 403,
});
}
const buckets = configs.s3_config_01.buckets || [];
if (!buckets.find((b) => b.bucket === bucket)) {
return NextResponse.json("Bucket does not exist", {
status: 403,
});
}
const R2 = createS3Client(
configs.s3_config_01.endpoint,
configs.s3_config_01.access_key_id,
configs.s3_config_01.secret_access_key,
);
for (const key of keys) {
await deleteFile(key, R2, bucket);
}
await softDeleteUserFiles(ids);
return NextResponse.json({ message: "File deleted successfully" });
} catch (error) {
return NextResponse.json({ error: "Error deleting file" }, { status: 500 });
}
}
+28
View File
@@ -0,0 +1,28 @@
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 files" }, { status: 500 });
}
}
+162
View File
@@ -0,0 +1,162 @@
import { NextRequest, NextResponse } from "next/server";
import { getUserFiles, softDeleteUserFiles } from "@/lib/dto/files";
import { getMultipleConfigs } from "@/lib/dto/system-config";
import { checkUserStatus } from "@/lib/dto/user";
import { createS3Client, deleteFile, getSignedUrlForDownload } from "@/lib/r2";
import { getCurrentUser } from "@/lib/session";
export async function GET(req: NextRequest) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const url = new URL(req.url);
const page = url.searchParams.get("page");
const size = url.searchParams.get("size");
const bucket = url.searchParams.get("bucket") || "";
const configs = await getMultipleConfigs(["s3_config_01"]);
if (!configs.s3_config_01.enabled) {
return NextResponse.json("S3 is not enabled", {
status: 403,
});
}
if (
!configs.s3_config_01 ||
!configs.s3_config_01.access_key_id ||
!configs.s3_config_01.secret_access_key ||
!configs.s3_config_01.endpoint
) {
return NextResponse.json("Invalid S3 config", {
status: 403,
});
}
const buckets = configs.s3_config_01.buckets || [];
if (!buckets.find((b) => b.bucket === bucket)) {
return NextResponse.json("Bucket does not exist", {
status: 403,
});
}
const res = await getUserFiles({
page: Number(page) || 1,
limit: Number(size) || 20,
bucket,
userId: user.id,
status: 1,
channel: configs.s3_config_01.channel,
platform: configs.s3_config_01.platform,
});
return NextResponse.json(res);
} catch (error) {
console.error("Error listing files:", error);
return NextResponse.json({ error: "Error listing files" }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const { key, bucket } = await request.json();
if (!key || !bucket) {
return NextResponse.json("key and bucket is required", {
status: 400,
});
}
const configs = await getMultipleConfigs(["s3_config_01"]);
if (!configs.s3_config_01.enabled) {
return NextResponse.json("S3 is not enabled", {
status: 403,
});
}
if (
!configs.s3_config_01 ||
!configs.s3_config_01.access_key_id ||
!configs.s3_config_01.secret_access_key ||
!configs.s3_config_01.endpoint
) {
return NextResponse.json("Invalid S3 config", {
status: 403,
});
}
const buckets = configs.s3_config_01.buckets || [];
if (!buckets.find((b) => b.bucket === bucket)) {
return NextResponse.json("Bucket does not exist", {
status: 403,
});
}
const signedUrl = await getSignedUrlForDownload(
key,
createS3Client(
configs.s3_config_01.endpoint,
configs.s3_config_01.access_key_id,
configs.s3_config_01.secret_access_key,
),
bucket,
);
return NextResponse.json({ signedUrl });
} catch (error) {
return NextResponse.json(
{ error: "Error generating download URL" },
{ status: 500 },
);
}
}
export async function DELETE(request: NextRequest) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const { keys, ids, bucket } = await request.json();
if (!keys || !ids || !bucket) {
return NextResponse.json("key and bucket is required", {
status: 400,
});
}
const configs = await getMultipleConfigs(["s3_config_01"]);
if (!configs.s3_config_01.enabled) {
return NextResponse.json("S3 is not enabled", {
status: 403,
});
}
if (
!configs.s3_config_01 ||
!configs.s3_config_01.access_key_id ||
!configs.s3_config_01.secret_access_key ||
!configs.s3_config_01.endpoint
) {
return NextResponse.json("Invalid S3 config", {
status: 403,
});
}
const buckets = configs.s3_config_01.buckets || [];
if (!buckets.find((b) => b.bucket === bucket)) {
return NextResponse.json("Bucket does not exist", {
status: 403,
});
}
const R2 = createS3Client(
configs.s3_config_01.endpoint,
configs.s3_config_01.access_key_id,
configs.s3_config_01.secret_access_key,
);
for (const key of keys) {
await deleteFile(key, R2, bucket);
}
await softDeleteUserFiles(ids);
return NextResponse.json({ message: "File deleted successfully" });
} catch (error) {
return NextResponse.json({ error: "Error deleting file" }, { status: 500 });
}
}
+68
View File
@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from "next/server";
import { updateUserFile } from "@/lib/dto/files";
import { getUserShortLinksByIds } from "@/lib/dto/short-urls";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
export async function POST(request: NextRequest) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const { ids } = await request.json();
if (!ids) {
return NextResponse.json({ error: "Ids are required" }, { status: 400 });
}
const data = await getUserShortLinksByIds(ids, user.id);
// ids:["cmcrqtql10001zbhynvwfuqza", "", "", "", ""],则返回的短链位置也要一一对应
const dataMap = new Map(data.map((item) => [item.id, item]));
const orderedResults = ids.map((id) => {
const item = dataMap.get(id);
return item ? `${item.prefix}/s/${item.url}` : "";
});
return NextResponse.json({
urls: orderedResults,
});
} catch (error) {
return NextResponse.json(
{ error: "Error generating download URL" },
{ status: 500 },
);
}
}
export async function PUT(request: NextRequest) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const { urlId, fileId } = await request.json();
if (!urlId || !fileId) {
return NextResponse.json(
{ error: "Slug and fileId are required" },
{ status: 400 },
);
}
const res = await updateUserFile(fileId, {
shortUrlId: urlId,
});
if (res.success) {
return NextResponse.json({ success: true });
} else {
return NextResponse.json({ error: res.error }, { status: 400 });
}
} catch (error) {
return NextResponse.json(
{ error: "Error generating download URL" },
{ status: 500 },
);
}
}
+236
View File
@@ -0,0 +1,236 @@
import { NextResponse } from "next/server";
import {
AbortMultipartUploadCommand,
CompleteMultipartUploadCommand,
CreateMultipartUploadCommand,
S3Client,
UploadPartCommand,
} from "@aws-sdk/client-s3";
import { User } from "@prisma/client";
import { createUserFile } from "@/lib/dto/files";
import { getPlanQuota } from "@/lib/dto/plan";
import { getMultipleConfigs } from "@/lib/dto/system-config";
import { checkUserStatus } from "@/lib/dto/user";
import { CloudStorageCredentials, createS3Client } from "@/lib/r2";
import { getCurrentUser } from "@/lib/session";
import { restrictByTimeRange } from "@/lib/team";
import { extractFileNameAndExtension, generateFileKey } from "@/lib/utils";
export async function POST(request: Request): Promise<Response> {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const formData = await request.formData();
const endpoint = formData.get("endPoint");
const bucket = formData.get("bucket");
const configs = await getMultipleConfigs(["s3_config_01"]);
if (!configs.s3_config_01.enabled) {
return NextResponse.json("S3 is not enabled", {
status: 403,
});
}
if (
!configs.s3_config_01 ||
!configs.s3_config_01.access_key_id ||
!configs.s3_config_01.secret_access_key ||
!configs.s3_config_01.endpoint
) {
return NextResponse.json("Invalid S3 config", {
status: 403,
});
}
const buckets = configs.s3_config_01.buckets || [];
if (!buckets.find((b) => b.bucket === bucket)) {
return NextResponse.json("Bucket does not exist", {
status: 403,
});
}
const R2 = createS3Client(
configs.s3_config_01.endpoint,
configs.s3_config_01.access_key_id,
configs.s3_config_01.secret_access_key,
);
switch (endpoint) {
case "create-multipart-upload":
return createMultipartUpload(user, formData, R2);
case "complete-multipart-upload":
return completeMultipartUpload(
formData,
R2,
user.id,
configs.s3_config_01,
);
case "abort-multipart-upload":
return abortMultipartUpload(formData, R2);
case "upload-part":
return uploadPart(formData, R2);
default:
return new Response(JSON.stringify({ error: "Endpoint not found" }), {
status: 404,
});
}
}
// Initiates a multipart upload
async function createMultipartUpload(
user: User,
formData: FormData,
R2: S3Client,
): Promise<Response> {
const fileName = formData.get("fileName") as string;
const fileType = formData.get("fileType") as string;
const fileSize = Number(formData.get("fileSize") as string);
const bucket = formData.get("bucket") as string;
const prefix = (formData.get("prefix") as string) || "";
const plan = await getPlanQuota(user.team!);
const limit = await restrictByTimeRange({
model: "userUrl",
userId: user.id,
limit: Number(plan.stMaxFileSize),
rangeType: "month",
});
if (limit) return Response.json(limit.statusText, { status: limit.status });
const fileKey = generateFileKey(fileName, prefix);
try {
const params = {
Bucket: bucket,
Key: fileKey,
ContentType: fileType,
};
const command = new CreateMultipartUploadCommand({ ...params });
const response = await R2.send(command);
return new Response(
JSON.stringify({
uploadId: response.UploadId,
key: response.Key,
}),
{ status: 200 },
);
} catch (err) {
console.log("Error From Create Multipart Upload => ", err);
return new Response(JSON.stringify({ error: "Internal Server Error" }), {
status: 500,
});
}
}
// Completes a multipart upload
async function completeMultipartUpload(
formData: FormData,
R2: S3Client,
userId: string,
bucketInfo: CloudStorageCredentials,
): Promise<Response> {
const key = formData.get("key") as string;
const uploadId = formData.get("uploadId") as string;
const bucket = formData.get("bucket") as string;
const size = parseInt(formData.get("fileSize") as string);
const fileType = formData.get("fileType") as string;
// const fileName = formData.get("fileName") as string;
const parts = JSON.parse(formData.get("parts") as string);
try {
const params = {
Bucket: bucket,
Key: key,
UploadId: uploadId,
MultipartUpload: { Parts: parts },
};
const command = new CompleteMultipartUploadCommand({ ...params });
const response = await R2.send(command);
const extractKey = extractFileNameAndExtension(key);
await createUserFile({
userId,
name: extractKey.fileName,
originalName: extractKey.nameWithoutExtension,
mimeType: fileType,
path: key,
etag: "",
storageClass: "",
channel: bucketInfo.channel || "",
platform: bucketInfo.platform || "",
providerName: bucketInfo.provider_name || "",
size,
bucket,
lastModified: new Date(),
});
return new Response(JSON.stringify(response), { status: 200 });
} catch (err) {
console.log("Error", err);
return new Response(JSON.stringify({ error: "Internal Server Error" }), {
status: 500,
});
}
}
// Aborts a multipart upload
async function abortMultipartUpload(
formData: FormData,
R2: S3Client,
): Promise<Response> {
const key = formData.get("key") as string;
const bucket = formData.get("bucket") as string;
const uploadId = formData.get("uploadId") as string;
try {
const params = {
Bucket: bucket,
Key: key,
UploadId: uploadId,
};
const command = new AbortMultipartUploadCommand({ ...params });
const response = await R2.send(command);
return new Response(JSON.stringify(response), { status: 200 });
} catch (err) {
console.log("Error", err);
return new Response(JSON.stringify({ error: "Internal Server Error" }), {
status: 500,
});
}
}
// Uploads a part of a file
async function uploadPart(formData: FormData, R2: S3Client): Promise<Response> {
const key = formData.get("key") as string;
const bucket = formData.get("bucket") as string;
const uploadId = formData.get("uploadId") as string;
const partNumber = Number(formData.get("partNumber")) as number;
const chunk = formData.get("chunk") as File;
try {
const arrayBuffer = await chunk.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const params = {
Bucket: bucket,
Key: key,
PartNumber: partNumber,
UploadId: uploadId,
Body: buffer,
};
const command = new UploadPartCommand({ ...params });
const response = await R2.send(command);
return new Response(JSON.stringify({ etag: response.ETag }), {
status: 200,
});
} catch (err) {
console.log("Error From Uploadpart => ", err);
return new Response(JSON.stringify({ error: "Internal Server Error" }), {
status: 500,
});
}
}
+1 -2
View File
@@ -10,11 +10,10 @@ import { ViewTransitions } from "next-view-transitions";
import { cn, constructMetadata } from "@/lib/utils";
import { Toaster } from "@/components/ui/sonner";
import ModalProvider from "@/components/modals/providers";
import GoogleAnalytics from "@/components/shared/GoogleAnalytics";
import UmamiAnalytics from "@/components/shared/UmamiAnalytics";
import { TailwindIndicator } from "@/components/tailwind-indicator";
import GoogleAnalytics from "../components/shared/GoogleAnalytics";
interface RootLayoutProps {
children: React.ReactNode;
}
+1 -1
View File
@@ -3,7 +3,7 @@
"short_name": "WR.DO",
"description": "Shorten links with analytics, manage emails and control subdomains—all on one platform.",
"appid": "com.wr.do",
"versionName": "1.0.8",
"versionName": "1.1.0",
"versionCode": "1",
"start_url": "/",
"orientation": "portrait",
+4 -2
View File
@@ -1,10 +1,12 @@
import { MetadataRoute } from "next"
import { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: "/admin/*",
},
}
sitemap: process.env.NEXT_PUBLIC_APP_URL + "/sitemap.xml",
};
}
+76
View File
@@ -0,0 +1,76 @@
import { MetadataRoute } from "next";
import { allDocs, allPages } from "contentlayer/generated";
async function getDocumentSlugs() {
return allDocs.map((doc) => ({
slug: doc.slugAsParams,
}));
}
async function getStaticPageSlugs() {
return allPages.map((page) => ({
slug: page.slugAsParams,
}));
}
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "https://wr.do";
const currentDate = new Date();
// static
const staticPages: MetadataRoute.Sitemap = [
{
url: baseUrl,
lastModified: currentDate,
changeFrequency: "daily",
priority: 1.0,
},
{
url: `${baseUrl}/login`,
lastModified: currentDate,
changeFrequency: "monthly",
priority: 0.8,
},
{
url: `${baseUrl}/feedback`,
lastModified: currentDate,
changeFrequency: "monthly",
priority: 0.8,
},
];
// (docs)/[slug]
const documentSlugs = await getDocumentSlugs();
const documentPages: MetadataRoute.Sitemap = documentSlugs.map((slug) => ({
url: `${baseUrl}/docs/${slug.slug}`,
lastModified: currentDate,
changeFrequency: "weekly" as const,
priority: 0.7,
}));
// (marketing)/[slug]
const marketingPageSlugs = await getStaticPageSlugs();
const marketingPages: MetadataRoute.Sitemap = marketingPageSlugs.map(
(slug) => ({
url: `${baseUrl}/${slug.slug}`,
lastModified: currentDate,
changeFrequency: "weekly" as const,
priority: 0.7,
}),
);
const protectedPages: MetadataRoute.Sitemap = [
{
url: `${baseUrl}/dashboard`,
lastModified: currentDate,
changeFrequency: "daily",
priority: 0.7,
},
];
return [
...staticPages,
...documentPages,
...marketingPages,
...protectedPages,
];
}
+1 -1
View File
@@ -94,7 +94,7 @@ export function SendEmailModal({
</Button>
)}
<Drawer open={isOpen} onOpenChange={setIsOpen}>
<Drawer open={isOpen} direction="right" onOpenChange={setIsOpen}>
<DrawerContent className="fixed bottom-0 right-0 top-0 w-full rounded-none sm:max-w-xl">
<DrawerHeader>
<DrawerTitle className="flex items-center gap-1">
+59
View File
@@ -0,0 +1,59 @@
"use client";
import React, { Dispatch, SetStateAction, useCallback } from "react";
import { useTranslations } from "next-intl";
import { useDropzone } from "react-dropzone";
import { BucketInfo } from "@/components/file";
import { Icons } from "../shared/icons";
import { Button } from "../ui/button";
const DragAndDrop = ({
setSelectedFile,
bucketInfo,
}: {
setSelectedFile: Dispatch<SetStateAction<File[] | null>>;
bucketInfo: BucketInfo;
}) => {
const t = useTranslations("Components");
const onDrop = useCallback(
(acceptedFiles: File[]) => {
setSelectedFile((prev) => [...(prev ?? []), ...acceptedFiles]);
},
[setSelectedFile],
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop });
return (
<div
{...getRootProps()}
className={`grids flex h-52 w-full cursor-pointer items-center justify-center rounded-lg border-2 border-dashed p-4 duration-150 ${
isDragActive
? "border-opacity-90 bg-muted/80 backdrop-blur-[2px]"
: "border-opacity-50 bg-muted/10 backdrop-blur-[1px]"
}`}
>
<input {...getInputProps()} />
<div className="text-center">
<div className="mx-auto w-fit transition-all duration-300">
<Icons.cloudUpload className="size-20" />
</div>
{isDragActive ? (
<div className="animate-fade-in text-primary">
{t("Drop files to upload them to")} {bucketInfo.bucket}
</div>
) : (
<div className="animate-fade-out">
<p>{t("Drag and drop file(s) here")}</p>
<p className="my-2 text-sm text-muted-foreground">{t("or")}</p>
<Button size="sm">{t("Browse file(s)")}</Button>
</div>
)}
</div>
</div>
);
};
export default DragAndDrop;
+807
View File
@@ -0,0 +1,807 @@
"use client";
import React, { useEffect, useState } from "react";
import Link from "next/link";
import { User } from "@prisma/client";
import {
Archive,
Download,
FileCode,
FileSpreadsheet,
FileText,
FileType2,
Folder,
ImageOff,
Trash2,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { UserFileData } from "@/lib/dto/files";
import {
cn,
downloadFileFromUrl,
formatDate,
formatFileSize,
truncateMiddle,
} from "@/lib/utils";
import { useMediaQuery } from "@/hooks/use-media-query";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { BucketInfo, DisplayType, FileListData } from "@/components/file";
import { UrlForm } from "../forms/url-form";
import { CopyButton } from "../shared/copy-button";
import { EmptyPlaceholder } from "../shared/empty-placeholder";
import { Icons } from "../shared/icons";
import { PaginationWrapper } from "../shared/pagination";
import QRCodeEditor from "../shared/qr";
import { TimeAgoIntl } from "../shared/time-ago";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
import { Checkbox } from "../ui/checkbox";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { Modal } from "../ui/modal";
import { Skeleton } from "../ui/skeleton";
import { Switch } from "../ui/switch";
import { TableCell, TableRow } from "../ui/table";
interface Props {
user: Pick<User, "id" | "name" | "apiKey" | "email" | "role" | "team">;
files?: FileListData;
isLoading: boolean;
bucketInfo: BucketInfo;
action: string;
view: DisplayType;
showMutiCheckBox: boolean;
currentPage: number;
pageSize: number;
setCurrentPage: (page: number) => void;
setPageSize: (size: number) => void;
selectedFiles: UserFileData[];
setSelectedFiles: (files: UserFileData[]) => void;
onRefresh: () => void;
onSelectAll: () => void;
onDeleteAll: () => void;
}
export default function UserFileList({
user,
files,
isLoading,
bucketInfo,
action,
view,
showMutiCheckBox,
currentPage,
pageSize,
setCurrentPage,
setPageSize,
selectedFiles,
setSelectedFiles,
onRefresh,
onSelectAll,
}: Props) {
const t = useTranslations("List");
const { isMobile } = useMediaQuery();
const [isShowForm, setShowForm] = useState(false);
const [shortTarget, setShortTarget] = useState<UserFileData | null>(null);
const [shortLinks, setShortLinks] = useState<string[]>([]);
const [isShowQrcode, setShowQrcode] = useState(false);
const [currentSelectFile, setCurrentSelectFile] =
useState<UserFileData | null>();
const isAdmin = action.includes("/admin");
const getFileUrl = (key: string) => {
return `${bucketInfo.custom_domain}/${key}`;
};
const handleSelectFile = (file: UserFileData) => {
if (selectedFiles.includes(file)) {
setSelectedFiles(selectedFiles.filter((f) => f.id !== file.id));
} else {
setSelectedFiles([...selectedFiles, file]);
}
};
const handleDownload = async (file: UserFileData) => {
downloadFileFromUrl(getFileUrl(file.path), file.name);
};
const handlePreviewRawFile = async (key: string) => {
try {
const response = await fetch(`${action}/r2/files`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, bucket: bucketInfo.bucket }),
});
const { signedUrl } = await response.json();
window.open(signedUrl, "_blank");
} catch (error) {
console.error("Error downloading file:", error);
alert("Error downloading file");
}
};
const handleDeleteSingle = async (file: UserFileData) => {
if (!confirm("Are you sure you want to delete this file?")) return;
try {
toast.promise(
fetch(`${action}/r2/files`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
keys: [file.path],
ids: [file.id],
bucket: bucketInfo.bucket,
}),
}),
{
loading: "Deleting file...",
success: "File deleted successfully!",
error: "Error deleting file",
finally: onRefresh,
},
);
} catch (error) {
console.error("Error deleting file:", error);
toast.success("Error deleting file");
}
};
const handleGenerateShortLink = async (urlId: string) => {
if (!shortTarget) return;
try {
const response = await fetch(`${action}/r2/short`, {
method: "PUT",
body: JSON.stringify({ urlId, fileId: shortTarget?.id }),
});
if (!response.ok || response.status !== 200) {
toast.error("Error generating short link");
} else {
onRefresh();
// handleGetFileShortLinkByIds();
}
} catch (error) {
console.error("Error generating short link:", error);
toast.error("Error generating short link");
}
};
const handleGetFileShortLinkByIds = async () => {
if (!files || !files.list) return;
try {
const ids = files.list.map((f) => f.shortUrlId || "");
if (!ids?.some((id) => id !== "")) return;
const response = await fetch(`${action}/r2/short`, {
method: "POST",
body: JSON.stringify({ ids }),
});
if (!response.ok || response.status !== 200) {
} else {
const data = await response.json();
setShortLinks(data.urls);
}
} catch (error) {
console.error("Error get short link:", error);
}
};
useEffect(() => {
handleGetFileShortLinkByIds();
}, [files]);
if (files && files.total === 0) {
return (
<EmptyPlaceholder className="col-span-full shadow-none">
<EmptyPlaceholder.Icon name="fileText" />
<EmptyPlaceholder.Title>{t("No Files")}</EmptyPlaceholder.Title>
<EmptyPlaceholder.Description>
{t("You don't upload any files yet")}
</EmptyPlaceholder.Description>
</EmptyPlaceholder>
);
}
const renderFileLinks = (file: UserFileData, index: number) => (
<>
{!isAdmin && file.shortUrlId && (
<div className="flex items-center gap-2">
<Icons.unLink className="size-3 flex-shrink-0 text-blue-500" />
<Link
href={"https://" + shortLinks[index]}
className="line-clamp-1 truncate rounded-md bg-neutral-100 p-1.5 text-xs hover:text-blue-500 dark:bg-neutral-800"
target="_blank"
>
https://{shortLinks[index]}
</Link>
<CopyButton
className="size-6"
value={`https://${shortLinks[index]}`}
/>
</div>
)}
<div className="flex items-center gap-2">
<Icons.link className="size-3 flex-shrink-0" />
<Link
href={getFileUrl(file.path)}
className="line-clamp-1 truncate rounded-md bg-neutral-100 p-1.5 text-xs hover:text-blue-500 dark:bg-neutral-800"
target="_blank"
>
{getFileUrl(file.path)}
</Link>
<CopyButton className="size-6" value={getFileUrl(file.path)} />
</div>
<div className="flex items-center gap-2">
<Icons.code 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">
{`<img src="${getFileUrl(file.path)}" alt="${file.name}">${getFileUrl(file.path)}</img>`}
</p>
<CopyButton
className="size-6"
value={`<img src="${getFileUrl(file.path)}" alt="${file.name}">${getFileUrl(file.path)}</img>`}
/>
</div>
<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)})`}
/>
</div>
</>
);
const renderListView = () => (
<div className="overflow-hidden rounded-lg border bg-primary-foreground">
<div className="text-mute-foreground grid grid-cols-6 gap-4 bg-neutral-100 px-6 py-3 text-sm font-medium dark:bg-neutral-800 sm:grid-cols-10">
{showMutiCheckBox && (
<div className="col-span-1 flex">
<Checkbox
className="mr-3 size-4 border-neutral-300 bg-neutral-100 data-[state=checked]:border-neutral-900 data-[state=checked]:bg-neutral-600 data-[state=checked]:text-neutral-100 dark:border-neutral-700 dark:bg-neutral-800 dark:data-[state=checked]:border-neutral-300 dark:data-[state=checked]:bg-neutral-300"
checked={selectedFiles.length === files?.list.length}
onCheckedChange={() => onSelectAll()}
/>
</div>
)}
<div className={cn(showMutiCheckBox ? "col-span-2" : "col-span-3")}>
{t("Name")}
</div>
<div className="col-span-2 hidden sm:flex">{t("Type")}</div>
<div className="col-span-1">{t("Size")}</div>
<div className="col-span-1 hidden sm:flex">{t("User")}</div>
<div className="col-span-1 hidden sm:flex">{t("Date")}</div>
<div className="col-span-1 hidden sm:flex">{t("Active")}</div>
<div className="col-span-1">{t("Actions")}</div>
</div>
{isLoading ? (
<>
<TableColumnSekleton />
<TableColumnSekleton />
<TableColumnSekleton />
<TableColumnSekleton />
<TableColumnSekleton />
</>
) : (
<div className="divide-y divide-neutral-200 dark:divide-neutral-600">
{files?.list.map((file, index) => (
<div
key={file.id}
className="text-mute-foreground grid grid-cols-6 gap-4 px-6 py-4 transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-600 sm:grid-cols-10"
>
{showMutiCheckBox && (
<div
className="col-span-1 flex items-center gap-2"
onClick={(e) => e.stopPropagation()}
>
<Checkbox
checked={
selectedFiles.find((f) => f.id === file.id) !== undefined
}
onCheckedChange={() => handleSelectFile(file)}
className="mr-3 size-4 border-neutral-300 bg-neutral-100 data-[state=checked]:border-neutral-900 data-[state=checked]:bg-neutral-600 data-[state=checked]:text-neutral-100 dark:border-neutral-700 dark:bg-neutral-800 dark:data-[state=checked]:border-neutral-300 dark:data-[state=checked]:bg-neutral-300"
/>
</div>
)}
<div
className={cn(
"items-center space-x-3 text-sm",
showMutiCheckBox ? "col-span-2" : "col-span-3",
)}
>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger
className={cn(
"flex items-center justify-start gap-1 break-all text-start",
file.status !== 1 && "text-muted-foreground",
)}
>
{truncateMiddle(file.path)}
{file.status === 1 && (
<CopyButton
className="size-6"
value={getFileUrl(file.path)}
/>
)}
</TooltipTrigger>
<TooltipContent
side="right"
className="w-72 space-y-1 text-wrap p-3 text-start"
>
{file.mimeType.startsWith("image/") &&
file.status === 1 && (
<img
className="max-h-[70vh] w-72 rounded shadow"
width={300}
height={300}
src={getFileUrl(file.path)}
alt={`${file.path}`}
/>
)}
{renderFileLinks(file, index)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="col-span-2 hidden items-center text-xs sm:flex">
<Badge className="truncate" variant="outline">
{file.mimeType || "-"}
</Badge>
</div>
<div className="col-span-1 flex items-center text-nowrap text-xs">
{formatFileSize(file.size || 0)}
</div>
<div className="col-span-1 hidden items-center text-xs sm:flex">
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger className="truncate">
{file.user.name ?? file.user.email}
</TooltipTrigger>
<TooltipContent>
<p>{file.user.name}</p>
<p>{file.user.email}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="col-span-1 hidden items-center text-nowrap text-xs sm:flex">
<TimeAgoIntl date={file.updatedAt as Date} />
</div>
<div className="col-span-1 hidden items-center text-xs sm:flex">
<Switch checked={file.status === 1} disabled />
</div>
<div className="col-span-1 flex items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="size-[25px] p-1.5"
size="sm"
variant="ghost"
>
<Icons.moreVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem asChild>
<Button
className="flex w-full items-center gap-2"
size="sm"
variant="ghost"
onClick={() => {
setCurrentSelectFile(file);
setShowQrcode(!isShowQrcode);
}}
>
<Icons.qrcode className="size-4" />
{t("QR Code")}
</Button>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Button
className="flex w-full items-center gap-2"
size="sm"
variant="ghost"
onClick={() => {
setShortTarget(file);
setShowForm(true);
}}
>
<Icons.link className="size-4" />
{file.shortUrlId
? t("Update short link")
: t("Generate short link")}
</Button>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Button
className="flex w-full items-center gap-2"
size="sm"
variant="ghost"
onClick={() => handlePreviewRawFile(file.path)}
>
<Icons.eye className="size-4" />
{t("Raw Data")}
</Button>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Button
className="flex w-full items-center gap-2"
size="sm"
variant="ghost"
onClick={() => handleDownload(file)}
>
<Icons.download className="size-4" />
{t("Download")}
</Button>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Button
className="flex w-full items-center gap-2 text-red-500"
size="sm"
variant="ghost"
onClick={() => file.path && handleDeleteSingle(file)}
>
<Icons.trash className="size-4" />
{t("Delete File")}
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
</div>
)}
</div>
);
const renderGridView = () => (
<div
className="grid justify-items-center gap-4"
style={{
gridTemplateColumns: "repeat(auto-fill, minmax(10px, 100px))",
}}
>
{isLoading &&
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => (
<Skeleton key={v} className="size-[100px]" />
))}
{files?.list.map((file, index) => (
<div
key={file.id}
className={cn(
"group relative flex cursor-pointer items-end rounded-md transition-all hover:bg-blue-50",
selectedFiles.find((f) => f.id === file.id) !== undefined &&
"bg-blue-50",
)}
onClick={() => handleSelectFile(file)}
>
<div className="flex flex-col items-center justify-center space-y-1 py-1">
{showMutiCheckBox && (
<Checkbox
checked={
selectedFiles.find((f) => f.id === file.id) !== undefined
}
// onCheckedChange={() => handleSelectFile(file)}
className="absolute left-1 top-1 size-4 border-neutral-300 bg-neutral-100 data-[state=checked]:border-neutral-900 data-[state=checked]:bg-neutral-600 data-[state=checked]:text-neutral-100 dark:border-neutral-700 dark:bg-neutral-800 dark:data-[state=checked]:border-neutral-300 dark:data-[state=checked]:bg-neutral-300"
/>
)}
{React.cloneElement(getFileIcon(file, bucketInfo), { size: 40 })}
<div className="w-full text-center">
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger 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]">
{truncateMiddle(file.path || "")}
</TooltipTrigger>
<TooltipContent
side="right"
className="max-w-[300px] space-y-1 p-3 text-start"
>
{file.mimeType.startsWith("image/") &&
file.status === 1 && (
<img
className="mb-2 max-h-[70vh] w-fit rounded shadow"
width={300}
height={300}
src={getFileUrl(file.path)}
alt={`${file.path}`}
/>
)}
<p className="mt-1 text-sm font-semibold text-muted-foreground">
{file.path}
</p>
<p className="mt-1 text-xs text-muted-foreground">
<strong>Size:</strong> {formatFileSize(file.size || 0)}
</p>
<p className="mt-1 text-xs text-muted-foreground">
<strong>Type:</strong> {file.mimeType || "-"}
</p>
<p className="mt-1 text-xs text-muted-foreground">
<strong>User:</strong> {file.user.name || file.user.email}
</p>
<p className="mt-1 text-xs text-muted-foreground">
<strong>Modified:</strong>{" "}
{formatDate(file.lastModified?.toString() || "")}
</p>
{renderFileLinks(file, index)}
<div className="flex items-center justify-end space-x-1 pt-2">
<Button
className="flex h-7 w-full items-center gap-2 text-xs"
size="sm"
variant="outline"
onClick={() => handlePreviewRawFile(file.path)}
disabled={file.status !== 1}
>
<Icons.eye className="size-4" />
{t("Raw Data")}
</Button>
<Button
className="h-7 px-1.5 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
size="sm"
variant={"outline"}
disabled={file.status !== 1}
onClick={() => {
setCurrentSelectFile(file);
setShowQrcode(!isShowQrcode);
}}
>
<Icons.qrcode className="size-4" />
</Button>
<Button
onClick={() => handlePreviewRawFile(file.path)}
className="h-7 px-1.5"
title="下载"
size="sm"
variant={"blue"}
disabled={file.status !== 1}
>
<Download className="size-4" />
</Button>
{file.status === 1 && (
<Button
onClick={() => handleDeleteSingle(file)}
className="h-7 px-1.5"
title="删除"
size="sm"
variant={"destructive"}
disabled={file.status !== 1}
>
<Trash2 className="size-4" />
</Button>
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</div>
))}
</div>
);
return (
<>
{view === "List" ? renderListView() : renderGridView()}
{files && Math.ceil(files.total / pageSize) > 1 && (
<PaginationWrapper
layout={isMobile ? "right" : "split"}
total={files.total}
currentPage={currentPage}
setCurrentPage={setCurrentPage}
pageSize={pageSize}
setPageSize={setPageSize}
/>
)}
<Modal
className="md:max-w-2xl"
showModal={isShowForm}
setShowModal={setShowForm}
>
<UrlForm
user={{ id: "", name: "" }}
isShowForm={isShowForm}
setShowForm={setShowForm}
type="add"
initData={{
target: getFileUrl(shortTarget?.path || ""),
userId: "",
userName: "",
url: "",
prefix: "",
visible: 1,
active: 1,
expiration: "-1",
password: "",
}}
action="/api/url"
onRefresh={handleGenerateShortLink}
/>
</Modal>
<Modal
className="md:max-w-lg"
showModal={isShowQrcode}
setShowModal={setShowQrcode}
>
{currentSelectFile && (
<QRCodeEditor
user={{
id: user.id,
apiKey: user.apiKey || "",
team: user.team || "free",
}}
url={getFileUrl(currentSelectFile.path)}
/>
)}
</Modal>
</>
);
}
function TableColumnSekleton() {
return (
<TableRow className="grid grid-cols-3 items-center sm:grid-cols-12">
<TableCell className="col-span-1 hidden sm:flex">
<Skeleton className="h-5 w-10" />
</TableCell>
<TableCell className="col-span-3 sm:col-span-2">
<Skeleton className="h-5 w-20" />
</TableCell>
<TableCell className="col-span-1 flex">
<Skeleton className="h-5 w-16" />
</TableCell>
<TableCell className="col-span-2 hidden sm:flex">
<Skeleton className="h-5 w-16" />
</TableCell>
<TableCell className="col-span-2 hidden sm:flex">
<Skeleton className="h-5 w-16" />
</TableCell>
<TableCell className="col-span-2 hidden sm:flex">
<Skeleton className="h-5 w-16" />
</TableCell>
<TableCell className="col-span-1 flex">
<Skeleton className="h-5 w-16" />
</TableCell>
</TableRow>
);
}
const getFileIcon = (file: UserFileData, bucketInfo: BucketInfo) => {
const filename = file.path;
const mimeType = file.mimeType;
const status = file.status;
const iconProps = { size: 24, className: "text-gray-600" };
// 如果没有 mimeType,回退到文件夹判断
if (!mimeType) {
if (filename.endsWith("/")) {
return <Folder {...iconProps} className="text-yellow-500" />;
}
return <FileText {...iconProps} className="text-gray-500" />;
}
if (mimeType.startsWith("image/")) {
if (mimeType === "image/svg+xml") {
return <FileCode {...iconProps} className="text-blue-500" />;
}
if (status === 1) {
return (
<img
className="max-h-12 w-fit max-w-24 rounded shadow"
height={60}
width={60}
src={
bucketInfo.custom_domain
? `${bucketInfo.custom_domain}/${filename}`
: filename
}
alt={filename}
/>
);
} else {
return <ImageOff {...iconProps} className="text-muted-foreground" />;
}
}
// 压缩文件
if (
mimeType === "application/zip" ||
mimeType === "application/x-rar-compressed" ||
mimeType === "application/x-7z-compressed" ||
mimeType === "application/x-tar" ||
mimeType === "application/gzip" ||
mimeType === "application/x-gzip"
) {
return <Archive {...iconProps} className="text-orange-500" />;
}
// Microsoft Office 文档
if (
mimeType ===
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" ||
mimeType === "application/msword"
) {
return <FileText {...iconProps} className="text-blue-600" />;
}
// Microsoft Office 演示文稿
if (
mimeType ===
"application/vnd.openxmlformats-officedocument.presentationml.presentation" ||
mimeType === "application/vnd.ms-powerpoint"
) {
return <FileText {...iconProps} className="text-red-500" />;
}
// Microsoft Office 电子表格
if (
mimeType ===
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ||
mimeType === "application/vnd.ms-excel" ||
mimeType === "text/csv"
) {
return <FileSpreadsheet {...iconProps} className="text-green-600" />;
}
// JSON 文件
if (mimeType === "application/json") {
return <FileCode {...iconProps} className="text-yellow-600" />;
}
// Markdown 文件
if (mimeType === "text/markdown" || mimeType === "text/x-markdown") {
return <FileType2 {...iconProps} className="text-gray-700" />;
}
// 代码文件
if (
mimeType.startsWith("text/") ||
mimeType === "application/javascript" ||
mimeType === "application/typescript" ||
mimeType === "application/x-javascript" ||
mimeType === "text/javascript" ||
mimeType === "text/typescript"
) {
return <FileCode {...iconProps} className="text-blue-400" />;
}
// PDF 文件
if (mimeType === "application/pdf") {
return <FileText {...iconProps} className="text-red-600" />;
}
// 音频文件
if (mimeType.startsWith("audio/")) {
return <FileText {...iconProps} className="text-purple-500" />;
}
// 视频文件
if (mimeType.startsWith("video/")) {
return <FileText {...iconProps} className="text-pink-500" />;
}
// 默认文件图标
return <FileText {...iconProps} className="text-gray-500" />;
};
+364
View File
@@ -0,0 +1,364 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { User } from "@prisma/client";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import useSWR, { useSWRConfig } from "swr";
import { UserFileData } from "@/lib/dto/files";
import { BucketItem, ClientStorageCredentials } from "@/lib/r2";
import { cn, fetcher } from "@/lib/utils";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
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 UserFileList from "@/components/file/file-list";
import Uploader from "@/components/file/uploader";
import { Icons } from "@/components/shared/icons";
import { EmptyPlaceholder } from "../shared/empty-placeholder";
import { Button } from "../ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { CircularStorageIndicator, FileSizeDisplay } from "./storage-size";
export interface FileListProps {
user: Pick<User, "id" | "name" | "apiKey" | "email" | "role" | "team">;
action: string;
}
export interface BucketInfo extends BucketItem {
platform?: string;
channel?: string;
provider_name?: string;
}
export type DisplayType = "List" | "Grid";
export interface FileListData {
total: number;
totalSize: number;
list: UserFileData[];
}
export interface StorageUserPlan {
stMaxTotalSize: string;
stMaxFileSize: string;
}
export default function UserFileManager({ user, action }: FileListProps) {
const t = useTranslations("List");
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [displayType, setDisplayType] = useState<DisplayType>("List");
const [showMutiCheckBox, setShowMutiCheckBox] = useState(false);
const [bucketInfo, setBucketInfo] = useState<BucketInfo>({
bucket: "",
custom_domain: "",
prefix: "",
platform: "",
channel: "",
provider_name: "",
public: true,
});
const [selectedFiles, setSelectedFiles] = useState<UserFileData[]>([]);
const [isDeleting, startDeleteTransition] = useTransition();
const isAdmin = action.includes("/admin");
const { mutate } = useSWRConfig();
const { data: r2Configs, isLoading } = useSWR<ClientStorageCredentials>(
`${action}/r2/configs`,
fetcher,
{ revalidateOnFocus: false },
);
const { data: files, isLoading: isLoadingFiles } = useSWR<FileListData>(
bucketInfo.bucket
? `${action}/r2/files?bucket=${bucketInfo.bucket}&page=${currentPage}&size=${pageSize}`
: null,
fetcher,
{
revalidateOnFocus: false,
dedupingInterval: 5000, // 防抖
},
);
const { data: plan } = useSWR<StorageUserPlan>(
`/api/plan?team=${user.team}`,
fetcher,
);
useEffect(() => {
if (r2Configs && r2Configs.buckets && r2Configs.buckets.length > 0) {
setBucketInfo({
...r2Configs.buckets[0],
platform: r2Configs.platform,
channel: r2Configs.channel,
provider_name: r2Configs.provider_name,
});
}
}, [r2Configs]);
const handleRefresh = () => {
mutate(
`${action}/r2/files?bucket=${bucketInfo.bucket}&page=${currentPage}&size=${pageSize}`,
undefined,
);
};
const handleChangeBucket = (bucket: string) => {
const newBucketInfo = r2Configs?.buckets?.find(
(item) => item.bucket === bucket,
);
setBucketInfo({
...bucketInfo,
...newBucketInfo,
});
};
const handleSelectAllFiles = () => {
if (selectedFiles.length === files?.list.length) {
setSelectedFiles([]);
} else {
setSelectedFiles(files?.list || []);
}
};
const handleDeleteAllFiles = () => {
startDeleteTransition(async () => {
try {
toast.promise(
fetch(`${action}/r2/files`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
keys: selectedFiles.map((file) => file.path),
ids: selectedFiles.map((file) => file.id),
bucket: bucketInfo.bucket,
}),
}),
{
loading: "Deleting files...",
success: "Files deleted successfully!",
error: "Error deleting files",
finally: handleRefresh,
},
);
} catch (error) {
console.error("Error deleting files:", error);
toast.success("Error deleting files");
}
});
};
return (
<div>
<Tabs value={displayType}>
<div className="mb-4 flex items-center justify-between gap-2">
<TabsList className="mr-auto">
<TabsTrigger value="List" onClick={() => setDisplayType("List")}>
<Icons.list className="size-4" />
</TabsTrigger>
<TabsTrigger value="Grid" onClick={() => setDisplayType("Grid")}>
<Icons.layoutGrid className="size-4" />
</TabsTrigger>
</TabsList>
{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">
<FileSizeDisplay files={files} plan={plan} t={t} />
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{isLoading ? (
<Skeleton className="h-9 w-[120px] rounded border-r-0 shadow-inner" />
) : (
<Select
value={bucketInfo.bucket}
onValueChange={handleChangeBucket}
>
<SelectTrigger className="w-[120px]">
<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 /> */}
</SelectContent>
</Select>
)}
{!isAdmin && (
<Uploader
bucketInfo={bucketInfo}
action={action}
onRefresh={handleRefresh}
plan={plan}
/>
)}
<div className="flex items-center">
<Button
className={cn(
"h-9 rounded-r-none border-r-0",
showMutiCheckBox
? "border-0 bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground"
: "",
)}
variant="outline"
size="icon"
onClick={() => setShowMutiCheckBox(!showMutiCheckBox)}
>
<Icons.listChecks className="size-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
"flex h-9 w-8 items-center justify-center gap-1 rounded-r-md border",
showMutiCheckBox
? "border-neutral-600 bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground"
: "",
)}
>
<Icons.chevronDown className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Button
variant="ghost"
size="sm"
onClick={handleSelectAllFiles}
className="w-full"
>
<span className="text-xs">{t("Select all")}</span>
</Button>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Button
variant="ghost"
size="sm"
className="w-full"
onClick={handleDeleteAllFiles}
disabled={isDeleting || selectedFiles.length === 0}
>
{isDeleting && (
<Icons.spinner className="mr-1 size-4 animate-spin" />
)}
<span className="text-xs">{t("Delete selected")}</span>
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Button
className="h-9"
size="icon"
variant="outline"
onClick={() => handleRefresh()}
disabled={isLoadingFiles}
>
{isLoadingFiles ? (
<Icons.refreshCw className="size-4 animate-spin" />
) : (
<Icons.refreshCw className="size-4" />
)}
</Button>
</div>
{isLoading && (
<div className="mt-8 flex flex-col items-center gap-3">
<div className="flex size-20 animate-pulse items-center justify-center rounded-full bg-muted">
<Icons.storage className="size-10" />
</div>
<div className="flex items-center justify-center gap-2 text-muted-foreground">
<Icons.spinner className="mr-1 size-4 animate-spin" />
{t("Loading storage buckets")}...
</div>
</div>
)}
{!isLoading && !r2Configs?.buckets?.length && (
<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 && r2Configs?.buckets && r2Configs.buckets.length > 0 && (
<UserFileList
user={user}
files={files}
isLoading={isLoadingFiles}
view={displayType}
bucketInfo={bucketInfo}
action={action}
showMutiCheckBox={showMutiCheckBox}
currentPage={currentPage}
pageSize={pageSize}
setCurrentPage={setCurrentPage}
setPageSize={setPageSize}
selectedFiles={selectedFiles}
setSelectedFiles={setSelectedFiles}
onRefresh={handleRefresh}
onSelectAll={handleSelectAllFiles}
onDeleteAll={handleDeleteAllFiles}
/>
)}
</Tabs>
</div>
);
}
+159
View File
@@ -0,0 +1,159 @@
import React from "react";
import { AlertTriangle, CheckCircle, HardDrive } from "lucide-react";
import { formatFileSize } from "@/lib/utils";
export function FileSizeDisplay({ files, plan, t }) {
const totalSize = files?.totalSize || 0;
const maxSize = Number(plan?.stMaxTotalSize || 0);
const usagePercentage =
maxSize > 0 ? Math.min((totalSize / maxSize) * 100, 100) : 0;
const getStatusColor = (percentage) => {
if (percentage >= 90) return "text-red-600";
if (percentage >= 70) return "text-yellow-600";
return "text-green-600";
};
const getProgressColor = (percentage) => {
if (percentage >= 90) return "bg-red-500";
if (percentage >= 70) return "bg-yellow-500";
return "bg-blue-500";
};
const getStatusIcon = (percentage) => {
if (percentage >= 90) return <AlertTriangle className="h-4 w-4" />;
if (percentage >= 70) return <AlertTriangle className="h-4 w-4" />;
return <CheckCircle className="h-4 w-4" />;
};
return (
<div className="mx-auto w-full max-w-md p-4">
{/* 标题 */}
<div className="mb-3 flex items-center gap-2">
<HardDrive className="h-5 w-5 text-neutral-600 dark:text-neutral-200" />
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
{t("storageUsage")}
</h3>
</div>
{/* 进度条 */}
<div className="mb-3">
<div className="mb-1 flex items-center justify-between">
<span className="text-xs text-neutral-500 dark:text-neutral-300">
{t("used")}
</span>
<span className="text-xs text-neutral-500 dark:text-neutral-300">
{usagePercentage.toFixed(1)}%
</span>
</div>
<div className="h-2 w-full rounded-full bg-neutral-200 dark:bg-neutral-600">
<div
className={`h-2 rounded-full transition-all duration-300 ${getProgressColor(usagePercentage)}`}
style={{ width: `${usagePercentage}%` }}
/>
</div>
</div>
{/* 详细信息 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-neutral-600 dark:text-neutral-300">
{t("usedSpace")}:
</span>
<span className="text-sm font-medium">
{formatFileSize(totalSize, { precision: 0 })}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-neutral-600 dark:text-neutral-300">
{t("totalCapacity")}:
</span>
<span className="text-sm font-medium">
{formatFileSize(maxSize, { precision: 0 })}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-neutral-600 dark:text-neutral-300">
{t("availableSpace")}:
</span>
<span className="text-sm font-medium">
{formatFileSize(maxSize - totalSize, { precision: 0 })}
</span>
</div>
</div>
{/* 状态提示 */}
<div
className={`mt-3 flex items-center gap-2 rounded-md bg-neutral-50 p-2 dark:bg-neutral-800 ${getStatusColor(usagePercentage)}`}
>
{getStatusIcon(usagePercentage)}
<span className="text-xs">
{usagePercentage >= 90
? t("storageFull")
: usagePercentage >= 70
? t("storageHigh")
: t("storageGood")}
</span>
</div>
</div>
);
}
export function CircularStorageIndicator({ files, plan, size = 32 }) {
const totalSize = files?.totalSize || 0;
const maxSize = Number(plan?.stMaxTotalSize || 0);
const usagePercentage =
maxSize > 0 ? Math.min((totalSize / maxSize) * 100, 100) : 0;
// 圆形参数
const radius = (size - 6) / 2;
const circumference = 2 * Math.PI * radius;
const strokeDashoffset =
circumference - (usagePercentage / 100) * circumference;
// 根据使用率确定颜色
const getColor = (percentage) => {
if (percentage >= 90) return "#ef4444"; // red-500
if (percentage >= 70) return "#f59e0b"; // amber-500
return "#3b82f6"; // blue-500
};
return (
<div
className="relative inline-block"
style={{ width: size, height: size }}
>
<svg width={size} height={size} className="-rotate-90 transform">
{/* 背景圆圈 */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="#e5e7eb"
strokeWidth="3"
fill="none"
/>
{/* 进度圆圈 */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke={getColor(usagePercentage)}
strokeWidth="3"
fill="none"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
className="transition-all duration-300 ease-out"
/>
</svg>
<div
className="absolute inset-0 flex scale-[.85] items-center justify-center text-xs font-medium"
style={{ color: getColor(usagePercentage) }}
>
{Math.round(usagePercentage)}%
</div>
</div>
);
}
+164
View File
@@ -0,0 +1,164 @@
"use client";
import { useTranslations } from "next-intl";
import { cn, formatFileSize } from "@/lib/utils";
import { BucketInfo } from "@/components/file";
import { CopyButton } from "../shared/copy-button";
import { Icons } from "../shared/icons";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
import { UploadPendingItemType, UploadProgressType } from "./uploader";
const UploadPending = ({
pendingUpload,
progressList,
bucketInfo,
onAbort,
}: {
pendingUpload: UploadPendingItemType[] | null;
progressList: UploadProgressType[] | undefined;
bucketInfo: BucketInfo;
onAbort: (uploadId: string, key: string) => void;
}) => {
const t = useTranslations("Components");
return (
<div className="space-y-2 rounded-lg">
{progressList && (
<div className="flex items-center justify-between gap-3">
<h2 className="font-semibold">{t("Upload List")}</h2>
<Badge className="flex items-center gap-1">
{progressList.filter((i) => i.progress === 100).length}
<span>/</span>
{progressList.length}
</Badge>
</div>
)}
{pendingUpload && (
<p className="rounded-md border border-dashed bg-yellow-100 p-2 text-sm text-muted-foreground dark:bg-neutral-600">
Do not close the window until the upload is complete
</p>
)}
{pendingUpload &&
pendingUpload.map((item) => {
const progress =
progressList?.find((p) => p.id === item.uploadId)?.progress || 0;
return (
<div
key={item.uploadId}
className={cn(
"relative overflow-hidden rounded-lg border",
item.status === "uploading" &&
"border-gray-300 bg-gray-50 dark:border-gray-600 dark:bg-gray-800",
item.status === "completed" &&
"border-green-300 bg-green-50 dark:border-green-600 dark:bg-green-900/20",
item.status === "aborted" &&
"border-red-300 bg-red-50 dark:border-red-600 dark:bg-red-900/20",
"backdrop-blur-sm transition-all duration-300",
)}
>
{/* 主进度条背景 */}
{item.status === "uploading" && (
<div className="absolute inset-0 overflow-hidden rounded-lg">
<div
className="h-full bg-gray-200 transition-all duration-500 ease-out dark:bg-gray-700"
style={{ width: `${progress}%` }}
/>
</div>
)}
{/* 内容区域 */}
<div className="relative z-10 px-4 py-3">
{/* 头部信息 */}
<div
className={cn(
"flex justify-between gap-3",
item.status === "uploading"
? "items-start"
: "items-center",
)}
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-gray-900 dark:text-white">
{item.fileName}
</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{formatFileSize(item.size)}
</p>
</div>
{/* 状态指示器 */}
<div className="flex items-center gap-2">
{item.status === "uploading" ? (
<div className="flex items-center gap-2">
<span className="text-sm">{progress}%</span>
<div className="flex items-center gap-1 rounded-full bg-gray-700 px-3 py-1 text-xs text-white dark:bg-gray-600">
<div className="h-2 w-2 animate-pulse rounded-full bg-gray-300 dark:bg-gray-400"></div>
{t("Uploading")}
</div>
<Button
className="size-6"
size={"icon"}
variant="destructive"
onClick={() => onAbort(item.uploadId, item.key)}
>
<Icons.close className="size-4" />
</Button>
</div>
) : item.status === "completed" ? (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 rounded-full bg-green-600 px-3 py-1 text-xs text-white dark:bg-green-700">
<div className="h-2 w-2 rounded-full bg-green-300 dark:bg-green-400"></div>
{t("Completed")}
</div>
<CopyButton
value={
bucketInfo.custom_domain
? `${bucketInfo.custom_domain}/${item.fileName}`
: item.path!
}
></CopyButton>
</div>
) : (
item.status === "aborted" && (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 rounded-full bg-red-600 px-3 py-1 text-xs text-white dark:bg-red-700">
<div className="h-2 w-2 rounded-full bg-red-300 dark:bg-red-400"></div>
{t("Aborted")}
</div>
</div>
)
)}
</div>
</div>
{/* 进度条 */}
{item.status === "uploading" && (
<div className="mt-3">
<div className="relative h-2 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
{/* 进度填充 */}
<div
className="absolute left-0 top-0 h-full bg-gray-800 transition-all duration-300 ease-out dark:bg-gray-300"
style={{ width: `${progress}%` }}
/>
{/* 动态光泽效果 */}
<div
className="absolute top-0 h-full w-16 bg-gradient-to-r from-transparent via-white/30 to-transparent transition-all duration-1000 ease-out dark:via-white/20"
style={{
left: `${Math.max(0, progress - 8)}%`,
opacity: progress > 0 && progress < 100 ? 1 : 0,
}}
/>
</div>
</div>
)}
</div>
</div>
);
})}
</div>
);
};
export default UploadPending;
+326
View File
@@ -0,0 +1,326 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { formatFileSize } from "@/lib/utils";
import { BucketInfo, StorageUserPlan } from "@/components/file";
import { Icons } from "../shared/icons";
import { Button } from "../ui/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from "../ui/drawer";
import DragAndDrop from "./drag-and-drop";
import UploadPending from "./upload-pending";
export type UploadPendingItemType = {
uploadId: string;
fileName: string;
size: number;
status: "uploading" | "completed" | "aborted";
key: string;
path?: string;
};
export type UploadProgressType = {
id: string;
progress: number;
};
export default function Uploader({
bucketInfo,
action,
plan,
onRefresh,
}: {
bucketInfo: BucketInfo;
action: string;
plan?: StorageUserPlan;
onRefresh: () => void;
}) {
const t = useTranslations("Components");
const [isOpen, setIsOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<File[] | null>(null);
const [pendingUpload, setPendingUpload] = useState<
UploadPendingItemType[] | null
>(null);
const [progressList, setProgressList] = useState<UploadProgressType[]>();
// Handles the upload process for selected files
const handleUpload = useCallback(() => {
selectedFile?.forEach((file: File) => {
uploadFile(file);
setSelectedFile((prev) => (prev ? prev.filter((f) => f !== file) : null));
});
}, [selectedFile]);
// Starts the multipart upload process
const startUpload = async (
file: File,
): Promise<{ uploadId: string; key: string }> => {
const formData = new FormData();
formData.append("fileName", file.name);
formData.append("fileType", file.type);
formData.append("fileSize", file.size.toString());
formData.append("bucket", bucketInfo.bucket);
formData.append("prefix", bucketInfo.prefix || "");
formData.append("endPoint", "create-multipart-upload");
const response = await fetch(`${action}/r2/uploads`, {
method: "POST",
body: formData,
});
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
return data; // { uploadId, key }
};
// Uploads file parts in chunks
const uploadParts = async (
file: File,
uploadId: string,
key: string,
onProgress: (progress: number) => void,
): Promise<{ ETag: string; PartNumber: number }[]> => {
const chunkSize = 5 * 1024 * 1024; // 5MB for each chunk
const totalChunks = Math.ceil(file.size / chunkSize);
const parts: { ETag: string; PartNumber: number }[] = [];
for (let i = 0; i < totalChunks; i++) {
const chunk = file.slice(i * chunkSize, (i + 1) * chunkSize);
const partNumber = i + 1;
const formData = new FormData();
formData.append("chunk", chunk);
formData.append("uploadId", uploadId);
formData.append("key", key);
formData.append("bucket", bucketInfo.bucket);
formData.append("partNumber", partNumber.toString());
formData.append("endPoint", "upload-part");
const uploadResponse = await fetch(`${action}/r2/uploads`, {
method: "POST",
body: formData,
});
const responseData = await uploadResponse.json();
if (responseData.error) {
throw new Error(responseData.error);
}
if (responseData.etag) {
parts.push({ ETag: responseData.etag, PartNumber: partNumber });
}
const progress = Math.round((partNumber / totalChunks) * 100);
onProgress(progress);
}
return parts;
};
// Completes the multipart upload process
const completeUpload = async (
uploadId: string,
key: string,
parts: { ETag: string; PartNumber: number }[],
file: File,
): Promise<{ Location: string }> => {
const formData = new FormData();
formData.append("key", key);
formData.append("uploadId", uploadId);
formData.append("bucket", bucketInfo.bucket);
formData.append("parts", JSON.stringify(parts));
formData.append("fileSize", file.size.toString());
formData.append("fileType", file.type);
formData.append("fileName", file.name);
formData.append("endPoint", "complete-multipart-upload");
const response = await fetch(`${action}/r2/uploads`, {
method: "POST",
body: formData,
});
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
return data;
};
const abortUpload = async (uploadId: string, key: string) => {
const formData = new FormData();
formData.append("uploadId", uploadId);
formData.append("key", key);
formData.append("bucket", bucketInfo.bucket);
formData.append("endPoint", "abort-multipart-upload");
const response = await fetch(`${action}/r2/uploads`, {
method: "POST",
body: formData,
});
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
setPendingUpload(
(prev) =>
prev?.map((item) =>
item.uploadId === uploadId
? { ...item, status: "aborted", path: "" }
: item,
) ?? [],
);
return data;
};
const uploadFile = async (file: File): Promise<void> => {
try {
if (file.size > Number(plan?.stMaxFileSize || "26214400")) {
toast.warning("Upload Failed", {
description: `File '${file.name}' size exceeds the maximum allowed size of ${formatFileSize(Number(plan?.stMaxFileSize || "0"))} bytes.`,
});
return;
}
const { uploadId, key } = await startUpload(file);
setProgressList((prev) => [
...(prev ?? []),
{ id: uploadId, progress: 0 },
]);
setPendingUpload((prev) => [
...(prev ?? []),
{
uploadId,
fileName: file.name,
size: file.size,
path: "",
status: "uploading",
key,
},
]);
const parts = await uploadParts(file, uploadId, key, (progress) => {
// console.log(`Upload Progress: ${progress}%`);
setProgressList(
(prev) =>
prev?.map((item) =>
item.id === uploadId ? { ...item, progress } : item,
) ?? [],
);
});
const result = await completeUpload(uploadId, key, parts, file);
setProgressList(
(prev) =>
prev?.map((item) =>
item.id === uploadId ? { ...item, progress: 100 } : item,
) ?? [],
);
setPendingUpload(
(prev) =>
prev?.map((item) =>
item.uploadId === uploadId
? { ...item, status: "completed", path: result.Location }
: item,
) ?? [],
);
onRefresh();
} catch (error) {
console.error(error);
}
};
// Triggers the upload process when files are selected
useEffect(() => {
if (selectedFile?.length) {
handleUpload();
}
}, [selectedFile]);
return (
<>
{!isOpen && (
<Button
className="flex h-9 items-center gap-1 text-nowrap"
onClick={() => setIsOpen(true)}
>
<Icons.cloudUpload className="size-5" />
{t("Upload Files")}
</Button>
)}
{isOpen && (
<Drawer open={isOpen} direction="right" onOpenChange={setIsOpen}>
<DrawerContent className="h-screen w-full overflow-y-auto sm:max-w-xl">
<DrawerHeader className="flex items-center justify-between">
<DrawerTitle className="flex items-center gap-1">
{t("Upload Files")}
</DrawerTitle>
<DrawerClose asChild>
<Button variant="ghost" className="">
<Icons.close className="size-4" />
</Button>
</DrawerClose>
</DrawerHeader>
<DrawerDescription className="flex items-center justify-between px-4">
<div className="flex items-center space-x-1 text-sm text-muted-foreground">
<div className="truncate">{bucketInfo.provider_name}</div>
<Icons.arrowRight className="size-3" />
<div className="font-medium text-blue-600 dark:text-blue-400">
{bucketInfo.bucket}
</div>
</div>
<p>
Max:{" "}
{formatFileSize(Number(plan?.stMaxFileSize || "0"), {
precision: 0,
})}
</p>
</DrawerDescription>
<div className="space-y-4 p-4">
<DragAndDrop
setSelectedFile={setSelectedFile}
bucketInfo={bucketInfo}
/>
<UploadPending
pendingUpload={pendingUpload}
progressList={progressList}
bucketInfo={bucketInfo}
onAbort={abortUpload}
/>
</div>
<DrawerFooter className="flex flex-row items-center justify-between gap-2">
<DrawerClose asChild>
<Button variant="outline">{t("Cancel")}</Button>
</DrawerClose>
<Button
variant="destructive"
onClick={() => {
setSelectedFile([]);
setPendingUpload([]);
setProgressList([]);
}}
>
{t("Clear")}
</Button>
</DrawerFooter>
</DrawerContent>
</Drawer>
)}
</>
);
}
+2 -2
View File
@@ -399,7 +399,7 @@ export function DomainForm({
<FormSectionColumns title="">
<div className="flex w-full items-start justify-between gap-2">
<Label className="mt-2.5 text-nowrap" htmlFor="api-key">
{t("API Token")}:
{t("API Key")}:
</Label>
<div className="w-full sm:w-3/5">
<Input
@@ -422,7 +422,7 @@ export function DomainForm({
href="/docs/developer/cloudflare"
target="_blank"
>
{t("How to get api token?")}
{t("How to get api key?")}
</Link>
</p>
)}
+86 -2
View File
@@ -4,12 +4,13 @@ import { Dispatch, SetStateAction, useState, useTransition } from "react";
import Link from "next/link";
import { zodResolver } from "@hookform/resolvers/zod";
import { User } from "@prisma/client";
import { create } from "lodash";
import { create, get } from "lodash";
import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { PlanQuotaFormData } from "@/lib/dto/plan";
import { formatFileSize } from "@/lib/utils";
import { createPlanSchema } from "@/lib/validations/plan";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -43,6 +44,12 @@ export function PlanForm({
const t = useTranslations("List");
const [isPending, startTransition] = useTransition();
const [isDeleting, startDeleteTransition] = useTransition();
const [currentStMaxTotalSize, setCurrentStMaxTotalSize] = useState(
initData?.stMaxTotalSize || "5242880000",
);
const [currentStMaxFileSize, setCurrentStMaxFileSize] = useState(
initData?.stMaxFileSize || "26214400",
);
const {
handleSubmit,
@@ -64,6 +71,9 @@ export function PlanForm({
emEmailAddresses: initData?.emEmailAddresses || 100,
emDomains: initData?.emDomains || 1,
emSendEmails: initData?.emSendEmails || 100,
stMaxFileSize: initData?.stMaxFileSize || "26214400",
stMaxTotalSize: initData?.stMaxTotalSize || "524288000",
stMaxFileCount: initData?.stMaxFileCount || 1000,
appSupport: initData?.appSupport || "BASIC",
appApiAccess: initData?.appApiAccess || false,
isActive: initData?.isActive || false,
@@ -290,7 +300,6 @@ export function PlanForm({
className="flex-1 shadow-inner"
size={32}
type="number"
disabled
{...register("slDomains", { valueAsNumber: true })}
/>
</div>
@@ -398,6 +407,81 @@ export function PlanForm({
</FormSectionColumns>
</div>
<div className="relative grid-cols-1 gap-2 rounded-md border bg-neutral-50 px-3 pb-3 pt-8 dark:bg-neutral-900 md:grid md:grid-cols-2">
<h2 className="absolute left-2 top-2 text-xs font-semibold text-neutral-400">
{t("Storage Service")}
</h2>
{/* Max File Size - stMaxFileSize */}
<FormSectionColumns title={t("Max File Size")} required>
<div className="flex w-full items-center gap-2">
<Label className="sr-only" htmlFor="Record-Limit">
{t("Max File Size")}
</Label>
<div className="relative flex-1">
<Input
id="max-file-size"
className="shadow-inner"
size={32}
{...register("stMaxFileSize")}
onChange={(e) => setCurrentStMaxFileSize(e.target.value)}
/>
<span className="absolute right-2 top-[11px] text-xs text-muted-foreground">
=
{formatFileSize(Number(currentStMaxFileSize), {
precision: 0,
})}
</span>
</div>
</div>
<div className="flex flex-col justify-between p-1">
{errors?.stMaxFileSize ? (
<p className="pb-0.5 text-[13px] text-red-600">
{errors.stMaxFileSize.message}
</p>
) : (
<p className="pb-0.5 text-[13px] text-muted-foreground">
{t("Maximum uploaded single file size in bytes")}.
</p>
)}
</div>
</FormSectionColumns>
{/* Max File Size - stMaxTotalSize */}
<FormSectionColumns title={t("Max Total Size")} required>
<div className="flex w-full items-center gap-2">
<Label className="sr-only" htmlFor="Record-Limit">
{t("Max Total Size")}
</Label>
<div className="relative flex-1">
<Input
id="max-total-size"
className="shadow-inner"
size={32}
type="number"
{...register("stMaxTotalSize")}
onChange={(e) => setCurrentStMaxTotalSize(e.target.value)}
/>
<span className="absolute right-2 top-[11px] text-xs text-muted-foreground">
=
{formatFileSize(Number(currentStMaxTotalSize), {
precision: 0,
})}
</span>
</div>
</div>
<div className="flex flex-col justify-between p-1">
{errors?.stMaxTotalSize ? (
<p className="pb-0.5 text-[13px] text-red-600">
{errors.stMaxTotalSize.message}
</p>
) : (
<p className="pb-0.5 text-[13px] text-muted-foreground">
{t("Maximum uploaded total file size in bytes")}.
</p>
)}
</div>
</FormSectionColumns>
</div>
{/* Action buttons */}
<div className="mt-3 flex justify-end gap-3">
{type === "edit" && initData?.name !== "free" && (
+3 -3
View File
@@ -47,7 +47,7 @@ export interface RecordFormProps {
type: FormType;
initData?: ShortUrlFormData | null;
action: string;
onRefresh: () => void;
onRefresh: (id?: string) => void;
}
export function UrlForm({
@@ -141,10 +141,10 @@ export function UrlForm({
description: await response.text(),
});
} else {
// const res = await response.json();
const res = await response.json();
toast.success(`Created successfully!`);
setShowForm(false);
onRefresh();
onRefresh(res.id);
}
});
};
+1
View File
@@ -15,6 +15,7 @@ export function Notification() {
const { data, isLoading, error } = useSWR<Record<string, any>>(
"/api/configs?key=system_notification",
fetcher,
{ dedupingInterval: 30000 },
);
const handleClose = () => {
+51
View File
@@ -3,6 +3,7 @@ import {
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
ArrowUpRight,
BookOpen,
BotMessageSquare,
@@ -15,6 +16,8 @@ import {
ChevronLeft,
ChevronRight,
CirclePlay,
CloudUpload,
Code,
Copy,
Crown,
Download,
@@ -57,6 +60,8 @@ import {
Settings,
SunMedium,
Trash2,
Type,
Unlink,
Unplug,
User,
UserCog,
@@ -75,6 +80,7 @@ export const Icons = {
arrowRight: ArrowRight,
arrowUpRight: ArrowUpRight,
arrowLeft: ArrowLeft,
arrowUp: ArrowUp,
arrowDown: ArrowDown,
chevronLeft: ChevronLeft,
chevronRight: ChevronRight,
@@ -83,10 +89,54 @@ export const Icons = {
check: Check,
checkCheck: CheckCheck,
close: X,
code: Code,
copy: Copy,
type: Type,
camera: Camera,
calendar: Calendar,
crown: Crown,
cloudUpload: ({ ...props }: LucideProps) => (
<svg
role="presentation"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
aria-hidden="true"
focusable="false"
{...props}
>
<g clipPath="url(#upload_svg__clip0)">
<path
fill="currentColor"
d="M14.966 7.211a2.91 2.91 0 00-2-.68 4.822 4.822 0 00-9.243-1.147A3.41 3.41 0 001.3 6.18 3.65 3.65 0 000 8.938a3.562 3.562 0 003.554 3.555H6.5v-1H3.547A2.559 2.559 0 011 8.938a2.64 2.64 0 01.943-1.992 2.413 2.413 0 012.032-.527l.435.075.13-.422a3.821 3.821 0 017.47 1.016l.017.57.563-.091a2.071 2.071 0 011.729.404A2.029 2.029 0 0115 9.508a1.987 1.987 0 01-1.985 1.985h-.032c-.061.001-.428.006-2.483.006v1c1.93 0 2.392-.004 2.515-.007a3.01 3.01 0 001.951-5.282v.001z"
></path>
<path
fill="currentColor"
d="M10.95 9.456l-2.46-2.5-2.46 2.5.712.701L7.99 8.89v3.62h1v-3.62l1.248 1.268.713-.701z"
></path>
</g>
<defs>
<clipPath id="upload_svg__clip0">
<path d="M0 0h16v16H0z"></path>
</clipPath>
</defs>
</svg>
),
storage: ({ ...props }: LucideProps) => (
<svg
role="presentation"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
aria-hidden="true"
focusable="false"
{...props}
>
<path
fill="currentColor"
fillRule="evenodd"
d="M12.116 2.57c-.973-.326-2.384-.546-3.991-.546-1.607 0-3.018.22-3.99.545-.492.164-.814.337-.992.478a.89.89 0 00-.082.072c.02.069.078.158.225.268.21.158.548.313 1.025.448.947.266 2.292.409 3.814.409 1.522 0 2.867-.143 3.814-.41.477-.134.816-.29 1.025-.447.147-.11.204-.2.225-.268a.884.884 0 00-.082-.072c-.178-.141-.5-.314-.991-.478zM4.02 4.818a5.18 5.18 0 01-.97-.369v1.757c0 .079.039.206.25.377.214.173.556.347 1.03.5.944.306 2.284.49 3.795.49 1.51 0 2.85-.184 3.794-.49.474-.153.817-.327 1.03-.5.212-.171.251-.298.251-.377V4.45a5.18 5.18 0 01-.97.369c-1.08.304-2.534.45-4.105.45-1.571 0-3.026-.146-4.105-.45zm10.23 1.388V3.05C14.25 1.917 11.508 1 8.125 1S2 1.917 2 3.049V12.95C2 14.083 4.742 15 8.125 15s6.125-.917 6.125-2.049V6.207zM13.2 7.654c-.28.157-.602.29-.95.403-1.083.35-2.543.54-4.125.54-1.582 0-3.042-.19-4.125-.54a5.258 5.258 0 01-.95-.403v1.712c0 .078.039.205.25.376.214.173.556.348 1.03.501.944.306 2.284.489 3.795.489 1.51 0 2.85-.183 3.794-.489.474-.153.817-.328 1.03-.5.212-.172.251-.299.251-.377V7.654zM4 11.215a5.253 5.253 0 01-.95-.403v2.058c.018.019.047.046.093.083.178.141.5.314.992.478.972.325 2.383.545 3.99.545 1.607 0 3.018-.22 3.99-.545.492-.164.814-.337.992-.478a.809.809 0 00.093-.083v-2.058a5.25 5.25 0 01-.95.403c-1.083.351-2.543.541-4.125.541-1.582 0-3.042-.19-4.125-.54zm9.224 1.624s0 .002-.004.006a.028.028 0 01.004-.006zm-10.198 0l.004.006a.024.024 0 01-.004-.006zm1.599-6.29c.29 0 .525-.23.525-.512a.519.519 0 00-.525-.513c-.29 0-.525.23-.525.513 0 .282.235.512.525.512zM5.15 9.28a.519.519 0 01-.525.513.519.519 0 01-.525-.513c0-.282.235-.512.525-.512.29 0 .525.23.525.512zm-.525 3.671c.29 0 .525-.23.525-.512a.519.519 0 00-.525-.512c-.29 0-.525.23-.525.512 0 .283.235.512.525.512z"
></path>
</svg>
),
eye: Eye,
lock: LockKeyhole,
list: List,
@@ -330,6 +380,7 @@ export const Icons = {
</svg>
),
link: Link,
unLink: Unlink,
mail: Mail,
mailPlus: MailPlus,
mailOpen: MailOpen,
-1
View File
@@ -12,7 +12,6 @@ import Link from "next/link";
import { debounce } from "lodash";
import { useTranslations } from "next-intl";
import { HexColorPicker } from "react-colorful";
import { toast } from "sonner";
import { getQRAsCanvas, getQRAsSVGDataUri, getQRData } from "@/lib/qr";
import { WRDO_QR_LOGO } from "@/lib/qr/constants";
+12 -2
View File
@@ -10,9 +10,13 @@ export const sidebarLinks: SidebarNavItem[] = [
items: [
{ href: "/dashboard", icon: "dashboard", title: "Dashboard" },
{ href: "/dashboard/urls", icon: "link", title: "Short Urls" },
{ href: "/emails", icon: "mail", title: "Emails" },
{ href: "/dashboard/records", icon: "globe", title: "DNS Records" },
{ href: "/chat", icon: "messages", title: "WRoom" },
{ href: "/emails", icon: "mail", title: "Emails" },
{
href: "/dashboard/storage",
icon: "storage",
title: "Cloud Storage",
},
],
},
{
@@ -72,6 +76,12 @@ export const sidebarLinks: SidebarNavItem[] = [
title: "Records",
authorizeOnly: UserRole.ADMIN,
},
{
href: "/admin/storage",
icon: "storage",
title: "Cloud Storage Manage",
authorizeOnly: UserRole.ADMIN,
},
{
href: "/admin/system",
icon: "settings",
+5
View File
@@ -156,6 +156,11 @@ export const docsConfig: DocsConfig = {
href: "/docs/developer/database",
icon: "page",
},
{
title: "Cloud Storage",
href: "/docs/developer/cloud-storage",
icon: "page",
},
{
title: "Telegram Bot",
href: "/docs/developer/telegram-bot",
+6 -6
View File
@@ -18,7 +18,7 @@ The `Short URL Service` and `Email Service` require no additional configuration
To enable the `DNS Record Service`, you must complete the `Cloudflare Configs(Optional)` form with the following fields:
- Zone ID
- API Token
- API Key
- Email
These fields are used to configure the Cloudflare API. If your domain is hosted through Cloudflare, you can find these details in the Cloudflare dashboard.
@@ -29,9 +29,9 @@ The unique identifier for a domain hosted on Cloudflare, located at:
https://dash.cloudflare.com/[account_id]/[zone_name]
### API Token
### API Key
Visit https://dash.cloudflare.com/profile/api-tokens, and find the Global API Key under the API Tokens section.
Visit https://dash.cloudflare.com/profile/api-tokens, and find the **Global API** Key under the API Tokens section.
### Email
@@ -39,7 +39,7 @@ Email for registering a Cloudflare account
<Callout type="info">
You can manage domains hosted under different Cloudflare accounts,
provided the API Token and Email are sourced from the same account.
provided the API Key and Email are sourced from the same account.
</Callout>
---
@@ -72,11 +72,11 @@ NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME=example.com,example2.com
### CLOUDFLARE_API_KEY
- Description: The API key used to authenticate requests to the Cloudflare API.
- Where to find: In the Cloudflare dashboard, go to Profile > API Tokens and locate your Global API Key.
- Where to find: In the Cloudflare dashboard, go to Profile > API Tokens and locate your **Global API Key**.
- Example: 1234567890abcdef1234567890abcdef
- Security Note: Keep this key confidential and never expose it in client-side code.
> Instructions: Visit https://dash.cloudflare.com/profile/api-tokens, and find the Global API Key under the API Tokens section.
> Instructions: Visit https://dash.cloudflare.com/profile/api-tokens, and find the **Global API Key** under the API Tokens section.
### CLOUDFLARE_EMAIL
@@ -13,6 +13,7 @@ description: 简单介绍 WR.DO 部署所需的环境变量
或参考社区优秀部署文档:
- https://linux.do/t/topic/711806
- https://bravexist.cn/2025/06/wr.do.html
- https://b23.tv/fWpMFQu (视频教程)
</Callout>
<Steps>
+1
View File
@@ -13,6 +13,7 @@ description: How to install the project.
Or read unofficial deployment tutorials:
- https://linux.do/t/topic/711806
- https://bravexist.cn/2025/06/wr.do.html
- https://b23.tv/fWpMFQu (Video tutorial)
</Callout>
<Steps>
+10
View File
@@ -0,0 +1,10 @@
---
title: S3 Configs
description: How to config the s3 api.
---
## Overview
Administrators can manage s3 configurations at `/admin/system`, including adding, deleting, and modifying s3 configurations.
## Cloudflare R2
+10
View File
@@ -34,6 +34,16 @@ WR.DO is a all-in-one web utility platform featuring short links with analytics,
- Support enabling application mode (user submission, admin approval)
- Support email notification of administrator and user domain application status
- 💳 **Cloud Storage Service**
- Connects to multiple channels (S3 API) cloud storage platforms (Cloudflare R2, AWS S3)
- Supports single-channel multi-bucket configuration
- Dynamic configuration (user quota settings) for file upload size limits
- Supports drag-and-drop, batch, and chunked file uploads
- Supports batch file deletion
- Quickly generates short links and QR codes for files
- Supports online preview of certain file types
- Supports file uploads via API calls
- 📡 **Open API Module**:
- Website metadata extraction API
- Website screenshot capture API
+348
View File
@@ -0,0 +1,348 @@
import { Prisma, UserFile } from "@prisma/client";
import { prisma } from "../db";
export interface UserFileData extends UserFile {
user: {
name: string;
email: string;
};
}
export interface CreateUserFileInput {
userId: string;
name: string;
originalName?: string;
mimeType: string;
size: number;
path: string;
etag?: string;
storageClass?: string;
channel: string;
platform: string;
providerName: string;
bucket: string;
shortUrlId?: string;
lastModified: Date;
}
export interface UpdateUserFileInput {
name?: string;
originalName?: string;
mimeType?: string;
size?: number;
path?: string;
etag?: string;
storageClass?: string;
channel?: string;
platform?: string;
providerName?: string;
bucket?: string;
shortUrlId?: string;
status?: number;
lastModified?: Date;
}
export interface QueryUserFileOptions {
bucket?: string;
userId?: string;
providerName?: string;
status?: number;
channel?: string;
platform?: string;
shortUrlId?: string;
page?: number;
limit?: number;
orderBy?: "createdAt" | "lastModified" | "size";
order?: "asc" | "desc";
}
// 创建文件记录
export async function createUserFile(data: CreateUserFileInput) {
try {
const userFile = await prisma.userFile.create({
data: {
...data,
updatedAt: new Date(),
},
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
return { success: true, data: userFile };
} catch (error) {
console.error("Failed to create file record:", error);
return { success: false, error: "Failed to create file record" };
}
}
// 根据ID查询文件记录
export async function getUserFileById(id: string) {
try {
const userFile = await prisma.userFile.findUnique({
where: { id },
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
return { success: true, data: userFile };
} catch (error) {
console.error("Failed to query file record:", error);
return { success: false, error: "Failed to query file record" };
}
}
// 条件查询文件记录
export async function getUserFiles(options: QueryUserFileOptions = {}) {
try {
const {
bucket,
userId,
providerName,
status,
channel,
platform,
shortUrlId,
page = 1,
limit = 20,
orderBy = "createdAt",
order = "desc",
} = options;
const where: Prisma.UserFileWhereInput = {
bucket,
...(status && { status }),
...(userId && { userId }),
...(providerName && { providerName }),
...(channel && { channel }),
...(platform && { platform }),
...(shortUrlId && { shortUrlId }),
};
const [files, total, totalSize] = await Promise.all([
prisma.userFile.findMany({
where,
include: {
user: {
select: {
name: true,
email: true,
},
},
},
orderBy: { [orderBy]: order },
skip: (page - 1) * limit,
take: limit,
}),
prisma.userFile.count({ where }),
prisma.userFile.aggregate({
where,
_sum: { size: true },
}),
]);
return {
total,
totalSize: totalSize._sum.size || 0,
list: files,
};
} catch (error) {
console.error("[GetUserFiles Error]", error);
return { success: false, error: "[GetUserFiles Error]" };
}
}
// 更新文件记录
export async function updateUserFile(id: string, data: UpdateUserFileInput) {
try {
const userFile = await prisma.userFile.update({
where: { id },
data: {
...data,
updatedAt: new Date(),
},
include: {
user: {
select: {
name: true,
email: true,
},
},
},
});
return { success: true, data: userFile };
} catch (error) {
console.error("Failed to update file record:", error);
return { success: false, error: "Failed to update file record" };
}
}
// 软删除文件记录
export async function softDeleteUserFile(id: string) {
try {
const userFile = await prisma.userFile.update({
where: { id },
data: {
status: 0,
updatedAt: new Date(),
},
});
return { success: true, data: userFile };
} catch (error) {
console.error("Delete file record failed:", error);
return { success: false, error: "Delete file record failed" };
}
}
// 批量软删除
export async function softDeleteUserFiles(ids: string[]) {
try {
const result = await prisma.userFile.updateMany({
where: {
id: { in: ids },
},
data: {
status: 0,
updatedAt: new Date(),
},
});
return { success: true, data: result };
} catch (error) {
console.error("Delete file records failed:", error);
return { success: false, error: "Delete file records failed" };
}
}
// 物理删除文件记录
export async function deleteUserFile(id: string) {
try {
const userFile = await prisma.userFile.delete({
where: { id },
});
return { success: true, data: userFile };
} catch (error) {
console.error("Delete file record failed:", error);
return { success: false, error: "Delete file record failed" };
}
}
// 获取用户文件统计
export async function getUserFileStats(userId: string) {
try {
const [totalFiles, totalSize, filesByProvider] = await Promise.all([
prisma.userFile.count({
where: { userId, status: 1 },
}),
prisma.userFile.aggregate({
where: { userId, status: 1 },
_sum: { size: true },
}),
prisma.userFile.groupBy({
by: ["providerName"],
where: { userId, status: 1 },
_count: { id: true },
_sum: { size: true },
}),
]);
return {
success: true,
data: {
totalFiles,
totalSize: totalSize._sum.size || 0,
filesByProvider,
},
};
} catch (error) {
console.error("Failed to get file statistics:", error);
return { success: false, error: "Failed to get file statistics" };
}
}
// 根据路径查找文件
export async function getUserFileByPath(path: string, providerName?: string) {
try {
const where: Prisma.UserFileWhereInput = {
path,
status: 1,
...(providerName && { providerName }),
};
const userFile = await prisma.userFile.findFirst({
where,
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
return { success: true, data: userFile };
} catch (error) {
console.error("Failed to query file record:", error);
return { success: false, error: "Failed to query file record" };
}
}
// 根据短链接ID查询文件
export async function getUserFileByShortUrlId(shortUrlId: string) {
try {
const userFile = await prisma.userFile.findFirst({
where: {
shortUrlId,
status: 1,
},
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
return { success: true, data: userFile };
} catch (error) {
console.error("Failed to query file record:", error);
return { success: false, error: "Failed to query file record" };
}
}
// 清理过期文件记录
export async function cleanupExpiredFiles(days: number = 30) {
try {
const expiredDate = new Date();
expiredDate.setDate(expiredDate.getDate() - days);
const result = await prisma.userFile.deleteMany({
where: {
status: 0,
updatedAt: {
lt: expiredDate,
},
},
});
return { success: true, data: result };
} catch (error) {
console.error("Failed to clean up expired files:", error);
return { success: false, error: "Failed to clean up expired files" };
}
}
+15
View File
@@ -12,6 +12,9 @@ export interface PlanQuota {
emEmailAddresses: number;
emDomains: number;
emSendEmails: number;
stMaxFileSize: string;
stMaxTotalSize: string;
stMaxFileCount: number;
appSupport: string;
appApiAccess: boolean;
isActive: boolean;
@@ -42,6 +45,9 @@ export async function getPlanQuota(planName: string) {
emEmailAddresses: 0,
emDomains: 0,
emSendEmails: 0,
stMaxFileSize: "26214400",
stMaxTotalSize: "524288000",
stMaxFileCount: 1000,
appSupport: "BASIC",
appApiAccess: true,
isActive: true,
@@ -60,6 +66,9 @@ export async function getPlanQuota(planName: string) {
emEmailAddresses: plan.emEmailAddresses,
emDomains: plan.emDomains,
emSendEmails: plan.emSendEmails,
stMaxFileSize: plan.stMaxFileSize,
stMaxTotalSize: plan.stMaxTotalSize,
stMaxFileCount: plan.stMaxFileCount,
appSupport: plan.appSupport.toLowerCase(),
appApiAccess: plan.appApiAccess,
isActive: plan.isActive,
@@ -121,6 +130,9 @@ export async function updatePlanQuota(plan: PlanQuotaFormData) {
emEmailAddresses: plan.emEmailAddresses,
emDomains: plan.emDomains,
emSendEmails: plan.emSendEmails,
stMaxFileSize: plan.stMaxFileSize,
stMaxTotalSize: plan.stMaxTotalSize,
stMaxFileCount: plan.stMaxFileCount,
appSupport: plan.appSupport.toUpperCase() as any,
appApiAccess: plan.appApiAccess,
isActive: plan.isActive,
@@ -144,6 +156,9 @@ export async function createPlan(plan: PlanQuota) {
emEmailAddresses: plan.emEmailAddresses,
emDomains: plan.emDomains,
emSendEmails: plan.emSendEmails,
stMaxFileSize: plan.stMaxFileSize,
stMaxTotalSize: plan.stMaxTotalSize,
stMaxFileCount: plan.stMaxFileCount,
appSupport: plan.appSupport.toUpperCase() as any,
appApiAccess: plan.appApiAccess,
isActive: true,
+13
View File
@@ -124,6 +124,19 @@ export async function getUserShortUrlCount(
}
}
export async function getUserShortLinksByIds(ids: string[], userId: string) {
try {
return await prisma.userUrl.findMany({
where: {
id: { in: ids },
userId,
},
});
} catch (error) {
return [];
}
}
export async function getUrlClicksByIds(
ids: string[],
userId: string,
+2 -1
View File
@@ -35,7 +35,8 @@ function parseConfigValue(value: string, type: ConfigType): any {
case "OBJECT":
try {
return JSON.parse(value);
} catch {
} catch (e) {
console.error(e);
return {};
}
case "STRING":
+4 -1
View File
@@ -200,10 +200,13 @@ export const updateUser = async (userId: string, data: UpdateUserForm) => {
export const deleteUserById = async (userId: string) => {
try {
const session = await prisma.user.delete({
const session = await prisma.user.update({
where: {
id: userId,
},
data: {
active: 0,
},
});
return session;
} catch (error) {
+181
View File
@@ -0,0 +1,181 @@
import {
DeleteObjectCommand,
GetObjectCommand,
HeadObjectCommand,
ListObjectsV2Command,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
export interface CloudStorageCredentials {
enabled?: boolean;
platform?: string;
channel?: string;
provider_name?: string;
account_id?: string;
access_key_id?: string;
secret_access_key?: string;
endpoint?: string;
buckets: BucketItem[];
}
export interface ClientStorageCredentials {
enabled?: boolean;
platform?: string;
channel?: string;
provider_name?: string;
buckets: BucketItem[];
}
export interface BucketItem {
bucket: string;
custom_domain?: string;
prefix?: string;
file_types?: string;
file_size?: string;
region?: string;
public: boolean;
}
export interface FileObject {
Key?: string;
LastModified?: Date;
ETag?: string;
Size?: number;
StorageClass?: string;
}
export function createS3Client(
endpoint: string,
accessKeyId: string,
secretAccessKey: string,
region: string = "auto",
) {
return new S3Client({
region: region,
endpoint: endpoint,
credentials: {
accessKeyId,
secretAccessKey,
},
});
}
export async function uploadFile(
file: Buffer,
key: string,
s3: S3Client,
bucket: string,
) {
const command = new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: file,
});
try {
const response = await s3.send(command);
return response;
} catch (error) {
console.error("Error uploading file:", error);
throw error;
}
}
export async function getSignedUrlForUpload(
key: string,
contentType: string,
s3: S3Client,
bucket: string,
): Promise<string> {
const command = new PutObjectCommand({
Bucket: bucket,
Key: key,
ContentType: contentType,
});
try {
const signedUrl = await getSignedUrl(s3, command, { expiresIn: 3600 });
return signedUrl;
} catch (error) {
console.error("Error generating signed URL:", error);
throw error;
}
}
export async function getSignedUrlForDownload(
key: string,
s3: S3Client,
bucket: string,
): Promise<string> {
const command = new GetObjectCommand({
Bucket: bucket,
Key: key,
});
try {
const signedUrl = await getSignedUrl(s3, command, { expiresIn: 3600 });
return signedUrl;
} catch (error) {
console.error("Error generating signed URL:", error);
throw error;
}
}
export async function listFiles(
prefix: string = "",
s3: S3Client,
bucket: string,
): Promise<FileObject[]> {
const command = new ListObjectsV2Command({
Bucket: bucket,
Prefix: prefix,
});
try {
const response = await s3.send(command);
return response.Contents || [];
} catch (error) {
console.error("Error listing files:", error);
throw error;
}
}
export async function getFileInfo(R2: S3Client, bucket: string, key: string) {
try {
const headCommand = new HeadObjectCommand({
Bucket: bucket,
Key: key,
});
const headResponse = await R2.send(headCommand);
return {
size: headResponse.ContentLength || 0,
etag: headResponse.ETag || "",
lastModified: headResponse.LastModified || new Date(),
contentType: headResponse.ContentType || "",
storageClass: headResponse.StorageClass || "",
metadata: headResponse.Metadata || {},
};
} catch (error) {
console.error("Error getting file info:", error);
throw error;
}
}
export async function deleteFile(key: string, s3: S3Client, bucket: string) {
const command = new DeleteObjectCommand({
Bucket: bucket,
Key: key,
});
try {
const response = await s3.send(command);
return response;
} catch (error) {
console.error("Error deleting file:", error);
throw error;
}
}
+1 -1
View File
@@ -67,7 +67,7 @@ export async function restrictByTimeRange({
if (count >= limit) {
return {
status: 409,
statusText: `You have exceeded the ${rangeType}ly ${model.toString()} creation limit (${limit}). Please try again later.`,
statusText: `You have exceeded the ${rangeType}ly ${model.toString()} usage limit (${limit}). Please try again later.`,
};
}
return null;
+179 -5
View File
@@ -189,6 +189,54 @@ export const truncate = (str: string, length: number) => {
return `${str.slice(0, length)}...`;
};
export const truncateMiddle = (
text: string,
maxLength: number = 20,
): string => {
if (text.length <= maxLength) return text;
// 找到最后一个点的位置(文件扩展名)
const lastDotIndex = text.lastIndexOf(".");
if (lastDotIndex === -1 || lastDotIndex === 0) {
// 没有扩展名,直接中间截断
const half = Math.floor((maxLength - 3) / 2);
return text.slice(0, half) + "..." + text.slice(-half);
}
const extension = text.slice(lastDotIndex);
const nameWithoutExt = text.slice(0, lastDotIndex);
// 如果扩展名太长,直接截断整个文件名
if (extension.length > maxLength / 2) {
const half = Math.floor((maxLength - 3) / 2);
return text.slice(0, half) + "..." + text.slice(-half);
}
// 计算可用于文件名的长度
const availableLength = maxLength - extension.length - 3;
if (availableLength <= 0) {
return "..." + extension;
}
// 如果文件名部分不需要截断
if (nameWithoutExt.length <= availableLength) {
return text;
}
// 中间截断文件名部分
const startLength = Math.ceil(availableLength / 2);
const endLength = Math.floor(availableLength / 2);
return (
nameWithoutExt.slice(0, startLength) +
"..." +
nameWithoutExt.slice(-endLength) +
extension
);
};
export const getBlurDataURL = async (url: string | null) => {
if (!url) {
return "data:image/webp;base64,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
@@ -274,12 +322,40 @@ export function htmlToText(html: string): string {
return doc.body.textContent || "";
}
export function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
export function formatFileSize(
bytes: number,
options: {
precision?: number;
binary?: boolean; // true for 1024, false for 1000
longNames?: boolean; // true for "bytes", false for "B"
} = {},
): string {
const { precision = 1, binary = true, longNames = false } = options;
// 输入验证
if (typeof bytes !== "number" || isNaN(bytes) || bytes < 0) {
return longNames ? "0 bytes" : "0 B";
}
if (bytes === 0) {
return longNames ? "0 bytes" : "0 B";
}
const k = binary ? 1024 : 1000;
const sizes = longNames
? ["bytes", "KB", "MB", "GB", "TB", "PB"]
: ["B", "KB", "MB", "GB", "TB", "PB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
const sizeIndex = Math.min(i, sizes.length - 1);
const size = bytes / Math.pow(k, sizeIndex);
// 特殊处理 bytes 单位的复数形式
if (longNames && sizeIndex === 0) {
return bytes === 1 ? "1 byte" : `${bytes} bytes`;
}
return `${size.toFixed(precision)} ${sizes[sizeIndex]}`;
}
export function downloadFile(url: string, filename: string): Promise<void> {
@@ -397,3 +473,101 @@ export function verifyPassword(
const hashToVerify = crypto.scryptSync(password, salt, 64).toString("hex");
return hash === hashToVerify;
}
export const formatFileSizeX = (bytes: number) => {
if (bytes < 1048576) return (bytes / 1024).toFixed() + " KB";
if (bytes < 1073741824) return (bytes / 1048576).toFixed(2) + " MB";
return (bytes / 1073741824).toFixed(2) + " GB";
};
export function extractFileName(filePath: string): string {
if (!filePath || typeof filePath !== "string") {
return "";
}
// 移除开头的斜杠
let normalizedPath = filePath.trim();
while (normalizedPath.startsWith("/")) {
normalizedPath = normalizedPath.substring(1);
}
// 移除结尾的斜杠
while (normalizedPath.endsWith("/")) {
normalizedPath = normalizedPath.substring(0, normalizedPath.length - 1);
}
// 如果路径为空,返回空字符串
if (!normalizedPath) {
return "";
}
// 提取文件名
const lastSlashIndex = normalizedPath.lastIndexOf("/");
return lastSlashIndex === -1
? normalizedPath
: normalizedPath.substring(lastSlashIndex + 1);
}
// 提取文件扩展名
export function extractFileExtension(filePath: string): string {
const fileName = extractFileName(filePath);
if (!fileName) {
return "";
}
const lastDotIndex = fileName.lastIndexOf(".");
// 如果没有找到点,或者点在开头(隐藏文件),返回空字符串
if (lastDotIndex === -1 || lastDotIndex === 0) {
return "";
}
return fileName.substring(lastDotIndex + 1);
}
// 同时提取文件名和扩展名的组合函数
export function extractFileNameAndExtension(filePath: string): {
fileName: string;
extension: string;
nameWithoutExtension: string;
} {
const fileName = extractFileName(filePath);
if (!fileName) {
return {
fileName: "",
extension: "",
nameWithoutExtension: "",
};
}
const lastDotIndex = fileName.lastIndexOf(".");
if (lastDotIndex === -1 || lastDotIndex === 0) {
return {
fileName: fileName,
extension: "",
nameWithoutExtension: fileName,
};
}
return {
fileName: fileName,
extension: fileName.substring(lastDotIndex + 1),
nameWithoutExtension: fileName.substring(0, lastDotIndex),
};
}
export function generateFileKey(fileName: string, prefix?: string): string {
if (prefix) {
return `${prefix}/${fileName}`;
}
const date = new Date();
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}/${month}/${day}/${fileName}`;
}
+3
View File
@@ -13,6 +13,9 @@ export const createPlanSchema = z.object({
emEmailAddresses: z.number().optional().default(0),
emDomains: z.number().optional().default(0),
emSendEmails: z.number().optional().default(0),
stMaxFileSize: z.string().optional().default("26214400"),
stMaxTotalSize: z.string().optional().default("524288000"),
stMaxFileCount: z.number().optional().default(1000),
appSupport: z.string().optional().default("BASIC"),
appApiAccess: z.boolean().optional().default(true),
isActive: z.boolean().optional().default(true),
+68 -5
View File
@@ -130,7 +130,7 @@
"API Token": "API Token",
"Account Email": "Account Email",
"How to get zone id?": "How to get zone id?",
"How to get api token?": "How to get api token?",
"How to get api key?": "How to get api key?",
"How to get cloudflare account email?": "How to get cloudflare account email?",
"Resend Configs": "Resend Configs",
"Associate with 'Subdomain Service' status": "Associate with 'Subdomain Service' status",
@@ -185,7 +185,35 @@
"Duplicate": "Duplicate",
"Confirm duplicate domain": "Confirm duplicate domain",
"This will duplicate all configuration information for the {domain} domain, and create a new domain": "This will duplicate all configuration information for the {domain} domain, and create a new domain",
"Add User": "Add User"
"Add User": "Add User",
"Loading storage buckets": "Loading storage buckets",
"No buckets found": "No buckets found",
"The administrator has not configured the storage bucket, no file can be uploaded": "The administrator has not configured the storage bucket, no file can be uploaded",
"No Files": "No Files",
"You don't upload any files yet": "You don't upload any files yet",
"Size": "Size",
"Date": "Date",
"Download": "Download",
"Generate short link": "Shorten link",
"Delete File": "Delete",
"Select all": "Select all",
"Delete selected": "Delete selected",
"Update short link": "Update short link",
"QR Code": "QR Code",
"Raw Data": "Raw Data",
"Storage Service": "Storage Service",
"Max File Size": "Max File Size",
"Maximum uploaded single file size in bytes": "Maximum uploaded single file size in bytes",
"Max Total Size": "Max Total Size",
"Maximum uploaded total file size in bytes": "Maximum uploaded total file size in bytes",
"storageUsage": "Storage Usage",
"used": "Used",
"usedSpace": "Used Space",
"totalCapacity": "Total Capacity",
"availableSpace": "Available Space",
"storageFull": "Storage space is almost full",
"storageHigh": "Storage space usage is high",
"storageGood": "Storage space is sufficient"
},
"Components": {
"Dashboard": "Dashboard",
@@ -324,7 +352,21 @@
"Actived": "Active",
"Disabled": "Disabled",
"Expired": "Expired",
"PasswordProtected": "Password Protected"
"PasswordProtected": "Password Protected",
"Upload List": "Upload List",
"Uploading": "Uploading",
"Completed": "Completed",
"Aborted": "Aborted",
"Drop files to upload them to": "Drop files to upload them to",
"Drag and drop file(s) here": "Drag and drop file(s) here",
"or": "or",
"Browse file(s)": "Browse file(s)",
"Cloud Storage": "Cloud Storage",
"List and manage cloud storage": "List and manage cloud storage",
"Cancel": "Cancel",
"Clear": "Clear",
"Upload Files": "Upload Files",
"Uploud channel": "Uploud channel"
},
"Landing": {
"settings": "Settings",
@@ -424,7 +466,9 @@
"Admin": "Admin",
"Sign in": "Sign in",
"Log out": "Log out",
"System Settings": "System Settings"
"System Settings": "System Settings",
"Cloud Storage": "Cloud Storage",
"Cloud Storage Manager": "Cloud Storage"
},
"Email": {
"Search emails": "Search emails",
@@ -554,6 +598,25 @@
"Email Suffix White List": "Email Suffix White List",
"Set email suffix white list, split by comma, such as: gmail-com,yahoo-com,hotmail-com": "Set email suffix white list, split by comma, such as: gmail.com,yahoo.com,hotmail.com",
"Application Status Email Notifications": "Application Status Email Notifications",
"Send email notifications for subdomain application status updates; Notifies administrators when users submit applications and notifies users of approval results; Only available when subdomain application mode is enabled": "Send email notifications for subdomain application status updates; Notifies administrators when users submit applications and notifies users of approval results; Only available when subdomain application mode is enabled"
"Send email notifications for subdomain application status updates; Notifies administrators when users submit applications and notifies users of approval results; Only available when subdomain application mode is enabled": "Send email notifications for subdomain application status updates; Notifies administrators when users submit applications and notifies users of approval results; Only available when subdomain application mode is enabled",
"Cloud Storage Configs": "Cloud Storage Configs",
"Verified": "Verified",
"Verify Configuration": "Verify Configuration",
"Clear": "Clear",
"How to get the R2 credentials?": "How to get the R2 credentials?",
"Endpoint": "Endpoint",
"Access Key ID": "Access Key ID",
"Secret Access Key": "Secret Access Key",
"Enabled": "Enabled",
"Bucket": "Bucket",
"Bucket Name": "Bucket Name",
"Public Domain": "Public Domain",
"Max File Size": "Max File Size",
"Region": "Region",
"Prefix": "Prefix",
"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"
}
}
+69 -6
View File
@@ -89,7 +89,7 @@
"The record is currently pending for admin approval": "正在等待管理员审核",
"The target is currently inaccessible": "目标链接目前无法访问",
"Please check the target and try again": "请检查解析记录并重试",
"If the target is not activated within 3 days": "如果目标链接在 3 天依然无法访问",
"If the target is not activated within 3 days": "如果目标链接在 3 天依然无法访问",
"the administrator will": "管理员将",
"delete this record": "删除此记录",
"Review": "审核",
@@ -130,7 +130,7 @@
"API Token": "API Token",
"Account Email": "账户邮箱",
"How to get zone id?": "如何获取 Zone ID?",
"How to get api token?": "如何获取 API Token?",
"How to get api key?": "如何获取 API 密钥?",
"How to get cloudflare account email?": "如何获取账户邮箱?",
"Resend Configs": "Resend 配置",
"Associate with 'Subdomain Service' status": "与 '子域名服务' 启用状态关联",
@@ -185,7 +185,35 @@
"Duplicate": "复制",
"Confirm duplicate domain": "确认复制域名",
"This will duplicate all configuration information for the {domain} domain, and create a new domain": "这将复制 {domain} 域名的所有配置信息,并创建一个新域名",
"Add User": "添加用户"
"Add User": "添加用户",
"Loading storage buckets": "正在加载存储桶",
"No buckets found": "未配置存储桶",
"The administrator has not configured the storage bucket, no file can be uploaded": "管理员未配置存储桶,无法上传文件",
"No Files": "暂无文件",
"You don't upload any files yet": "您还没有上传任何文件",
"Size": "大小",
"Date": "日期",
"Download": "下载文件",
"Generate short link": "生成短链",
"Delete File": "删除文件",
"Select all": "全部选中",
"Delete selected": "删除选中",
"Update short link": "更新短链",
"QR Code": "二维码",
"Raw Data": "在线预览",
"Storage Service": "存储服务",
"Max File Size": "单文件大小上限",
"Maximum uploaded single file size in bytes": "单个文件最大上传大小(字节)",
"Max Total Size": "总文件大小上限",
"Maximum uploaded total file size in bytes": "所有文件最大上传总大小(字节)",
"storageUsage": "存储空间使用情况",
"used": "已使用",
"usedSpace": "已使用空间",
"totalCapacity": "总容量",
"availableSpace": "剩余空间",
"storageFull": "存储空间即将用完",
"storageHigh": "存储空间使用较多",
"storageGood": "存储空间充足"
},
"Components": {
"Dashboard": "用户面板",
@@ -324,7 +352,21 @@
"Actived": "有效",
"Disabled": "已禁用",
"Expired": "已过期",
"PasswordProtected": "密码保护"
"PasswordProtected": "密码保护",
"Upload List": "上传列表",
"Uploading": "上传中",
"Completed": "已完成",
"Aborted": "已中止",
"Drop files to upload them to": "将文件上传到",
"Drag and drop file(s) here": "将文件拖到此处上传",
"or": "或",
"Browse file(s)": "浏览本地文件",
"Cloud Storage": "云存储",
"List and manage cloud storage": "上传和管理云存储文件",
"Cancel": "取消",
"Clear": "清空",
"Upload Files": "上传文件",
"Uploud channel": "渠道"
},
"Landing": {
"settings": "设置",
@@ -424,7 +466,9 @@
"Admin": "管理面板",
"Sign in": "登录",
"Log out": "退出登录",
"System Settings": "系统设置"
"System Settings": "系统设置",
"Cloud Storage": "云存储",
"Cloud Storage Manage": "云存储管理"
},
"Email": {
"Search emails": "搜索邮箱...",
@@ -554,6 +598,25 @@
"Email Suffix White List": "白名单",
"Set email suffix white list, split by comma, such as: gmail-com,yahoo-com,hotmail-com": "设置邮箱后缀白名单,多个后缀请用逗号分隔,例如:gmail.com,yahoo.com,hotmail.com",
"Application Status Email Notifications": "申请状态邮件通知",
"Send email notifications for subdomain application status updates; Notifies administrators when users submit applications and notifies users of approval results; Only available when subdomain application mode is enabled": "开启后,用户申请子域名时将邮件通知管理员审核,审核完成后邮件通知用户结果。此功能仅在子域申请模式开启时有效"
"Send email notifications for subdomain application status updates; Notifies administrators when users submit applications and notifies users of approval results; Only available when subdomain application mode is enabled": "开启后,用户申请子域名时将邮件通知管理员审核,审核完成后邮件通知用户结果。此功能仅在子域申请模式开启时有效",
"Cloud Storage Configs": "云存储配置",
"Verified": "已就绪",
"Verify Configuration": "验证配置",
"Clear": "清空",
"How to get the R2 credentials?": "如何获取 R2 授权配置?",
"Endpoint": "S3 端点",
"Access Key ID": "访问密钥 ID",
"Secret Access Key": "机密访问密钥",
"Enabled": "启用",
"Bucket": "存储桶",
"Bucket Name": "存储桶名称",
"Public Domain": "公开域名或自定义域名",
"Max File Size": "上传文件大小限制",
"Region": "存储桶区域",
"Prefix": "前缀",
"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": "公开此存储桶,所有注册用户都可以上传文件到此存储桶; 若不公开,只有管理员可以上传文件到此存储桶"
}
}
-5
View File
@@ -1,5 +0,0 @@
module.exports = {
siteUrl: "https://wr.do",
generateRobotsTxt: true, // (optional)
sitemapSize: 7000, // Number of URLs per sitemap file
};
-1
View File
@@ -122,5 +122,4 @@ const withPWA = nextPWA({
disable: false,
});
// module.exports = withContentlayer(withPWA(withNextIntl(nextConfig)));
export default withContentlayer(withPWA(withNextIntl(nextConfig)));
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "wr.do",
"version": "1.0.8",
"version": "1.1.0",
"author": {
"name": "oiov",
"url": "https://github.com/oiov"
@@ -8,7 +8,6 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"postbuild": "next-sitemap",
"turbo": "next dev --turbo",
"start": "next start",
"start-docker": "npm-run-all check-db start-server",
@@ -26,6 +25,8 @@
},
"dependencies": {
"@auth/prisma-adapter": "^2.4.1",
"@aws-sdk/client-s3": "^3.840.0",
"@aws-sdk/s3-request-presigner": "^3.840.0",
"@hookform/resolvers": "^3.9.0",
"@mantine/hooks": "^8.0.1",
"@prisma/client": "^5.17.0",
@@ -100,7 +101,6 @@
"next-contentlayer2": "^0.5.0",
"next-intl": "^4.1.0",
"next-pwa": "^5.6.0",
"next-sitemap": "^4.2.3",
"next-themes": "^0.3.0",
"next-view-transitions": "^0.3.0",
"nodemailer": "^6.9.14",
@@ -113,6 +113,7 @@
"react-countup": "^6.5.3",
"react-day-picker": "^8.10.1",
"react-dom": "18.3.1",
"react-dropzone": "^14.3.8",
"react-email": "2.1.5",
"react-globe.gl": "^2.33.2",
"react-hook-form": "^7.52.1",
+1259 -34
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,63 @@
-- CF R2 配置
INSERT INTO "system_configs"
(
"key",
"value",
"type",
"description"
)
VALUES
(
's3_config_01',
'{"enabled":true,"platform":"cloudflare","channel":"r2","provider_name":"Cloudflare R2","account_id":"","access_key_id":"","secret_access_key":"","endpoint":"https://<account_id>.r2.cloudflarestorage.com","buckets":[{"custom_domain":"","prefix":"","bucket":"","file_types":"","file_size":"26214400","region":"auto","public":true}]}',
'OBJECT',
'R2 存储桶配置'
);
-- AWS S3 配置
INSERT INTO "system_configs"
(
"key",
"value",
"type",
"description"
)
VALUES
(
's3_config_02',
'{"enabled":true,"platform":"aws","channel":"s3","provider_name":"Amazon S3","endpoint":"https://s3.<region>.amazonaws.com","account_id":"","access_key_id":"","secret_access_key":"","buckets":[{"custom_domain":"","prefix":"","bucket":"","file_types":"","file_size":"26214400","region":"us-east-1","public":true}]}',
'OBJECT',
'Amazon S3 存储桶配置'
);
-- 阿里云 OSS 配置
INSERT INTO "system_configs"
(
"key",
"value",
"type",
"description"
)
VALUES
(
's3_config_03',
'{"enabled":true,"platform":"ali","channel":"oss","provider_name":"阿里云 OSS","endpoint":"","account_id":"","access_key_id":"","secret_access_key":"","buckets":[{"custom_domain":"","prefix":"","bucket":"","file_types":"","file_size":"26214400","region":"","public":true}]}',
'OBJECT',
'阿里云 OSS 存储桶配置'
);
-- 腾讯云 COS 配置
INSERT INTO "system_configs"
(
"key",
"value",
"type",
"description"
)
VALUES
(
's3_config_04',
'{"enabled":true,"platform":"tencent","channel":"cos","provider_name":"腾讯云 COS","endpoint":"","account_id":"","access_key_id":"","secret_access_key":"","buckets":[{"custom_domain":"","prefix":"","bucket":"","file_types":"","file_size":"26214400","region":"","public":true}]}',
'OBJECT',
'腾讯云 COS 存储桶配置'
);
@@ -0,0 +1,35 @@
CREATE TABLE "user_files"
(
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"originalName" TEXT,
"mimeType" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"path" TEXT NOT NULL,
"etag" TEXT,
"storageClass" TEXT,
"channel" TEXT NOT NULL,
"platform" TEXT NOT NULL,
"providerName" TEXT NOT NULL,
"bucket" TEXT NOT NULL,
"shortUrlId" TEXT,
"status" INTEGER NOT NULL DEFAULT 1,
"lastModified" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "user_files_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "user_files_userId_providerName_status_lastModified_createdAt_idx"
ON "user_files"("userId", "providerName", "status", "lastModified", "createdAt");
ALTER TABLE "user_files" ADD CONSTRAINT "user_files_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- BigInt
ALTER TABLE "plans" ADD COLUMN "stMaxFileSize" TEXT NOT NULL DEFAULT '26214400';
ALTER TABLE "plans" ADD COLUMN "stMaxTotalSize" TEXT NOT NULL DEFAULT '524288000';
ALTER TABLE "plans" ADD COLUMN "stMaxFileCount" INTEGER NOT NULL DEFAULT 1000;
+36
View File
@@ -70,6 +70,7 @@ model User {
ScrapeMeta ScrapeMeta[]
UserEmail UserEmail[]
UserSendEmail UserSendEmail[]
UserFile UserFile[]
@@index([createdAt])
@@map(name: "users")
@@ -312,6 +313,11 @@ model Plan {
emDomains Int
emSendEmails Int
// Storage (ST) related settings
stMaxFileSize String @default("26214400")
stMaxTotalSize String @default("524288000")
stMaxFileCount Int @default(1000)
// App (APP) related settings
appSupport String // "BASIC", "LIVE"
appApiAccess Boolean
@@ -323,3 +329,33 @@ model Plan {
@@map("plans")
}
model UserFile {
id String @id @default(uuid())
userId String
shortUrlId String?
name String
originalName String?
mimeType String
size Int
path String
etag String?
storageClass String?
channel String
platform String
providerName String
bucket String
status Int @default(1) // 0 删除, 1 正常, 2 禁用
lastModified DateTime
createdAt DateTime @default(now())
updatedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, providerName, status, lastModified, createdAt])
@@map(name: "user_files")
}
+1 -1
View File
@@ -3,7 +3,7 @@
"short_name": "WR.DO",
"description": "Shorten links with analytics, manage emails and control subdomains—all on one platform.",
"appid": "com.wr.do",
"versionName": "1.0.8",
"versionName": "1.1.0",
"versionCode": "1",
"start_url": "/",
"orientation": "portrait",
-9
View File
@@ -1,9 +0,0 @@
# *
User-agent: *
Allow: /
# Host
Host: https://wr.do
# Sitemaps
Sitemap: https://wr.do/sitemap.xml
+1 -1
View File
@@ -3,7 +3,7 @@
"short_name": "WR.DO",
"description": "Shorten links with analytics, manage emails and control subdomains—all on one platform.",
"appid": "com.wr.do",
"versionName": "1.0.8",
"versionName": "1.1.0",
"versionCode": "1",
"start_url": "/",
"orientation": "portrait",
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
<url><loc>https://wr.do/manifest.json</loc><lastmod>2025-06-29T11:28:45.465Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/robots.txt</loc><lastmod>2025-06-29T11:28:45.465Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/opengraph-image.jpg</loc><lastmod>2025-06-29T11:28:45.465Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
</urlset>
-4
View File
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap><loc>https://wr.do/sitemap-0.xml</loc></sitemap>
</sitemapindex>
+1 -1
View File
File diff suppressed because one or more lines are too long