diff --git a/.github/workflows/docker-build-push.yml b/.github/workflows/docker-build-push.yml index 5a61bc1..e15dee4 100644 --- a/.github/workflows/docker-build-push.yml +++ b/.github/workflows/docker-build-push.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - s3/cloudflare-r2 tags: - "v*.*.*" pull_request: diff --git a/README-zh.md b/README-zh.md index cecc3bb..e9a439b 100644 --- a/README-zh.md +++ b/README-zh.md @@ -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 diff --git a/README.md b/README.md index ce21e62..a1c0a3d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/(protected)/admin/storage/loading.tsx b/app/(protected)/admin/storage/loading.tsx new file mode 100644 index 0000000..2123322 --- /dev/null +++ b/app/(protected)/admin/storage/loading.tsx @@ -0,0 +1,14 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { DashboardHeader } from "@/components/dashboard/header"; + +export default function DashboardRecordsLoading() { + return ( + <> + + + + ); +} diff --git a/app/(protected)/admin/storage/page.tsx b/app/(protected)/admin/storage/page.tsx new file mode 100644 index 0000000..29f6e0f --- /dev/null +++ b/app/(protected)/admin/storage/page.tsx @@ -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 ( + <> + + + + ); +} diff --git a/app/(protected)/admin/system/domain-list.tsx b/app/(protected)/admin/system/domain-list.tsx index b07612d..06ac3c4 100644 --- a/app/(protected)/admin/system/domain-list.tsx +++ b/app/(protected)/admin/system/domain-list.tsx @@ -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 ? ( - + ) : ( - + )} + )} + {index < r2Credentials.buckets.length - 1 && ( + + )} + + {index !== 0 && ( + + )} + + +
+ + { + const newBuckets = [...r2Credentials.buckets]; + newBuckets[index] = { + ...bucket, + bucket: e.target.value, + }; + setR2Credentials({ + ...r2Credentials, + buckets: newBuckets, + }); + }} + /> +
+
+ + { + const newBuckets = [...r2Credentials.buckets]; + newBuckets[index] = { + ...bucket, + custom_domain: e.target.value, + }; + setR2Credentials({ + ...r2Credentials, + buckets: newBuckets, + }); + }} + /> +
+
+ + { + const newBuckets = [...r2Credentials.buckets]; + newBuckets[index] = { + ...bucket, + region: e.target.value, + }; + setR2Credentials({ + ...r2Credentials, + buckets: newBuckets, + }); + }} + /> +
+
+ + { + const newBuckets = [...r2Credentials.buckets]; + newBuckets[index] = { + ...bucket, + prefix: e.target.value, + }; + setR2Credentials({ + ...r2Credentials, + buckets: newBuckets, + }); + }} + /> +
+
+
+ + + + + + + + {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", + )} + + + +
+ + setR2Credentials({ + ...r2Credentials, + buckets: r2Credentials.buckets.map((b, i) => { + if (i === index) { + return { + ...b, + public: e, + }; + } + return b; + }), + }) + } + /> +
+ {/*
+ + { + const newBuckets = [...r2Credentials.buckets]; + newBuckets[index] = { + ...bucket, + file_types: e.target.value, + }; + setR2Credentials({ + ...r2Credentials, + buckets: newBuckets, + }); + }} + /> +
*/} + + ))} +
+ + {t("How to get the R2 credentials?")} + + + +
+ + + + + + ); +} diff --git a/app/(protected)/admin/users/user-list.tsx b/app/(protected)/admin/users/user-list.tsx index 269103c..eedb749 100644 --- a/app/(protected)/admin/users/user-list.tsx +++ b/app/(protected)/admin/users/user-list.tsx @@ -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 ? ( - + ) : ( - + )} diff --git a/app/(protected)/dashboard/storage/loading.tsx b/app/(protected)/dashboard/storage/loading.tsx new file mode 100644 index 0000000..2123322 --- /dev/null +++ b/app/(protected)/dashboard/storage/loading.tsx @@ -0,0 +1,14 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { DashboardHeader } from "@/components/dashboard/header"; + +export default function DashboardRecordsLoading() { + return ( + <> + + + + ); +} diff --git a/app/(protected)/dashboard/storage/page.tsx b/app/(protected)/dashboard/storage/page.tsx new file mode 100644 index 0000000..4d73b74 --- /dev/null +++ b/app/(protected)/dashboard/storage/page.tsx @@ -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 ( + <> + + + + ); +} diff --git a/app/(protected)/dashboard/urls/live-logs.tsx b/app/(protected)/dashboard/urls/live-logs.tsx index 49cb334..4145ba5 100644 --- a/app/(protected)/dashboard/urls/live-logs.tsx +++ b/app/(protected)/dashboard/urls/live-logs.tsx @@ -180,9 +180,9 @@ export default function LiveLog({ admin = false }: { admin?: boolean }) { disabled={!isLive} > {isLoading ? ( - + ) : ( - + )} {action.indexOf("admin") === -1 && ( diff --git a/app/api/admin/plan/route.ts b/app/api/admin/plan/route.ts index 404526f..faf99b7 100644 --- a/app/api/admin/plan/route.ts +++ b/app/api/admin/plan/route.ts @@ -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, diff --git a/app/api/admin/s3/route.ts b/app/api/admin/s3/route.ts new file mode 100644 index 0000000..92b0f71 --- /dev/null +++ b/app/api/admin/s3/route.ts @@ -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 }); + } +} diff --git a/app/api/record/admin/route.ts b/app/api/record/admin/route.ts index 082ddac..e0058f8 100644 --- a/app/api/record/admin/route.ts +++ b/app/api/record/admin/route.ts @@ -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, }); diff --git a/app/api/storage/admin/r2/configs/route.ts b/app/api/storage/admin/r2/configs/route.ts new file mode 100644 index 0000000..fa11c71 --- /dev/null +++ b/app/api/storage/admin/r2/configs/route.ts @@ -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 }); + } +} diff --git a/app/api/storage/admin/r2/files/route.ts b/app/api/storage/admin/r2/files/route.ts new file mode 100644 index 0000000..429c55b --- /dev/null +++ b/app/api/storage/admin/r2/files/route.ts @@ -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 }); + } +} diff --git a/app/api/storage/r2/configs/route.ts b/app/api/storage/r2/configs/route.ts new file mode 100644 index 0000000..aef9bbd --- /dev/null +++ b/app/api/storage/r2/configs/route.ts @@ -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 }); + } +} diff --git a/app/api/storage/r2/files/route.ts b/app/api/storage/r2/files/route.ts new file mode 100644 index 0000000..81b5f64 --- /dev/null +++ b/app/api/storage/r2/files/route.ts @@ -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 }); + } +} diff --git a/app/api/storage/r2/short/route.ts b/app/api/storage/r2/short/route.ts new file mode 100644 index 0000000..2ed825d --- /dev/null +++ b/app/api/storage/r2/short/route.ts @@ -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 }, + ); + } +} diff --git a/app/api/storage/r2/uploads/route.ts b/app/api/storage/r2/uploads/route.ts new file mode 100644 index 0000000..cc08985 --- /dev/null +++ b/app/api/storage/r2/uploads/route.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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, + }); + } +} diff --git a/app/layout.tsx b/app/layout.tsx index a8f514e..5e3e093 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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; } diff --git a/app/manifest.json b/app/manifest.json index bee3ead..8aed41e 100644 --- a/app/manifest.json +++ b/app/manifest.json @@ -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", diff --git a/app/robots.ts b/app/robots.ts index f86f167..0a0a7f6 100644 --- a/app/robots.ts +++ b/app/robots.ts @@ -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", + }; } diff --git a/app/sitemap.ts b/app/sitemap.ts new file mode 100644 index 0000000..552f0eb --- /dev/null +++ b/app/sitemap.ts @@ -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 { + 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, + ]; +} diff --git a/components/email/SendEmailModal.tsx b/components/email/SendEmailModal.tsx index 831251e..ab0fdc4 100644 --- a/components/email/SendEmailModal.tsx +++ b/components/email/SendEmailModal.tsx @@ -94,7 +94,7 @@ export function SendEmailModal({ )} - + diff --git a/components/file/drag-and-drop.tsx b/components/file/drag-and-drop.tsx new file mode 100644 index 0000000..e0d42d9 --- /dev/null +++ b/components/file/drag-and-drop.tsx @@ -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>; + bucketInfo: BucketInfo; +}) => { + const t = useTranslations("Components"); + + const onDrop = useCallback( + (acceptedFiles: File[]) => { + setSelectedFile((prev) => [...(prev ?? []), ...acceptedFiles]); + }, + [setSelectedFile], + ); + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop }); + + return ( +
+ +
+
+ +
+ {isDragActive ? ( +
+ {t("Drop files to upload them to")} {bucketInfo.bucket} +
+ ) : ( +
+

{t("Drag and drop file(s) here")}

+

{t("or")}

+ +
+ )} +
+
+ ); +}; +export default DragAndDrop; diff --git a/components/file/file-list.tsx b/components/file/file-list.tsx new file mode 100644 index 0000000..508ab65 --- /dev/null +++ b/components/file/file-list.tsx @@ -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; + 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(null); + const [shortLinks, setShortLinks] = useState([]); + const [isShowQrcode, setShowQrcode] = useState(false); + const [currentSelectFile, setCurrentSelectFile] = + useState(); + + 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 ( + + + {t("No Files")} + + {t("You don't upload any files yet")} + + + ); + } + + const renderFileLinks = (file: UserFileData, index: number) => ( + <> + {!isAdmin && file.shortUrlId && ( +
+ + + https://{shortLinks[index]} + + +
+ )} +
+ + + {getFileUrl(file.path)} + + +
+
+ +

+ {`${file.name}${getFileUrl(file.path)}`} +

+ ${getFileUrl(file.path)}`} + /> +
+
+ +

+ {`[${file.name}](${getFileUrl(file.path)})`} +

+ +
+ + ); + + const renderListView = () => ( +
+
+ {showMutiCheckBox && ( +
+ onSelectAll()} + /> +
+ )} +
+ {t("Name")} +
+
{t("Type")}
+
{t("Size")}
+
{t("User")}
+
{t("Date")}
+
{t("Active")}
+
{t("Actions")}
+
+ {isLoading ? ( + <> + + + + + + + ) : ( +
+ {files?.list.map((file, index) => ( +
+ {showMutiCheckBox && ( +
e.stopPropagation()} + > + 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" + /> +
+ )} +
+ + + + {truncateMiddle(file.path)} + {file.status === 1 && ( + + )} + + + {file.mimeType.startsWith("image/") && + file.status === 1 && ( + {`${file.path}`} + )} + {renderFileLinks(file, index)} + + + +
+
+ + {file.mimeType || "-"} + +
+
+ {formatFileSize(file.size || 0)} +
+
+ + + + {file.user.name ?? file.user.email} + + +

{file.user.name}

+

{file.user.email}

+
+
+
+
+
+ +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ ))} +
+ )} +
+ ); + + const renderGridView = () => ( +
+ {isLoading && + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => ( + + ))} + {files?.list.map((file, index) => ( +
f.id === file.id) !== undefined && + "bg-blue-50", + )} + onClick={() => handleSelectFile(file)} + > +
+ {showMutiCheckBox && ( + 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 })} +
+ + + + {truncateMiddle(file.path || "")} + + + {file.mimeType.startsWith("image/") && + file.status === 1 && ( + {`${file.path}`} + )} +

+ {file.path} +

+

+ Size: {formatFileSize(file.size || 0)} +

+

+ Type: {file.mimeType || "-"} +

+

+ User: {file.user.name || file.user.email} +

+

+ Modified:{" "} + {formatDate(file.lastModified?.toString() || "")} +

+ {renderFileLinks(file, index)} +
+ + + + {file.status === 1 && ( + + )} +
+
+
+
+
+
+
+ ))} +
+ ); + + return ( + <> + {view === "List" ? renderListView() : renderGridView()} + {files && Math.ceil(files.total / pageSize) > 1 && ( + + )} + + + + + + + {currentSelectFile && ( + + )} + + + ); +} + +function TableColumnSekleton() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +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 ; + } + return ; + } + + if (mimeType.startsWith("image/")) { + if (mimeType === "image/svg+xml") { + return ; + } + if (status === 1) { + return ( + {filename} + ); + } else { + return ; + } + } + + // 压缩文件 + 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 ; + } + + // Microsoft Office 文档 + if ( + mimeType === + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" || + mimeType === "application/msword" + ) { + return ; + } + + // Microsoft Office 演示文稿 + if ( + mimeType === + "application/vnd.openxmlformats-officedocument.presentationml.presentation" || + mimeType === "application/vnd.ms-powerpoint" + ) { + return ; + } + + // Microsoft Office 电子表格 + if ( + mimeType === + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" || + mimeType === "application/vnd.ms-excel" || + mimeType === "text/csv" + ) { + return ; + } + + // JSON 文件 + if (mimeType === "application/json") { + return ; + } + + // Markdown 文件 + if (mimeType === "text/markdown" || mimeType === "text/x-markdown") { + return ; + } + + // 代码文件 + if ( + mimeType.startsWith("text/") || + mimeType === "application/javascript" || + mimeType === "application/typescript" || + mimeType === "application/x-javascript" || + mimeType === "text/javascript" || + mimeType === "text/typescript" + ) { + return ; + } + + // PDF 文件 + if (mimeType === "application/pdf") { + return ; + } + + // 音频文件 + if (mimeType.startsWith("audio/")) { + return ; + } + + // 视频文件 + if (mimeType.startsWith("video/")) { + return ; + } + + // 默认文件图标 + return ; +}; diff --git a/components/file/index.tsx b/components/file/index.tsx new file mode 100644 index 0000000..c0035af --- /dev/null +++ b/components/file/index.tsx @@ -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; + 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("List"); + const [showMutiCheckBox, setShowMutiCheckBox] = useState(false); + const [bucketInfo, setBucketInfo] = useState({ + bucket: "", + custom_domain: "", + prefix: "", + platform: "", + channel: "", + provider_name: "", + public: true, + }); + + const [selectedFiles, setSelectedFiles] = useState([]); + const [isDeleting, startDeleteTransition] = useTransition(); + + const isAdmin = action.includes("/admin"); + + const { mutate } = useSWRConfig(); + + const { data: r2Configs, isLoading } = useSWR( + `${action}/r2/configs`, + fetcher, + { revalidateOnFocus: false }, + ); + + const { data: files, isLoading: isLoadingFiles } = useSWR( + bucketInfo.bucket + ? `${action}/r2/files?bucket=${bucketInfo.bucket}&page=${currentPage}&size=${pageSize}` + : null, + fetcher, + { + revalidateOnFocus: false, + dedupingInterval: 5000, // 防抖 + }, + ); + + const { data: plan } = useSWR( + `/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 ( +
+ +
+ + setDisplayType("List")}> + + + setDisplayType("Grid")}> + + + + + {files && files.totalSize > 0 && plan && ( + + + + + + + + + + + )} + + {isLoading ? ( + + ) : ( + + )} + + {!isAdmin && ( + + )} + +
+ + + + + + + + + + + + + + + +
+ + +
+ + {isLoading && ( +
+
+ +
+
+ + {t("Loading storage buckets")}... +
+
+ )} + + {!isLoading && !r2Configs?.buckets?.length && ( + + + + {t("No buckets found")} + + + {t( + "The administrator has not configured the storage bucket, no file can be uploaded", + )} + + + )} + + {!isLoading && r2Configs?.buckets && r2Configs.buckets.length > 0 && ( + + )} +
+
+ ); +} diff --git a/components/file/storage-size.tsx b/components/file/storage-size.tsx new file mode 100644 index 0000000..e7371e4 --- /dev/null +++ b/components/file/storage-size.tsx @@ -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 ; + if (percentage >= 70) return ; + return ; + }; + + return ( +
+ {/* 标题 */} +
+ +

+ {t("storageUsage")} +

+
+ + {/* 进度条 */} +
+
+ + {t("used")} + + + {usagePercentage.toFixed(1)}% + +
+
+
+
+
+ + {/* 详细信息 */} +
+
+ + {t("usedSpace")}: + + + {formatFileSize(totalSize, { precision: 0 })} + +
+
+ + {t("totalCapacity")}: + + + {formatFileSize(maxSize, { precision: 0 })} + +
+
+ + {t("availableSpace")}: + + + {formatFileSize(maxSize - totalSize, { precision: 0 })} + +
+
+ + {/* 状态提示 */} +
+ {getStatusIcon(usagePercentage)} + + {usagePercentage >= 90 + ? t("storageFull") + : usagePercentage >= 70 + ? t("storageHigh") + : t("storageGood")} + +
+
+ ); +} + +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 ( +
+ + {/* 背景圆圈 */} + + {/* 进度圆圈 */} + + +
+ {Math.round(usagePercentage)}% +
+
+ ); +} diff --git a/components/file/upload-pending.tsx b/components/file/upload-pending.tsx new file mode 100644 index 0000000..0dc6958 --- /dev/null +++ b/components/file/upload-pending.tsx @@ -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 ( +
+ {progressList && ( +
+

{t("Upload List")}

+ + {progressList.filter((i) => i.progress === 100).length} + / + {progressList.length} + +
+ )} + {pendingUpload && ( +

+ Do not close the window until the upload is complete +

+ )} + {pendingUpload && + pendingUpload.map((item) => { + const progress = + progressList?.find((p) => p.id === item.uploadId)?.progress || 0; + return ( +
+ {/* 主进度条背景 */} + {item.status === "uploading" && ( +
+
+
+ )} + + {/* 内容区域 */} +
+ {/* 头部信息 */} +
+
+

+ {item.fileName} +

+

+ {formatFileSize(item.size)} +

+
+ + {/* 状态指示器 */} +
+ {item.status === "uploading" ? ( +
+ {progress}% +
+
+ {t("Uploading")} +
+ +
+ ) : item.status === "completed" ? ( +
+
+
+ {t("Completed")} +
+ +
+ ) : ( + item.status === "aborted" && ( +
+
+
+ {t("Aborted")} +
+
+ ) + )} +
+
+ + {/* 进度条 */} + {item.status === "uploading" && ( +
+
+ {/* 进度填充 */} +
+ {/* 动态光泽效果 */} +
0 && progress < 100 ? 1 : 0, + }} + /> +
+
+ )} +
+
+ ); + })} +
+ ); +}; + +export default UploadPending; diff --git a/components/file/uploader.tsx b/components/file/uploader.tsx new file mode 100644 index 0000000..31ce789 --- /dev/null +++ b/components/file/uploader.tsx @@ -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(null); + const [pendingUpload, setPendingUpload] = useState< + UploadPendingItemType[] | null + >(null); + const [progressList, setProgressList] = useState(); + + // 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 => { + 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 && ( + + )} + {isOpen && ( + + + + + {t("Upload Files")} + + + + + + +
+
{bucketInfo.provider_name}
+ +
+ {bucketInfo.bucket} +
+
+

+ Max:{" "} + {formatFileSize(Number(plan?.stMaxFileSize || "0"), { + precision: 0, + })} +

+
+ +
+ + +
+ + + + + + + +
+
+ )} + + ); +} diff --git a/components/forms/domain-form.tsx b/components/forms/domain-form.tsx index 1123cd4..d3314c3 100644 --- a/components/forms/domain-form.tsx +++ b/components/forms/domain-form.tsx @@ -399,7 +399,7 @@ export function DomainForm({
- {t("How to get api token?")} + {t("How to get api key?")}

)} diff --git a/components/forms/plan-form.tsx b/components/forms/plan-form.tsx index d3c8da9..589dabb 100644 --- a/components/forms/plan-form.tsx +++ b/components/forms/plan-form.tsx @@ -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 })} />
@@ -398,6 +407,81 @@ export function PlanForm({
+
+

+ {t("Storage Service")} +

+ {/* Max File Size - stMaxFileSize */} + +
+ +
+ setCurrentStMaxFileSize(e.target.value)} + /> + + = + {formatFileSize(Number(currentStMaxFileSize), { + precision: 0, + })} + +
+
+
+ {errors?.stMaxFileSize ? ( +

+ {errors.stMaxFileSize.message} +

+ ) : ( +

+ {t("Maximum uploaded single file size in bytes")}. +

+ )} +
+
+ {/* Max File Size - stMaxTotalSize */} + +
+ +
+ setCurrentStMaxTotalSize(e.target.value)} + /> + + = + {formatFileSize(Number(currentStMaxTotalSize), { + precision: 0, + })} + +
+
+
+ {errors?.stMaxTotalSize ? ( +

+ {errors.stMaxTotalSize.message} +

+ ) : ( +

+ {t("Maximum uploaded total file size in bytes")}. +

+ )} +
+
+
+ {/* Action buttons */}
{type === "edit" && initData?.name !== "free" && ( diff --git a/components/forms/url-form.tsx b/components/forms/url-form.tsx index 57fb71e..a655f39 100644 --- a/components/forms/url-form.tsx +++ b/components/forms/url-form.tsx @@ -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); } }); }; diff --git a/components/layout/notification.tsx b/components/layout/notification.tsx index a06f125..28f87a1 100644 --- a/components/layout/notification.tsx +++ b/components/layout/notification.tsx @@ -15,6 +15,7 @@ export function Notification() { const { data, isLoading, error } = useSWR>( "/api/configs?key=system_notification", fetcher, + { dedupingInterval: 30000 }, ); const handleClose = () => { diff --git a/components/shared/icons.tsx b/components/shared/icons.tsx index 5b54b56..d9652a3 100644 --- a/components/shared/icons.tsx +++ b/components/shared/icons.tsx @@ -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) => ( + + ), + storage: ({ ...props }: LucideProps) => ( + + ), eye: Eye, lock: LockKeyhole, list: List, @@ -330,6 +380,7 @@ export const Icons = { ), link: Link, + unLink: Unlink, mail: Mail, mailPlus: MailPlus, mailOpen: MailOpen, diff --git a/components/shared/qr.tsx b/components/shared/qr.tsx index 629a71c..0bd3043 100644 --- a/components/shared/qr.tsx +++ b/components/shared/qr.tsx @@ -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"; diff --git a/config/dashboard.ts b/config/dashboard.ts index c0db354..b75c859 100644 --- a/config/dashboard.ts +++ b/config/dashboard.ts @@ -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", diff --git a/config/docs.ts b/config/docs.ts index ec73f40..f1e9963 100644 --- a/config/docs.ts +++ b/config/docs.ts @@ -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", diff --git a/content/docs/developer/cloudflare.mdx b/content/docs/developer/cloudflare.mdx index dbbc9f2..7cb34c5 100644 --- a/content/docs/developer/cloudflare.mdx +++ b/content/docs/developer/cloudflare.mdx @@ -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 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. --- @@ -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 diff --git a/content/docs/developer/installation-zh.mdx b/content/docs/developer/installation-zh.mdx index 5794957..7b4f430 100644 --- a/content/docs/developer/installation-zh.mdx +++ b/content/docs/developer/installation-zh.mdx @@ -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 (视频教程) diff --git a/content/docs/developer/installation.mdx b/content/docs/developer/installation.mdx index a9c0f96..d03fa1e 100644 --- a/content/docs/developer/installation.mdx +++ b/content/docs/developer/installation.mdx @@ -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) diff --git a/content/docs/developer/s3.mdx b/content/docs/developer/s3.mdx new file mode 100644 index 0000000..3b08cc4 --- /dev/null +++ b/content/docs/developer/s3.mdx @@ -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 \ No newline at end of file diff --git a/content/docs/index.mdx b/content/docs/index.mdx index f013c2a..47bc1fa 100644 --- a/content/docs/index.mdx +++ b/content/docs/index.mdx @@ -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 diff --git a/lib/dto/files.ts b/lib/dto/files.ts new file mode 100644 index 0000000..c541394 --- /dev/null +++ b/lib/dto/files.ts @@ -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" }; + } +} diff --git a/lib/dto/plan.ts b/lib/dto/plan.ts index 3364644..5bd5edb 100644 --- a/lib/dto/plan.ts +++ b/lib/dto/plan.ts @@ -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, diff --git a/lib/dto/short-urls.ts b/lib/dto/short-urls.ts index 5d10394..b48fb9e 100644 --- a/lib/dto/short-urls.ts +++ b/lib/dto/short-urls.ts @@ -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, diff --git a/lib/dto/system-config.ts b/lib/dto/system-config.ts index b658bc0..470bc1a 100644 --- a/lib/dto/system-config.ts +++ b/lib/dto/system-config.ts @@ -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": diff --git a/lib/dto/user.ts b/lib/dto/user.ts index b13855d..e82214f 100644 --- a/lib/dto/user.ts +++ b/lib/dto/user.ts @@ -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) { diff --git a/lib/r2.ts b/lib/r2.ts new file mode 100644 index 0000000..3aa3b98 --- /dev/null +++ b/lib/r2.ts @@ -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 { + 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 { + 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 { + 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; + } +} diff --git a/lib/team.ts b/lib/team.ts index 0157e64..aac4f67 100644 --- a/lib/team.ts +++ b/lib/team.ts @@ -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; diff --git a/lib/utils.ts b/lib/utils.ts index 596a99f..82ec090 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -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 { @@ -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}`; +} diff --git a/lib/validations/plan.ts b/lib/validations/plan.ts index d2c3551..57ec258 100644 --- a/lib/validations/plan.ts +++ b/lib/validations/plan.ts @@ -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), diff --git a/locales/en.json b/locales/en.json index d1e7cc5..4f4614f 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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" } } diff --git a/locales/zh.json b/locales/zh.json index d1e11df..1729cdd 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -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": "公开此存储桶,所有注册用户都可以上传文件到此存储桶; 若不公开,只有管理员可以上传文件到此存储桶" } } diff --git a/next-sitemap.config.js b/next-sitemap.config.js deleted file mode 100644 index e663f7b..0000000 --- a/next-sitemap.config.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = { - siteUrl: "https://wr.do", - generateRobotsTxt: true, // (optional) - sitemapSize: 7000, // Number of URLs per sitemap file -}; diff --git a/next.config.mjs b/next.config.mjs index e6707ff..d5ef255 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -122,5 +122,4 @@ const withPWA = nextPWA({ disable: false, }); -// module.exports = withContentlayer(withPWA(withNextIntl(nextConfig))); export default withContentlayer(withPWA(withNextIntl(nextConfig))); diff --git a/package.json b/package.json index a89d88d..2aa594f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c31cdc6..bc386d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,12 @@ importers: '@auth/prisma-adapter': specifier: ^2.4.1 version: 2.4.1(@prisma/client@5.17.0(prisma@5.17.0))(nodemailer@6.9.14) + '@aws-sdk/client-s3': + specifier: ^3.840.0 + version: 3.840.0 + '@aws-sdk/s3-request-presigner': + specifier: ^3.840.0 + version: 3.840.0 '@hookform/resolvers': specifier: ^3.9.0 version: 3.9.0(react-hook-form@7.52.1(react@18.3.1)) @@ -154,7 +160,7 @@ importers: version: 1.3.1(next@14.2.28(@babel/core@7.24.5)(@opentelemetry/api@1.8.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) '@vercel/functions': specifier: ^1.4.0 - version: 1.4.0 + version: 1.4.0(@aws-sdk/credential-provider-web-identity@3.840.0) '@vercel/og': specifier: ^0.6.2 version: 0.6.2 @@ -233,9 +239,6 @@ importers: next-pwa: specifier: ^5.6.0 version: 5.6.0(@babel/core@7.24.5)(esbuild@0.19.11)(next@14.2.28(@babel/core@7.24.5)(@opentelemetry/api@1.8.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(webpack@5.90.3(@swc/core@1.3.101(@swc/helpers@0.5.5))(esbuild@0.19.11)) - next-sitemap: - specifier: ^4.2.3 - version: 4.2.3(next@14.2.28(@babel/core@7.24.5)(@opentelemetry/api@1.8.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) next-themes: specifier: ^0.3.0 version: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -272,6 +275,9 @@ importers: react-dom: specifier: 18.3.1 version: 18.3.1(react@18.3.1) + react-dropzone: + specifier: ^14.3.8 + version: 14.3.8(react@18.3.1) react-email: specifier: 2.1.5 version: 2.1.5(@opentelemetry/api@1.8.0)(@swc/helpers@0.5.5)(eslint@8.57.0) @@ -492,6 +498,165 @@ packages: peerDependencies: '@prisma/client': '>=2.26.0 || >=3 || >=4 || >=5' + '@aws-crypto/crc32@5.2.0': + resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/crc32c@5.2.0': + resolution: {integrity: sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==} + + '@aws-crypto/sha1-browser@5.2.0': + resolution: {integrity: sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==} + + '@aws-crypto/sha256-browser@5.2.0': + resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + + '@aws-sdk/client-s3@3.840.0': + resolution: {integrity: sha512-dRuo03EqGBbl9+PTogpwY9bYmGWIjn8nB82HN5Qj20otgjUvhLOdEkkip9mroYsrvqNoKbMedWdCudIcB/YY1w==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/client-sso@3.840.0': + resolution: {integrity: sha512-3Zp+FWN2hhmKdpS0Ragi5V2ZPsZNScE3jlbgoJjzjI/roHZqO+e3/+XFN4TlM0DsPKYJNp+1TAjmhxN6rOnfYA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/core@3.840.0': + resolution: {integrity: sha512-x3Zgb39tF1h2XpU+yA4OAAQlW6LVEfXNlSedSYJ7HGKXqA/E9h3rWQVpYfhXXVVsLdYXdNw5KBUkoAoruoZSZA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-env@3.840.0': + resolution: {integrity: sha512-EzF6VcJK7XvQ/G15AVEfJzN2mNXU8fcVpXo4bRyr1S6t2q5zx6UPH/XjDbn18xyUmOq01t+r8gG+TmHEVo18fA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-http@3.840.0': + resolution: {integrity: sha512-wbnUiPGLVea6mXbUh04fu+VJmGkQvmToPeTYdHE8eRZq3NRDi3t3WltT+jArLBKD/4NppRpMjf2ju4coMCz91g==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-ini@3.840.0': + resolution: {integrity: sha512-7F290BsWydShHb+7InXd+IjJc3mlEIm9I0R57F/Pjl1xZB69MdkhVGCnuETWoBt4g53ktJd6NEjzm/iAhFXFmw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-node@3.840.0': + resolution: {integrity: sha512-KufP8JnxA31wxklLm63evUPSFApGcH8X86z3mv9SRbpCm5ycgWIGVCTXpTOdgq6rPZrwT9pftzv2/b4mV/9clg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-process@3.840.0': + resolution: {integrity: sha512-HkDQWHy8tCI4A0Ps2NVtuVYMv9cB4y/IuD/TdOsqeRIAT12h8jDb98BwQPNLAImAOwOWzZJ8Cu0xtSpX7CQhMw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-sso@3.840.0': + resolution: {integrity: sha512-2qgdtdd6R0Z1y0KL8gzzwFUGmhBHSUx4zy85L2XV1CXhpRNwV71SVWJqLDVV5RVWVf9mg50Pm3AWrUC0xb0pcA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.840.0': + resolution: {integrity: sha512-dpEeVXG8uNZSmVXReE4WP0lwoioX2gstk4RnUgrdUE3YaPq8A+hJiVAyc3h+cjDeIqfbsQbZm9qFetKC2LF9dQ==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-bucket-endpoint@3.840.0': + resolution: {integrity: sha512-+gkQNtPwcSMmlwBHFd4saVVS11In6ID1HczNzpM3MXKXRBfSlbZJbCt6wN//AZ8HMklZEik4tcEOG0qa9UY8SQ==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-expect-continue@3.840.0': + resolution: {integrity: sha512-iJg2r6FKsKKvdiU4oCOuCf7Ro/YE0Q2BT/QyEZN3/Rt8Nr4SAZiQOlcBXOCpGvuIKOEAhvDOUnW3aDHL01PdVw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-flexible-checksums@3.840.0': + resolution: {integrity: sha512-Kg/o2G6o72sdoRH0J+avdcf668gM1bp6O4VeEXpXwUj/urQnV5qiB2q1EYT110INHUKWOLXPND3sQAqh6sTqHw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-host-header@3.840.0': + resolution: {integrity: sha512-ub+hXJAbAje94+Ya6c6eL7sYujoE8D4Bumu1NUI8TXjUhVVn0HzVWQjpRLshdLsUp1AW7XyeJaxyajRaJQ8+Xg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-location-constraint@3.840.0': + resolution: {integrity: sha512-KVLD0u0YMF3aQkVF8bdyHAGWSUY6N1Du89htTLgqCcIhSxxAJ9qifrosVZ9jkAzqRW99hcufyt2LylcVU2yoKQ==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-logger@3.840.0': + resolution: {integrity: sha512-lSV8FvjpdllpGaRspywss4CtXV8M7NNNH+2/j86vMH+YCOZ6fu2T/TyFd/tHwZ92vDfHctWkRbQxg0bagqwovA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-recursion-detection@3.840.0': + resolution: {integrity: sha512-Gu7lGDyfddyhIkj1Z1JtrY5NHb5+x/CRiB87GjaSrKxkDaydtX2CU977JIABtt69l9wLbcGDIQ+W0uJ5xPof7g==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.840.0': + resolution: {integrity: sha512-rOUji7CayWN3O09zvvgLzDVQe0HiJdZkxoTS6vzOS3WbbdT7joGdVtAJHtn+x776QT3hHzbKU5gnfhel0o6gQA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-ssec@3.840.0': + resolution: {integrity: sha512-CBZP9t1QbjDFGOrtnUEHL1oAvmnCUUm7p0aPNbIdSzNtH42TNKjPRN3TuEIJDGjkrqpL3MXyDSmNayDcw/XW7Q==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-user-agent@3.840.0': + resolution: {integrity: sha512-hiiMf7BP5ZkAFAvWRcK67Mw/g55ar7OCrvrynC92hunx/xhMkrgSLM0EXIZ1oTn3uql9kH/qqGF0nqsK6K555A==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/nested-clients@3.840.0': + resolution: {integrity: sha512-LXYYo9+n4hRqnRSIMXLBb+BLz+cEmjMtTudwK1BF6Bn2RfdDv29KuyeDRrPCS3TwKl7ZKmXUmE9n5UuHAPfBpA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/region-config-resolver@3.840.0': + resolution: {integrity: sha512-Qjnxd/yDv9KpIMWr90ZDPtRj0v75AqGC92Lm9+oHXZ8p1MjG5JE2CW0HL8JRgK9iKzgKBL7pPQRXI8FkvEVfrA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/s3-request-presigner@3.840.0': + resolution: {integrity: sha512-1jcrhVoSZjiAQJGNswI0RGR36/+OG6yTV42wQamHdNHk+/68dn9MGTUVr+58AEFOyEAPE/EvkiYRD6n5WkUjMg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.840.0': + resolution: {integrity: sha512-8AoVgHrkSfhvGPtwx23hIUO4MmMnux2pjnso1lrLZGqxfElM6jm2w4jTNLlNXk8uKHGyX89HaAIuT0lL6dJj9g==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/token-providers@3.840.0': + resolution: {integrity: sha512-6BuTOLTXvmgwjK7ve7aTg9JaWFdM5UoMolLVPMyh3wTv9Ufalh8oklxYHUBIxsKkBGO2WiHXytveuxH6tAgTYg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/types@3.840.0': + resolution: {integrity: sha512-xliuHaUFZxEx1NSXeLLZ9Dyu6+EJVQKEoD+yM+zqUo3YDZ7medKJWY6fIOKiPX/N7XbLdBYwajb15Q7IL8KkeA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/util-arn-parser@3.804.0': + resolution: {integrity: sha512-wmBJqn1DRXnZu3b4EkE6CWnoWMo1ZMvlfkqU5zPz67xx1GMaXlDCchFvKAXMjk4jn/L1O3tKnoFDNsoLV1kgNQ==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/util-endpoints@3.840.0': + resolution: {integrity: sha512-eqE9ROdg/Kk0rj3poutyRCFauPDXIf/WSvCqFiRDDVi6QOnCv/M0g2XW8/jSvkJlOyaXkNCptapIp6BeeFFGYw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/util-format-url@3.840.0': + resolution: {integrity: sha512-VB1PWyI1TQPiPvg4w7tgUGGQER1xxXPNUqfh3baxUSFi1Oh8wHrDnFywkxLm3NMmgDmnLnSZ5Q326qAoyqKLSg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/util-locate-window@3.804.0': + resolution: {integrity: sha512-zVoRfpmBVPodYlnMjgVjfGoEZagyRF5IPn3Uo6ZvOZp24chnW/FRstH7ESDHDDRga4z3V+ElUQHKpFDXWyBW5A==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/util-user-agent-browser@3.840.0': + resolution: {integrity: sha512-JdyZM3EhhL4PqwFpttZu1afDpPJCCc3eyZOLi+srpX11LsGj6sThf47TYQN75HT1CarZ7cCdQHGzP2uy3/xHfQ==} + + '@aws-sdk/util-user-agent-node@3.840.0': + resolution: {integrity: sha512-Fy5JUEDQU1tPm2Yw/YqRYYc27W5+QD/J4mYvQvdWjUGZLB5q3eLFMGD35Uc28ZFoGMufPr4OCxK/bRfWROBRHQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + + '@aws-sdk/xml-builder@3.821.0': + resolution: {integrity: sha512-DIIotRnefVL6DiaHtO6/21DhJ4JZnnIwdNbpwiAhdt/AVbttcE4yw925gsjur0OGv5BTYXQXU3YnANBYnZjuQA==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.24.6': resolution: {integrity: sha512-ZJhac6FkEd1yhG2AHOmfcXG4ceoLltoCVJjN5XsWN9BifBQr+cHJbWi0h68HZuSORq+3WtJ2z0hwF2NG1b5kcA==} engines: {node: '>=6.9.0'} @@ -1177,9 +1342,6 @@ packages: '@effect-ts/otel-node': optional: true - '@corex/deepmerge@4.0.43': - resolution: {integrity: sha512-N8uEMrMPL0cu/bdboEWpQYb/0i2K5Qn8eCsxzOmxSggJbbQte7ljMRoXm917AbntqTGOzdTu+vP3KOOzoC70HQ==} - '@dimforge/rapier3d-compat@0.12.0': resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} @@ -3012,6 +3174,218 @@ packages: engines: {node: '>= 8.0.0'} hasBin: true + '@smithy/abort-controller@4.0.4': + resolution: {integrity: sha512-gJnEjZMvigPDQWHrW3oPrFhQtkrgqBkyjj3pCIdF3A5M6vsZODG93KNlfJprv6bp4245bdT32fsHK4kkH3KYDA==} + engines: {node: '>=18.0.0'} + + '@smithy/chunked-blob-reader-native@4.0.0': + resolution: {integrity: sha512-R9wM2yPmfEMsUmlMlIgSzOyICs0x9uu7UTHoccMyt7BWw8shcGM8HqB355+BZCPBcySvbTYMs62EgEQkNxz2ig==} + engines: {node: '>=18.0.0'} + + '@smithy/chunked-blob-reader@5.0.0': + resolution: {integrity: sha512-+sKqDBQqb036hh4NPaUiEkYFkTUGYzRsn3EuFhyfQfMy6oGHEUJDurLP9Ufb5dasr/XiAmPNMr6wa9afjQB+Gw==} + engines: {node: '>=18.0.0'} + + '@smithy/config-resolver@4.1.4': + resolution: {integrity: sha512-prmU+rDddxHOH0oNcwemL+SwnzcG65sBF2yXRO7aeXIn/xTlq2pX7JLVbkBnVLowHLg4/OL4+jBmv9hVrVGS+w==} + engines: {node: '>=18.0.0'} + + '@smithy/core@3.6.0': + resolution: {integrity: sha512-Pgvfb+TQ4wUNLyHzvgCP4aYZMh16y7GcfF59oirRHcgGgkH1e/s9C0nv/v3WP+Quymyr5je71HeFQCwh+44XLg==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.0.6': + resolution: {integrity: sha512-hKMWcANhUiNbCJouYkZ9V3+/Qf9pteR1dnwgdyzR09R4ODEYx8BbUysHwRSyex4rZ9zapddZhLFTnT4ZijR4pw==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-codec@4.0.4': + resolution: {integrity: sha512-7XoWfZqWb/QoR/rAU4VSi0mWnO2vu9/ltS6JZ5ZSZv0eovLVfDfu0/AX4ub33RsJTOth3TiFWSHS5YdztvFnig==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-browser@4.0.4': + resolution: {integrity: sha512-3fb/9SYaYqbpy/z/H3yIi0bYKyAa89y6xPmIqwr2vQiUT2St+avRt8UKwsWt9fEdEasc5d/V+QjrviRaX1JRFA==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-config-resolver@4.1.2': + resolution: {integrity: sha512-JGtambizrWP50xHgbzZI04IWU7LdI0nh/wGbqH3sJesYToMi2j/DcoElqyOcqEIG/D4tNyxgRuaqBXWE3zOFhQ==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-node@4.0.4': + resolution: {integrity: sha512-RD6UwNZ5zISpOWPuhVgRz60GkSIp0dy1fuZmj4RYmqLVRtejFqQ16WmfYDdoSoAjlp1LX+FnZo+/hkdmyyGZ1w==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-universal@4.0.4': + resolution: {integrity: sha512-UeJpOmLGhq1SLox79QWw/0n2PFX+oPRE1ZyRMxPIaFEfCqWaqpB7BU9C8kpPOGEhLF7AwEqfFbtwNxGy4ReENA==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.0.4': + resolution: {integrity: sha512-AMtBR5pHppYMVD7z7G+OlHHAcgAN7v0kVKEpHuTO4Gb199Gowh0taYi9oDStFeUhetkeP55JLSVlTW1n9rFtUw==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-blob-browser@4.0.4': + resolution: {integrity: sha512-WszRiACJiQV3QG6XMV44i5YWlkrlsM5Yxgz4jvsksuu7LDXA6wAtypfPajtNTadzpJy3KyJPoWehYpmZGKUFIQ==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-node@4.0.4': + resolution: {integrity: sha512-qnbTPUhCVnCgBp4z4BUJUhOEkVwxiEi1cyFM+Zj6o+aY8OFGxUQleKWq8ltgp3dujuhXojIvJWdoqpm6dVO3lQ==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-stream-node@4.0.4': + resolution: {integrity: sha512-wHo0d8GXyVmpmMh/qOR0R7Y46/G1y6OR8U+bSTB4ppEzRxd1xVAQ9xOE9hOc0bSjhz0ujCPAbfNLkLrpa6cevg==} + engines: {node: '>=18.0.0'} + + '@smithy/invalid-dependency@4.0.4': + resolution: {integrity: sha512-bNYMi7WKTJHu0gn26wg8OscncTt1t2b8KcsZxvOv56XA6cyXtOAAAaNP7+m45xfppXfOatXF3Sb1MNsLUgVLTw==} + engines: {node: '>=18.0.0'} + + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} + + '@smithy/is-array-buffer@4.0.0': + resolution: {integrity: sha512-saYhF8ZZNoJDTvJBEWgeBccCg+yvp1CX+ed12yORU3NilJScfc6gfch2oVb4QgxZrGUx3/ZJlb+c/dJbyupxlw==} + engines: {node: '>=18.0.0'} + + '@smithy/md5-js@4.0.4': + resolution: {integrity: sha512-uGLBVqcOwrLvGh/v/jw423yWHq/ofUGK1W31M2TNspLQbUV1Va0F5kTxtirkoHawODAZcjXTSGi7JwbnPcDPJg==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-content-length@4.0.4': + resolution: {integrity: sha512-F7gDyfI2BB1Kc+4M6rpuOLne5LOcEknH1n6UQB69qv+HucXBR1rkzXBnQTB2q46sFy1PM/zuSJOB532yc8bg3w==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-endpoint@4.1.13': + resolution: {integrity: sha512-xg3EHV/Q5ZdAO5b0UiIMj3RIOCobuS40pBBODguUDVdko6YK6QIzCVRrHTogVuEKglBWqWenRnZ71iZnLL3ZAQ==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-retry@4.1.14': + resolution: {integrity: sha512-eoXaLlDGpKvdmvt+YBfRXE7HmIEtFF+DJCbTPwuLunP0YUnrydl+C4tS+vEM0+nyxXrX3PSUFqC+lP1+EHB1Tw==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-serde@4.0.8': + resolution: {integrity: sha512-iSSl7HJoJaGyMIoNn2B7czghOVwJ9nD7TMvLhMWeSB5vt0TnEYyRRqPJu/TqW76WScaNvYYB8nRoiBHR9S1Ddw==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-stack@4.0.4': + resolution: {integrity: sha512-kagK5ggDrBUCCzI93ft6DjteNSfY8Ulr83UtySog/h09lTIOAJ/xUSObutanlPT0nhoHAkpmW9V5K8oPyLh+QA==} + engines: {node: '>=18.0.0'} + + '@smithy/node-config-provider@4.1.3': + resolution: {integrity: sha512-HGHQr2s59qaU1lrVH6MbLlmOBxadtzTsoO4c+bF5asdgVik3I8o7JIOzoeqWc5MjVa+vD36/LWE0iXKpNqooRw==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.0.6': + resolution: {integrity: sha512-NqbmSz7AW2rvw4kXhKGrYTiJVDHnMsFnX4i+/FzcZAfbOBauPYs2ekuECkSbtqaxETLLTu9Rl/ex6+I2BKErPA==} + engines: {node: '>=18.0.0'} + + '@smithy/property-provider@4.0.4': + resolution: {integrity: sha512-qHJ2sSgu4FqF4U/5UUp4DhXNmdTrgmoAai6oQiM+c5RZ/sbDwJ12qxB1M6FnP+Tn/ggkPZf9ccn4jqKSINaquw==} + engines: {node: '>=18.0.0'} + + '@smithy/protocol-http@5.1.2': + resolution: {integrity: sha512-rOG5cNLBXovxIrICSBm95dLqzfvxjEmuZx4KK3hWwPFHGdW3lxY0fZNXfv2zebfRO7sJZ5pKJYHScsqopeIWtQ==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-builder@4.0.4': + resolution: {integrity: sha512-SwREZcDnEYoh9tLNgMbpop+UTGq44Hl9tdj3rf+yeLcfH7+J8OXEBaMc2kDxtyRHu8BhSg9ADEx0gFHvpJgU8w==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-parser@4.0.4': + resolution: {integrity: sha512-6yZf53i/qB8gRHH/l2ZwUG5xgkPgQF15/KxH0DdXMDHjesA9MeZje/853ifkSY0x4m5S+dfDZ+c4x439PF0M2w==} + engines: {node: '>=18.0.0'} + + '@smithy/service-error-classification@4.0.6': + resolution: {integrity: sha512-RRoTDL//7xi4tn5FrN2NzH17jbgmnKidUqd4KvquT0954/i6CXXkh1884jBiunq24g9cGtPBEXlU40W6EpNOOg==} + engines: {node: '>=18.0.0'} + + '@smithy/shared-ini-file-loader@4.0.4': + resolution: {integrity: sha512-63X0260LoFBjrHifPDs+nM9tV0VMkOTl4JRMYNuKh/f5PauSjowTfvF3LogfkWdcPoxsA9UjqEOgjeYIbhb7Nw==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.1.2': + resolution: {integrity: sha512-d3+U/VpX7a60seHziWnVZOHuEgJlclufjkS6zhXvxcJgkJq4UWdH5eOBLzHRMx6gXjsdT9h6lfpmLzbrdupHgQ==} + engines: {node: '>=18.0.0'} + + '@smithy/smithy-client@4.4.5': + resolution: {integrity: sha512-+lynZjGuUFJaMdDYSTMnP/uPBBXXukVfrJlP+1U/Dp5SFTEI++w6NMga8DjOENxecOF71V9Z2DllaVDYRnGlkg==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.3.1': + resolution: {integrity: sha512-UqKOQBL2x6+HWl3P+3QqFD4ncKq0I8Nuz9QItGv5WuKuMHuuwlhvqcZCoXGfc+P1QmfJE7VieykoYYmrOoFJxA==} + engines: {node: '>=18.0.0'} + + '@smithy/url-parser@4.0.4': + resolution: {integrity: sha512-eMkc144MuN7B0TDA4U2fKs+BqczVbk3W+qIvcoCY6D1JY3hnAdCuhCZODC+GAeaxj0p6Jroz4+XMUn3PCxQQeQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-base64@4.0.0': + resolution: {integrity: sha512-CvHfCmO2mchox9kjrtzoHkWHxjHZzaFojLc8quxXY7WAAMAg43nuxwv95tATVgQFNDwd4M9S1qFzj40Ul41Kmg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-browser@4.0.0': + resolution: {integrity: sha512-sNi3DL0/k64/LO3A256M+m3CDdG6V7WKWHdAiBBMUN8S3hK3aMPhwnPik2A/a2ONN+9doY9UxaLfgqsIRg69QA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-node@4.0.0': + resolution: {integrity: sha512-q0iDP3VsZzqJyje8xJWEJCNIu3lktUGVoSy1KB0UWym2CL1siV3artm+u1DFYTLejpsrdGyCSWBdGNjJzfDPjg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-buffer-from@4.0.0': + resolution: {integrity: sha512-9TOQ7781sZvddgO8nxueKi3+yGvkY35kotA0Y6BWRajAv8jjmigQ1sBwz0UX47pQMYXJPahSKEKYFgt+rXdcug==} + engines: {node: '>=18.0.0'} + + '@smithy/util-config-provider@4.0.0': + resolution: {integrity: sha512-L1RBVzLyfE8OXH+1hsJ8p+acNUSirQnWQ6/EgpchV88G6zGBTDPdXiiExei6Z1wR2RxYvxY/XLw6AMNCCt8H3w==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-browser@4.0.21': + resolution: {integrity: sha512-wM0jhTytgXu3wzJoIqpbBAG5U6BwiubZ6QKzSbP7/VbmF1v96xlAbX2Am/mz0Zep0NLvLh84JT0tuZnk3wmYQA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-node@4.0.21': + resolution: {integrity: sha512-/F34zkoU0GzpUgLJydHY8Rxu9lBn8xQC/s/0M0U9lLBkYbA1htaAFjWYJzpzsbXPuri5D1H8gjp2jBum05qBrA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-endpoints@3.0.6': + resolution: {integrity: sha512-YARl3tFL3WgPuLzljRUnrS2ngLiUtkwhQtj8PAL13XZSyUiNLQxwG3fBBq3QXFqGFUXepIN73pINp3y8c2nBmA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-hex-encoding@4.0.0': + resolution: {integrity: sha512-Yk5mLhHtfIgW2W2WQZWSg5kuMZCVbvhFmC7rV4IO2QqnZdbEFPmQnCcGMAX2z/8Qj3B9hYYNjZOhWym+RwhePw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-middleware@4.0.4': + resolution: {integrity: sha512-9MLKmkBmf4PRb0ONJikCbCwORACcil6gUWojwARCClT7RmLzF04hUR4WdRprIXal7XVyrddadYNfp2eF3nrvtQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-retry@4.0.6': + resolution: {integrity: sha512-+YekoF2CaSMv6zKrA6iI/N9yva3Gzn4L6n35Luydweu5MMPYpiGZlWqehPHDHyNbnyaYlz/WJyYAZnC+loBDZg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-stream@4.2.2': + resolution: {integrity: sha512-aI+GLi7MJoVxg24/3J1ipwLoYzgkB4kUfogZfnslcYlynj3xsQ0e7vk4TnTro9hhsS5PvX1mwmkRqqHQjwcU7w==} + engines: {node: '>=18.0.0'} + + '@smithy/util-uri-escape@4.0.0': + resolution: {integrity: sha512-77yfbCbQMtgtTylO9itEAdpPXSog3ZxMe09AEhm0dU0NLTalV70ghDZFR+Nfi1C60jnJoh/Re4090/DuZh2Omg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@4.0.0': + resolution: {integrity: sha512-b+zebfKCfRdgNJDknHCob3O7FpeYQN6ZG6YLExMcasDHsCXlsXCEuiPZeLnJLpwa5dvPetGlnGCiMHuLwGvFow==} + engines: {node: '>=18.0.0'} + + '@smithy/util-waiter@4.0.6': + resolution: {integrity: sha512-slcr1wdRbX7NFphXZOxtxRNA7hXAAtJAXJDE/wdoMAos27SIquVCKiSqfB6/28YzQ8FCsB5NKkhdM5gMADbqxg==} + engines: {node: '>=18.0.0'} + '@socket.io/component-emitter@3.1.0': resolution: {integrity: sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==} @@ -3418,6 +3792,9 @@ packages: '@types/unist@3.0.2': resolution: {integrity: sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==} + '@types/uuid@9.0.8': + resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} + '@types/webpack@5.28.5': resolution: {integrity: sha512-wR87cgvxj3p6D0Crt1r5avwqffqPXUkNlnQ1mjU93G7gCuFjufZR4I6j8cz5g1F1tTYpfOOFvly+cmIQwL9wvw==} @@ -3740,6 +4117,10 @@ packages: resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} engines: {node: '>= 4.0.0'} + attr-accept@2.2.5: + resolution: {integrity: sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==} + engines: {node: '>=4'} + autoprefixer@10.4.14: resolution: {integrity: sha512-FQzyfOsTlwVzjHxKEqRIAdJx9niO6VCBCoEwax/VLSoQF29ggECcPuBqUMZ+u8jCZOPSy8b8/8KnuFbp0SaFZQ==} engines: {node: ^10 || ^12 || >=14} @@ -3821,6 +4202,9 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + bowser@2.11.0: + resolution: {integrity: sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==} + brace-expansion@1.1.11: resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} @@ -4860,6 +5244,10 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-xml-parser@4.4.1: + resolution: {integrity: sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw==} + hasBin: true + fastq@1.15.0: resolution: {integrity: sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==} @@ -4876,6 +5264,10 @@ packages: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} + file-selector@2.1.2: + resolution: {integrity: sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==} + engines: {node: '>= 12'} + filelist@1.0.4: resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} @@ -6097,13 +6489,6 @@ packages: peerDependencies: next: '>=9.0.0' - next-sitemap@4.2.3: - resolution: {integrity: sha512-vjdCxeDuWDzldhCnyFCQipw5bfpl4HmZA7uoo3GAaYGjGgfL4Cxb1CiztPuWGmS+auYs7/8OekRS8C2cjdAsjQ==} - engines: {node: '>=14.18'} - hasBin: true - peerDependencies: - next: '*' - next-themes@0.3.0: resolution: {integrity: sha512-/QHIrsYpd6Kfk7xakK4svpDI5mmXP0gfvCoJdGpZQ2TOrQZmsW0QxjaiLn8wbIKjtm4BTSqLoix4lxYYOnLJ/w==} peerDependencies: @@ -6701,6 +7086,12 @@ packages: peerDependencies: react: ^18.3.1 + react-dropzone@14.3.8: + resolution: {integrity: sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==} + engines: {node: '>= 10.13'} + peerDependencies: + react: '>= 16.8 || 18.0.0' + react-email@2.1.5: resolution: {integrity: sha512-SjGt5XiqNwrC6FT0rAxERj0MC9binUOVZDzspAxcRHpxjZavvePAHvV29uROWNQ1Ha7ssg1sfy4dTQi7bjCXrg==} engines: {node: '>=18.0.0'} @@ -7283,6 +7674,9 @@ packages: striptags@3.2.0: resolution: {integrity: sha512-g45ZOGzHDMe2bdYMdIvdAfCQkCTDMGBazSw1ypMowwGIee7ZQ5dU0rBJ8Jqgl+jAKIv4dbeE1jscZq9wid1Tkw==} + strnum@1.1.2: + resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==} + style-to-object@0.4.4: resolution: {integrity: sha512-HYNoHZa2GorYNyqiCaBgsxvcJIn7OHq6inEga+E6Ke3m5JkoqpQbnFssk4jwe+K7AhGa2fcha4wSOf1Kn01dMg==} @@ -8005,6 +8399,489 @@ snapshots: - '@simplewebauthn/server' - nodemailer + '@aws-crypto/crc32@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.840.0 + tslib: 2.8.1 + + '@aws-crypto/crc32c@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.840.0 + tslib: 2.8.1 + + '@aws-crypto/sha1-browser@5.2.0': + dependencies: + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.840.0 + '@aws-sdk/util-locate-window': 3.804.0 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-browser@5.2.0': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.840.0 + '@aws-sdk/util-locate-window': 3.804.0 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.840.0 + tslib: 2.8.1 + + '@aws-crypto/supports-web-crypto@5.2.0': + dependencies: + tslib: 2.8.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.840.0 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/client-s3@3.840.0': + dependencies: + '@aws-crypto/sha1-browser': 5.2.0 + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.840.0 + '@aws-sdk/credential-provider-node': 3.840.0 + '@aws-sdk/middleware-bucket-endpoint': 3.840.0 + '@aws-sdk/middleware-expect-continue': 3.840.0 + '@aws-sdk/middleware-flexible-checksums': 3.840.0 + '@aws-sdk/middleware-host-header': 3.840.0 + '@aws-sdk/middleware-location-constraint': 3.840.0 + '@aws-sdk/middleware-logger': 3.840.0 + '@aws-sdk/middleware-recursion-detection': 3.840.0 + '@aws-sdk/middleware-sdk-s3': 3.840.0 + '@aws-sdk/middleware-ssec': 3.840.0 + '@aws-sdk/middleware-user-agent': 3.840.0 + '@aws-sdk/region-config-resolver': 3.840.0 + '@aws-sdk/signature-v4-multi-region': 3.840.0 + '@aws-sdk/types': 3.840.0 + '@aws-sdk/util-endpoints': 3.840.0 + '@aws-sdk/util-user-agent-browser': 3.840.0 + '@aws-sdk/util-user-agent-node': 3.840.0 + '@aws-sdk/xml-builder': 3.821.0 + '@smithy/config-resolver': 4.1.4 + '@smithy/core': 3.6.0 + '@smithy/eventstream-serde-browser': 4.0.4 + '@smithy/eventstream-serde-config-resolver': 4.1.2 + '@smithy/eventstream-serde-node': 4.0.4 + '@smithy/fetch-http-handler': 5.0.4 + '@smithy/hash-blob-browser': 4.0.4 + '@smithy/hash-node': 4.0.4 + '@smithy/hash-stream-node': 4.0.4 + '@smithy/invalid-dependency': 4.0.4 + '@smithy/md5-js': 4.0.4 + '@smithy/middleware-content-length': 4.0.4 + '@smithy/middleware-endpoint': 4.1.13 + '@smithy/middleware-retry': 4.1.14 + '@smithy/middleware-serde': 4.0.8 + '@smithy/middleware-stack': 4.0.4 + '@smithy/node-config-provider': 4.1.3 + '@smithy/node-http-handler': 4.0.6 + '@smithy/protocol-http': 5.1.2 + '@smithy/smithy-client': 4.4.5 + '@smithy/types': 4.3.1 + '@smithy/url-parser': 4.0.4 + '@smithy/util-base64': 4.0.0 + '@smithy/util-body-length-browser': 4.0.0 + '@smithy/util-body-length-node': 4.0.0 + '@smithy/util-defaults-mode-browser': 4.0.21 + '@smithy/util-defaults-mode-node': 4.0.21 + '@smithy/util-endpoints': 3.0.6 + '@smithy/util-middleware': 4.0.4 + '@smithy/util-retry': 4.0.6 + '@smithy/util-stream': 4.2.2 + '@smithy/util-utf8': 4.0.0 + '@smithy/util-waiter': 4.0.6 + '@types/uuid': 9.0.8 + tslib: 2.8.1 + uuid: 9.0.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/client-sso@3.840.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.840.0 + '@aws-sdk/middleware-host-header': 3.840.0 + '@aws-sdk/middleware-logger': 3.840.0 + '@aws-sdk/middleware-recursion-detection': 3.840.0 + '@aws-sdk/middleware-user-agent': 3.840.0 + '@aws-sdk/region-config-resolver': 3.840.0 + '@aws-sdk/types': 3.840.0 + '@aws-sdk/util-endpoints': 3.840.0 + '@aws-sdk/util-user-agent-browser': 3.840.0 + '@aws-sdk/util-user-agent-node': 3.840.0 + '@smithy/config-resolver': 4.1.4 + '@smithy/core': 3.6.0 + '@smithy/fetch-http-handler': 5.0.4 + '@smithy/hash-node': 4.0.4 + '@smithy/invalid-dependency': 4.0.4 + '@smithy/middleware-content-length': 4.0.4 + '@smithy/middleware-endpoint': 4.1.13 + '@smithy/middleware-retry': 4.1.14 + '@smithy/middleware-serde': 4.0.8 + '@smithy/middleware-stack': 4.0.4 + '@smithy/node-config-provider': 4.1.3 + '@smithy/node-http-handler': 4.0.6 + '@smithy/protocol-http': 5.1.2 + '@smithy/smithy-client': 4.4.5 + '@smithy/types': 4.3.1 + '@smithy/url-parser': 4.0.4 + '@smithy/util-base64': 4.0.0 + '@smithy/util-body-length-browser': 4.0.0 + '@smithy/util-body-length-node': 4.0.0 + '@smithy/util-defaults-mode-browser': 4.0.21 + '@smithy/util-defaults-mode-node': 4.0.21 + '@smithy/util-endpoints': 3.0.6 + '@smithy/util-middleware': 4.0.4 + '@smithy/util-retry': 4.0.6 + '@smithy/util-utf8': 4.0.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/core@3.840.0': + dependencies: + '@aws-sdk/types': 3.840.0 + '@aws-sdk/xml-builder': 3.821.0 + '@smithy/core': 3.6.0 + '@smithy/node-config-provider': 4.1.3 + '@smithy/property-provider': 4.0.4 + '@smithy/protocol-http': 5.1.2 + '@smithy/signature-v4': 5.1.2 + '@smithy/smithy-client': 4.4.5 + '@smithy/types': 4.3.1 + '@smithy/util-base64': 4.0.0 + '@smithy/util-body-length-browser': 4.0.0 + '@smithy/util-middleware': 4.0.4 + '@smithy/util-utf8': 4.0.0 + fast-xml-parser: 4.4.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.840.0': + dependencies: + '@aws-sdk/core': 3.840.0 + '@aws-sdk/types': 3.840.0 + '@smithy/property-provider': 4.0.4 + '@smithy/types': 4.3.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.840.0': + dependencies: + '@aws-sdk/core': 3.840.0 + '@aws-sdk/types': 3.840.0 + '@smithy/fetch-http-handler': 5.0.4 + '@smithy/node-http-handler': 4.0.6 + '@smithy/property-provider': 4.0.4 + '@smithy/protocol-http': 5.1.2 + '@smithy/smithy-client': 4.4.5 + '@smithy/types': 4.3.1 + '@smithy/util-stream': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.840.0': + dependencies: + '@aws-sdk/core': 3.840.0 + '@aws-sdk/credential-provider-env': 3.840.0 + '@aws-sdk/credential-provider-http': 3.840.0 + '@aws-sdk/credential-provider-process': 3.840.0 + '@aws-sdk/credential-provider-sso': 3.840.0 + '@aws-sdk/credential-provider-web-identity': 3.840.0 + '@aws-sdk/nested-clients': 3.840.0 + '@aws-sdk/types': 3.840.0 + '@smithy/credential-provider-imds': 4.0.6 + '@smithy/property-provider': 4.0.4 + '@smithy/shared-ini-file-loader': 4.0.4 + '@smithy/types': 4.3.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-node@3.840.0': + dependencies: + '@aws-sdk/credential-provider-env': 3.840.0 + '@aws-sdk/credential-provider-http': 3.840.0 + '@aws-sdk/credential-provider-ini': 3.840.0 + '@aws-sdk/credential-provider-process': 3.840.0 + '@aws-sdk/credential-provider-sso': 3.840.0 + '@aws-sdk/credential-provider-web-identity': 3.840.0 + '@aws-sdk/types': 3.840.0 + '@smithy/credential-provider-imds': 4.0.6 + '@smithy/property-provider': 4.0.4 + '@smithy/shared-ini-file-loader': 4.0.4 + '@smithy/types': 4.3.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-process@3.840.0': + dependencies: + '@aws-sdk/core': 3.840.0 + '@aws-sdk/types': 3.840.0 + '@smithy/property-provider': 4.0.4 + '@smithy/shared-ini-file-loader': 4.0.4 + '@smithy/types': 4.3.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.840.0': + dependencies: + '@aws-sdk/client-sso': 3.840.0 + '@aws-sdk/core': 3.840.0 + '@aws-sdk/token-providers': 3.840.0 + '@aws-sdk/types': 3.840.0 + '@smithy/property-provider': 4.0.4 + '@smithy/shared-ini-file-loader': 4.0.4 + '@smithy/types': 4.3.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-web-identity@3.840.0': + dependencies: + '@aws-sdk/core': 3.840.0 + '@aws-sdk/nested-clients': 3.840.0 + '@aws-sdk/types': 3.840.0 + '@smithy/property-provider': 4.0.4 + '@smithy/types': 4.3.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/middleware-bucket-endpoint@3.840.0': + dependencies: + '@aws-sdk/types': 3.840.0 + '@aws-sdk/util-arn-parser': 3.804.0 + '@smithy/node-config-provider': 4.1.3 + '@smithy/protocol-http': 5.1.2 + '@smithy/types': 4.3.1 + '@smithy/util-config-provider': 4.0.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-expect-continue@3.840.0': + dependencies: + '@aws-sdk/types': 3.840.0 + '@smithy/protocol-http': 5.1.2 + '@smithy/types': 4.3.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-flexible-checksums@3.840.0': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@aws-crypto/crc32c': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/core': 3.840.0 + '@aws-sdk/types': 3.840.0 + '@smithy/is-array-buffer': 4.0.0 + '@smithy/node-config-provider': 4.1.3 + '@smithy/protocol-http': 5.1.2 + '@smithy/types': 4.3.1 + '@smithy/util-middleware': 4.0.4 + '@smithy/util-stream': 4.2.2 + '@smithy/util-utf8': 4.0.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-host-header@3.840.0': + dependencies: + '@aws-sdk/types': 3.840.0 + '@smithy/protocol-http': 5.1.2 + '@smithy/types': 4.3.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-location-constraint@3.840.0': + dependencies: + '@aws-sdk/types': 3.840.0 + '@smithy/types': 4.3.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-logger@3.840.0': + dependencies: + '@aws-sdk/types': 3.840.0 + '@smithy/types': 4.3.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-recursion-detection@3.840.0': + dependencies: + '@aws-sdk/types': 3.840.0 + '@smithy/protocol-http': 5.1.2 + '@smithy/types': 4.3.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.840.0': + dependencies: + '@aws-sdk/core': 3.840.0 + '@aws-sdk/types': 3.840.0 + '@aws-sdk/util-arn-parser': 3.804.0 + '@smithy/core': 3.6.0 + '@smithy/node-config-provider': 4.1.3 + '@smithy/protocol-http': 5.1.2 + '@smithy/signature-v4': 5.1.2 + '@smithy/smithy-client': 4.4.5 + '@smithy/types': 4.3.1 + '@smithy/util-config-provider': 4.0.0 + '@smithy/util-middleware': 4.0.4 + '@smithy/util-stream': 4.2.2 + '@smithy/util-utf8': 4.0.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-ssec@3.840.0': + dependencies: + '@aws-sdk/types': 3.840.0 + '@smithy/types': 4.3.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-user-agent@3.840.0': + dependencies: + '@aws-sdk/core': 3.840.0 + '@aws-sdk/types': 3.840.0 + '@aws-sdk/util-endpoints': 3.840.0 + '@smithy/core': 3.6.0 + '@smithy/protocol-http': 5.1.2 + '@smithy/types': 4.3.1 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.840.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.840.0 + '@aws-sdk/middleware-host-header': 3.840.0 + '@aws-sdk/middleware-logger': 3.840.0 + '@aws-sdk/middleware-recursion-detection': 3.840.0 + '@aws-sdk/middleware-user-agent': 3.840.0 + '@aws-sdk/region-config-resolver': 3.840.0 + '@aws-sdk/types': 3.840.0 + '@aws-sdk/util-endpoints': 3.840.0 + '@aws-sdk/util-user-agent-browser': 3.840.0 + '@aws-sdk/util-user-agent-node': 3.840.0 + '@smithy/config-resolver': 4.1.4 + '@smithy/core': 3.6.0 + '@smithy/fetch-http-handler': 5.0.4 + '@smithy/hash-node': 4.0.4 + '@smithy/invalid-dependency': 4.0.4 + '@smithy/middleware-content-length': 4.0.4 + '@smithy/middleware-endpoint': 4.1.13 + '@smithy/middleware-retry': 4.1.14 + '@smithy/middleware-serde': 4.0.8 + '@smithy/middleware-stack': 4.0.4 + '@smithy/node-config-provider': 4.1.3 + '@smithy/node-http-handler': 4.0.6 + '@smithy/protocol-http': 5.1.2 + '@smithy/smithy-client': 4.4.5 + '@smithy/types': 4.3.1 + '@smithy/url-parser': 4.0.4 + '@smithy/util-base64': 4.0.0 + '@smithy/util-body-length-browser': 4.0.0 + '@smithy/util-body-length-node': 4.0.0 + '@smithy/util-defaults-mode-browser': 4.0.21 + '@smithy/util-defaults-mode-node': 4.0.21 + '@smithy/util-endpoints': 3.0.6 + '@smithy/util-middleware': 4.0.4 + '@smithy/util-retry': 4.0.6 + '@smithy/util-utf8': 4.0.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/region-config-resolver@3.840.0': + dependencies: + '@aws-sdk/types': 3.840.0 + '@smithy/node-config-provider': 4.1.3 + '@smithy/types': 4.3.1 + '@smithy/util-config-provider': 4.0.0 + '@smithy/util-middleware': 4.0.4 + tslib: 2.8.1 + + '@aws-sdk/s3-request-presigner@3.840.0': + dependencies: + '@aws-sdk/signature-v4-multi-region': 3.840.0 + '@aws-sdk/types': 3.840.0 + '@aws-sdk/util-format-url': 3.840.0 + '@smithy/middleware-endpoint': 4.1.13 + '@smithy/protocol-http': 5.1.2 + '@smithy/smithy-client': 4.4.5 + '@smithy/types': 4.3.1 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.840.0': + dependencies: + '@aws-sdk/middleware-sdk-s3': 3.840.0 + '@aws-sdk/types': 3.840.0 + '@smithy/protocol-http': 5.1.2 + '@smithy/signature-v4': 5.1.2 + '@smithy/types': 4.3.1 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.840.0': + dependencies: + '@aws-sdk/core': 3.840.0 + '@aws-sdk/nested-clients': 3.840.0 + '@aws-sdk/types': 3.840.0 + '@smithy/property-provider': 4.0.4 + '@smithy/shared-ini-file-loader': 4.0.4 + '@smithy/types': 4.3.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/types@3.840.0': + dependencies: + '@smithy/types': 4.3.1 + tslib: 2.8.1 + + '@aws-sdk/util-arn-parser@3.804.0': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-endpoints@3.840.0': + dependencies: + '@aws-sdk/types': 3.840.0 + '@smithy/types': 4.3.1 + '@smithy/util-endpoints': 3.0.6 + tslib: 2.8.1 + + '@aws-sdk/util-format-url@3.840.0': + dependencies: + '@aws-sdk/types': 3.840.0 + '@smithy/querystring-builder': 4.0.4 + '@smithy/types': 4.3.1 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.804.0': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-browser@3.840.0': + dependencies: + '@aws-sdk/types': 3.840.0 + '@smithy/types': 4.3.1 + bowser: 2.11.0 + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-node@3.840.0': + dependencies: + '@aws-sdk/middleware-user-agent': 3.840.0 + '@aws-sdk/types': 3.840.0 + '@smithy/node-config-provider': 4.1.3 + '@smithy/types': 4.3.1 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.821.0': + dependencies: + '@smithy/types': 4.3.1 + tslib: 2.8.1 + '@babel/code-frame@7.24.6': dependencies: '@babel/highlight': 7.24.6 @@ -8973,8 +9850,6 @@ snapshots: ts-pattern: 5.1.1 type-fest: 4.18.1 - '@corex/deepmerge@4.0.43': {} - '@dimforge/rapier3d-compat@0.12.0': {} '@effect-ts/core@0.60.5': @@ -10984,6 +11859,339 @@ snapshots: fflate: 0.7.4 string.prototype.codepointat: 0.2.1 + '@smithy/abort-controller@4.0.4': + dependencies: + '@smithy/types': 4.3.1 + tslib: 2.8.1 + + '@smithy/chunked-blob-reader-native@4.0.0': + dependencies: + '@smithy/util-base64': 4.0.0 + tslib: 2.8.1 + + '@smithy/chunked-blob-reader@5.0.0': + dependencies: + tslib: 2.8.1 + + '@smithy/config-resolver@4.1.4': + dependencies: + '@smithy/node-config-provider': 4.1.3 + '@smithy/types': 4.3.1 + '@smithy/util-config-provider': 4.0.0 + '@smithy/util-middleware': 4.0.4 + tslib: 2.8.1 + + '@smithy/core@3.6.0': + dependencies: + '@smithy/middleware-serde': 4.0.8 + '@smithy/protocol-http': 5.1.2 + '@smithy/types': 4.3.1 + '@smithy/util-base64': 4.0.0 + '@smithy/util-body-length-browser': 4.0.0 + '@smithy/util-middleware': 4.0.4 + '@smithy/util-stream': 4.2.2 + '@smithy/util-utf8': 4.0.0 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.0.6': + dependencies: + '@smithy/node-config-provider': 4.1.3 + '@smithy/property-provider': 4.0.4 + '@smithy/types': 4.3.1 + '@smithy/url-parser': 4.0.4 + tslib: 2.8.1 + + '@smithy/eventstream-codec@4.0.4': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.3.1 + '@smithy/util-hex-encoding': 4.0.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-browser@4.0.4': + dependencies: + '@smithy/eventstream-serde-universal': 4.0.4 + '@smithy/types': 4.3.1 + tslib: 2.8.1 + + '@smithy/eventstream-serde-config-resolver@4.1.2': + dependencies: + '@smithy/types': 4.3.1 + tslib: 2.8.1 + + '@smithy/eventstream-serde-node@4.0.4': + dependencies: + '@smithy/eventstream-serde-universal': 4.0.4 + '@smithy/types': 4.3.1 + tslib: 2.8.1 + + '@smithy/eventstream-serde-universal@4.0.4': + dependencies: + '@smithy/eventstream-codec': 4.0.4 + '@smithy/types': 4.3.1 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.0.4': + dependencies: + '@smithy/protocol-http': 5.1.2 + '@smithy/querystring-builder': 4.0.4 + '@smithy/types': 4.3.1 + '@smithy/util-base64': 4.0.0 + tslib: 2.8.1 + + '@smithy/hash-blob-browser@4.0.4': + dependencies: + '@smithy/chunked-blob-reader': 5.0.0 + '@smithy/chunked-blob-reader-native': 4.0.0 + '@smithy/types': 4.3.1 + tslib: 2.8.1 + + '@smithy/hash-node@4.0.4': + dependencies: + '@smithy/types': 4.3.1 + '@smithy/util-buffer-from': 4.0.0 + '@smithy/util-utf8': 4.0.0 + tslib: 2.8.1 + + '@smithy/hash-stream-node@4.0.4': + dependencies: + '@smithy/types': 4.3.1 + '@smithy/util-utf8': 4.0.0 + tslib: 2.8.1 + + '@smithy/invalid-dependency@4.0.4': + dependencies: + '@smithy/types': 4.3.1 + tslib: 2.8.1 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/is-array-buffer@4.0.0': + dependencies: + tslib: 2.8.1 + + '@smithy/md5-js@4.0.4': + dependencies: + '@smithy/types': 4.3.1 + '@smithy/util-utf8': 4.0.0 + tslib: 2.8.1 + + '@smithy/middleware-content-length@4.0.4': + dependencies: + '@smithy/protocol-http': 5.1.2 + '@smithy/types': 4.3.1 + tslib: 2.8.1 + + '@smithy/middleware-endpoint@4.1.13': + dependencies: + '@smithy/core': 3.6.0 + '@smithy/middleware-serde': 4.0.8 + '@smithy/node-config-provider': 4.1.3 + '@smithy/shared-ini-file-loader': 4.0.4 + '@smithy/types': 4.3.1 + '@smithy/url-parser': 4.0.4 + '@smithy/util-middleware': 4.0.4 + tslib: 2.8.1 + + '@smithy/middleware-retry@4.1.14': + dependencies: + '@smithy/node-config-provider': 4.1.3 + '@smithy/protocol-http': 5.1.2 + '@smithy/service-error-classification': 4.0.6 + '@smithy/smithy-client': 4.4.5 + '@smithy/types': 4.3.1 + '@smithy/util-middleware': 4.0.4 + '@smithy/util-retry': 4.0.6 + tslib: 2.8.1 + uuid: 9.0.1 + + '@smithy/middleware-serde@4.0.8': + dependencies: + '@smithy/protocol-http': 5.1.2 + '@smithy/types': 4.3.1 + tslib: 2.8.1 + + '@smithy/middleware-stack@4.0.4': + dependencies: + '@smithy/types': 4.3.1 + tslib: 2.8.1 + + '@smithy/node-config-provider@4.1.3': + dependencies: + '@smithy/property-provider': 4.0.4 + '@smithy/shared-ini-file-loader': 4.0.4 + '@smithy/types': 4.3.1 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.0.6': + dependencies: + '@smithy/abort-controller': 4.0.4 + '@smithy/protocol-http': 5.1.2 + '@smithy/querystring-builder': 4.0.4 + '@smithy/types': 4.3.1 + tslib: 2.8.1 + + '@smithy/property-provider@4.0.4': + dependencies: + '@smithy/types': 4.3.1 + tslib: 2.8.1 + + '@smithy/protocol-http@5.1.2': + dependencies: + '@smithy/types': 4.3.1 + tslib: 2.8.1 + + '@smithy/querystring-builder@4.0.4': + dependencies: + '@smithy/types': 4.3.1 + '@smithy/util-uri-escape': 4.0.0 + tslib: 2.8.1 + + '@smithy/querystring-parser@4.0.4': + dependencies: + '@smithy/types': 4.3.1 + tslib: 2.8.1 + + '@smithy/service-error-classification@4.0.6': + dependencies: + '@smithy/types': 4.3.1 + + '@smithy/shared-ini-file-loader@4.0.4': + dependencies: + '@smithy/types': 4.3.1 + tslib: 2.8.1 + + '@smithy/signature-v4@5.1.2': + dependencies: + '@smithy/is-array-buffer': 4.0.0 + '@smithy/protocol-http': 5.1.2 + '@smithy/types': 4.3.1 + '@smithy/util-hex-encoding': 4.0.0 + '@smithy/util-middleware': 4.0.4 + '@smithy/util-uri-escape': 4.0.0 + '@smithy/util-utf8': 4.0.0 + tslib: 2.8.1 + + '@smithy/smithy-client@4.4.5': + dependencies: + '@smithy/core': 3.6.0 + '@smithy/middleware-endpoint': 4.1.13 + '@smithy/middleware-stack': 4.0.4 + '@smithy/protocol-http': 5.1.2 + '@smithy/types': 4.3.1 + '@smithy/util-stream': 4.2.2 + tslib: 2.8.1 + + '@smithy/types@4.3.1': + dependencies: + tslib: 2.8.1 + + '@smithy/url-parser@4.0.4': + dependencies: + '@smithy/querystring-parser': 4.0.4 + '@smithy/types': 4.3.1 + tslib: 2.8.1 + + '@smithy/util-base64@4.0.0': + dependencies: + '@smithy/util-buffer-from': 4.0.0 + '@smithy/util-utf8': 4.0.0 + tslib: 2.8.1 + + '@smithy/util-body-length-browser@4.0.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-body-length-node@4.0.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-buffer-from@4.0.0': + dependencies: + '@smithy/is-array-buffer': 4.0.0 + tslib: 2.8.1 + + '@smithy/util-config-provider@4.0.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-defaults-mode-browser@4.0.21': + dependencies: + '@smithy/property-provider': 4.0.4 + '@smithy/smithy-client': 4.4.5 + '@smithy/types': 4.3.1 + bowser: 2.11.0 + tslib: 2.8.1 + + '@smithy/util-defaults-mode-node@4.0.21': + dependencies: + '@smithy/config-resolver': 4.1.4 + '@smithy/credential-provider-imds': 4.0.6 + '@smithy/node-config-provider': 4.1.3 + '@smithy/property-provider': 4.0.4 + '@smithy/smithy-client': 4.4.5 + '@smithy/types': 4.3.1 + tslib: 2.8.1 + + '@smithy/util-endpoints@3.0.6': + dependencies: + '@smithy/node-config-provider': 4.1.3 + '@smithy/types': 4.3.1 + tslib: 2.8.1 + + '@smithy/util-hex-encoding@4.0.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-middleware@4.0.4': + dependencies: + '@smithy/types': 4.3.1 + tslib: 2.8.1 + + '@smithy/util-retry@4.0.6': + dependencies: + '@smithy/service-error-classification': 4.0.6 + '@smithy/types': 4.3.1 + tslib: 2.8.1 + + '@smithy/util-stream@4.2.2': + dependencies: + '@smithy/fetch-http-handler': 5.0.4 + '@smithy/node-http-handler': 4.0.6 + '@smithy/types': 4.3.1 + '@smithy/util-base64': 4.0.0 + '@smithy/util-buffer-from': 4.0.0 + '@smithy/util-hex-encoding': 4.0.0 + '@smithy/util-utf8': 4.0.0 + tslib: 2.8.1 + + '@smithy/util-uri-escape@4.0.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-utf8@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-utf8@4.0.0': + dependencies: + '@smithy/util-buffer-from': 4.0.0 + tslib: 2.8.1 + + '@smithy/util-waiter@4.0.6': + dependencies: + '@smithy/abort-controller': 4.0.4 + '@smithy/types': 4.3.1 + tslib: 2.8.1 + '@socket.io/component-emitter@3.1.0': {} '@surma/rollup-plugin-off-main-thread@2.2.3': @@ -11436,6 +12644,8 @@ snapshots: '@types/unist@3.0.2': {} + '@types/uuid@9.0.8': {} + '@types/webpack@5.28.5(@swc/core@1.3.101(@swc/helpers@0.5.5))(esbuild@0.19.11)': dependencies: '@types/node': 20.14.11 @@ -11594,7 +12804,9 @@ snapshots: next: 14.2.28(@babel/core@7.24.5)(@opentelemetry/api@1.8.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 - '@vercel/functions@1.4.0': {} + '@vercel/functions@1.4.0(@aws-sdk/credential-provider-web-identity@3.840.0)': + optionalDependencies: + '@aws-sdk/credential-provider-web-identity': 3.840.0 '@vercel/og@0.6.2': dependencies: @@ -11847,6 +13059,8 @@ snapshots: at-least-node@1.0.0: {} + attr-accept@2.2.5: {} + autoprefixer@10.4.14(postcss@8.4.38): dependencies: browserslist: 4.23.0 @@ -11938,6 +13152,8 @@ snapshots: boolbase@1.0.0: {} + bowser@2.11.0: {} + brace-expansion@1.1.11: dependencies: balanced-match: 1.0.2 @@ -13203,6 +14419,10 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-xml-parser@4.4.1: + dependencies: + strnum: 1.1.2 + fastq@1.15.0: dependencies: reusify: 1.0.4 @@ -13219,6 +14439,10 @@ snapshots: dependencies: flat-cache: 3.2.0 + file-selector@2.1.2: + dependencies: + tslib: 2.8.1 + filelist@1.0.4: dependencies: minimatch: 5.1.6 @@ -13284,7 +14508,7 @@ snapshots: framer-motion@10.17.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - tslib: 2.6.2 + tslib: 2.8.1 optionalDependencies: '@emotion/is-prop-valid': 0.8.8 react: 18.3.1 @@ -14781,14 +16005,6 @@ snapshots: - uglify-js - webpack - next-sitemap@4.2.3(next@14.2.28(@babel/core@7.24.5)(@opentelemetry/api@1.8.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)): - dependencies: - '@corex/deepmerge': 4.0.43 - '@next/env': 13.5.11 - fast-glob: 3.3.2 - minimist: 1.2.8 - next: 14.2.28(@babel/core@7.24.5)(@opentelemetry/api@1.8.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - next-themes@0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 @@ -15376,6 +16592,13 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-dropzone@14.3.8(react@18.3.1): + dependencies: + attr-accept: 2.2.5 + file-selector: 2.1.2 + prop-types: 15.8.1 + react: 18.3.1 + react-email@2.1.5(@opentelemetry/api@1.8.0)(@swc/helpers@0.5.5)(eslint@8.57.0): dependencies: '@babel/core': 7.24.5 @@ -15465,7 +16688,7 @@ snapshots: dependencies: react: 18.3.1 react-style-singleton: 2.2.1(@types/react@18.2.47)(react@18.3.1) - tslib: 2.6.2 + tslib: 2.8.1 optionalDependencies: '@types/react': 18.2.47 @@ -15473,7 +16696,7 @@ snapshots: dependencies: react: 18.3.1 react-style-singleton: 2.2.1(@types/react@18.3.3)(react@18.3.1) - tslib: 2.6.2 + tslib: 2.8.1 optionalDependencies: '@types/react': 18.3.3 @@ -15523,7 +16746,7 @@ snapshots: get-nonce: 1.0.1 invariant: 2.2.4 react: 18.3.1 - tslib: 2.6.2 + tslib: 2.8.1 optionalDependencies: '@types/react': 18.2.47 @@ -15532,7 +16755,7 @@ snapshots: get-nonce: 1.0.1 invariant: 2.2.4 react: 18.3.1 - tslib: 2.6.2 + tslib: 2.8.1 optionalDependencies: '@types/react': 18.3.3 @@ -16186,6 +17409,8 @@ snapshots: striptags@3.2.0: {} + strnum@1.1.2: {} + style-to-object@0.4.4: dependencies: inline-style-parser: 0.1.1 @@ -16626,14 +17851,14 @@ snapshots: use-callback-ref@1.3.0(@types/react@18.2.47)(react@18.3.1): dependencies: react: 18.3.1 - tslib: 2.6.2 + tslib: 2.8.1 optionalDependencies: '@types/react': 18.2.47 use-callback-ref@1.3.0(@types/react@18.3.3)(react@18.3.1): dependencies: react: 18.3.1 - tslib: 2.6.2 + tslib: 2.8.1 optionalDependencies: '@types/react': 18.3.3 @@ -16665,7 +17890,7 @@ snapshots: dependencies: detect-node-es: 1.1.0 react: 18.3.1 - tslib: 2.6.2 + tslib: 2.8.1 optionalDependencies: '@types/react': 18.2.47 @@ -16673,7 +17898,7 @@ snapshots: dependencies: detect-node-es: 1.1.0 react: 18.3.1 - tslib: 2.6.2 + tslib: 2.8.1 optionalDependencies: '@types/react': 18.3.3 diff --git a/prisma/migrations/20250702103024/migration.sql b/prisma/migrations/20250702103024/migration.sql new file mode 100644 index 0000000..6a7f546 --- /dev/null +++ b/prisma/migrations/20250702103024/migration.sql @@ -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://.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..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 存储桶配置' +); \ No newline at end of file diff --git a/prisma/migrations/20250705192109/migration.sql b/prisma/migrations/20250705192109/migration.sql new file mode 100644 index 0000000..688c7ff --- /dev/null +++ b/prisma/migrations/20250705192109/migration.sql @@ -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; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8c3e63d..eac6488 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") +} diff --git a/public/manifest.json b/public/manifest.json index bee3ead..8aed41e 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -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", diff --git a/public/robots.txt b/public/robots.txt deleted file mode 100644 index 218f4fa..0000000 --- a/public/robots.txt +++ /dev/null @@ -1,9 +0,0 @@ -# * -User-agent: * -Allow: / - -# Host -Host: https://wr.do - -# Sitemaps -Sitemap: https://wr.do/sitemap.xml diff --git a/public/site.webmanifest b/public/site.webmanifest index bee3ead..8aed41e 100644 --- a/public/site.webmanifest +++ b/public/site.webmanifest @@ -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", diff --git a/public/sitemap-0.xml b/public/sitemap-0.xml deleted file mode 100644 index 4f7143d..0000000 --- a/public/sitemap-0.xml +++ /dev/null @@ -1,6 +0,0 @@ - - -https://wr.do/manifest.json2025-06-29T11:28:45.465Zdaily0.7 -https://wr.do/robots.txt2025-06-29T11:28:45.465Zdaily0.7 -https://wr.do/opengraph-image.jpg2025-06-29T11:28:45.465Zdaily0.7 - \ No newline at end of file diff --git a/public/sitemap.xml b/public/sitemap.xml deleted file mode 100644 index 152c182..0000000 --- a/public/sitemap.xml +++ /dev/null @@ -1,4 +0,0 @@ - - -https://wr.do/sitemap-0.xml - \ No newline at end of file diff --git a/public/sw.js.map b/public/sw.js.map index 4fae946..a00e370 100644 --- a/public/sw.js.map +++ b/public/sw.js.map @@ -1 +1 @@ -{"version":3,"file":"sw.js","sources":["../../../../../../private/var/folders/9b/3qmyp8zd2xvdspdrp149fyg00000gn/T/1dd65d3378a56c5edfb8a5692cf5e1ab/sw.js"],"sourcesContent":["import {registerRoute as workbox_routing_registerRoute} from '/Users/songjunxi/Desktop/repos/wrdo-app/wr.do/node_modules/.pnpm/workbox-routing@6.6.0/node_modules/workbox-routing/registerRoute.mjs';\nimport {NetworkFirst as workbox_strategies_NetworkFirst} from '/Users/songjunxi/Desktop/repos/wrdo-app/wr.do/node_modules/.pnpm/workbox-strategies@6.6.0/node_modules/workbox-strategies/NetworkFirst.mjs';\nimport {NetworkOnly as workbox_strategies_NetworkOnly} from '/Users/songjunxi/Desktop/repos/wrdo-app/wr.do/node_modules/.pnpm/workbox-strategies@6.6.0/node_modules/workbox-strategies/NetworkOnly.mjs';\nimport {clientsClaim as workbox_core_clientsClaim} from '/Users/songjunxi/Desktop/repos/wrdo-app/wr.do/node_modules/.pnpm/workbox-core@6.6.0/node_modules/workbox-core/clientsClaim.mjs';/**\n * Welcome to your Workbox-powered service worker!\n *\n * You'll need to register this file in your web app.\n * See https://goo.gl/nhQhGp\n *\n * The rest of the code is auto-generated. Please don't update this file\n * directly; instead, make changes to your Workbox build configuration\n * and re-run your build process.\n * See https://goo.gl/2aRDsh\n */\n\n\nimportScripts(\n \n);\n\n\n\n\n\n\n\nself.skipWaiting();\n\nworkbox_core_clientsClaim();\n\n\n\nworkbox_routing_registerRoute(\"/\", new workbox_strategies_NetworkFirst({ \"cacheName\":\"start-url\", plugins: [{ cacheWillUpdate: async ({ request, response, event, state }) => { if (response && response.type === 'opaqueredirect') { return new Response(response.body, { status: 200, statusText: 'OK', headers: response.headers }) } return response } }] }), 'GET');\nworkbox_routing_registerRoute(/.*/i, new workbox_strategies_NetworkOnly({ \"cacheName\":\"dev\", plugins: [] }), 'GET');\n\n\n\n\n"],"names":["importScripts","self","skipWaiting","workbox_core_clientsClaim","workbox_routing_registerRoute","workbox_strategies_NetworkFirst","plugins","cacheWillUpdate","request","response","event","state","type","Response","body","status","statusText","headers","workbox_strategies_NetworkOnly"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgBAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAa,EAEZ,CAAA;EAQDC,CAAI,CAAA,CAAA,CAAA,CAACC,CAAW,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAE,CAAA;AAElBC,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAyB,EAAE,CAAA;AAI3BC,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAA6B,CAAC,CAAA,CAAA,CAAG,CAAE,CAAA,CAAA,CAAA,CAAA,CAAIC,oBAA+B,CAAC,CAAA;EAAE,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAW,EAAC,CAAW,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA;EAAEC,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAO,EAAE,CAAC,CAAA;GAAEC,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAe,EAAE,CAAO,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA;QAAEC,CAAO,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA;QAAEC,CAAQ,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA;QAAEC,CAAK,CAAA,CAAA,CAAA,CAAA,CAAA;AAAEC,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA;AAAM,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAC,CAAK,CAAA,CAAA,CAAA,CAAA,CAAA;EAAE,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAIF,QAAQ,CAAIA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAQ,CAACG,CAAI,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAK,gBAAgB,CAAE,CAAA,CAAA;AAAE,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,OAAO,CAAIC,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAQ,CAACJ,CAAQ,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAACK,IAAI,CAAE,CAAA,CAAA;EAAEC,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAM,EAAE,CAAG,CAAA,CAAA,CAAA;EAAEC,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAU,EAAE,CAAI,CAAA,CAAA,CAAA,CAAA;YAAEC,CAAO,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAER,CAAQ,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAACQ,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA;AAAQ,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAC,CAAC,CAAA;EAAC,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA;EAAE,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAOR,QAAQ,CAAA;EAAC,CAAA,CAAA,CAAA,CAAA,CAAA;KAAG,CAAA;AAAE,CAAA,CAAA,CAAC,CAAC,CAAA,CAAE,CAAK,CAAA,CAAA,CAAA,CAAA,CAAC,CAAA;AACxWL,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAA6B,CAAC,CAAA,CAAA,CAAA,CAAA,CAAK,CAAE,CAAA,CAAA,CAAA,CAAA,CAAIc,mBAA8B,CAAC,CAAA;EAAE,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAW,EAAC,CAAK,CAAA,CAAA,CAAA,CAAA,CAAA;EAAEZ,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAO,EAAE,CAAA,CAAA;EAAG,CAAC,CAAC,CAAE,CAAA,CAAA,CAAA,CAAA,CAAA,CAAK,CAAC,CAAA;;"} \ No newline at end of file +{"version":3,"file":"sw.js","sources":["../../../../../../private/var/folders/9b/3qmyp8zd2xvdspdrp149fyg00000gn/T/134a86773ec2a95b91090ef4e9e888d6/sw.js"],"sourcesContent":["import {registerRoute as workbox_routing_registerRoute} from '/Users/songjunxi/Desktop/repos/wrdo-app/wr.do/node_modules/.pnpm/workbox-routing@6.6.0/node_modules/workbox-routing/registerRoute.mjs';\nimport {NetworkFirst as workbox_strategies_NetworkFirst} from '/Users/songjunxi/Desktop/repos/wrdo-app/wr.do/node_modules/.pnpm/workbox-strategies@6.6.0/node_modules/workbox-strategies/NetworkFirst.mjs';\nimport {NetworkOnly as workbox_strategies_NetworkOnly} from '/Users/songjunxi/Desktop/repos/wrdo-app/wr.do/node_modules/.pnpm/workbox-strategies@6.6.0/node_modules/workbox-strategies/NetworkOnly.mjs';\nimport {clientsClaim as workbox_core_clientsClaim} from '/Users/songjunxi/Desktop/repos/wrdo-app/wr.do/node_modules/.pnpm/workbox-core@6.6.0/node_modules/workbox-core/clientsClaim.mjs';/**\n * Welcome to your Workbox-powered service worker!\n *\n * You'll need to register this file in your web app.\n * See https://goo.gl/nhQhGp\n *\n * The rest of the code is auto-generated. Please don't update this file\n * directly; instead, make changes to your Workbox build configuration\n * and re-run your build process.\n * See https://goo.gl/2aRDsh\n */\n\n\nimportScripts(\n \n);\n\n\n\n\n\n\n\nself.skipWaiting();\n\nworkbox_core_clientsClaim();\n\n\n\nworkbox_routing_registerRoute(\"/\", new workbox_strategies_NetworkFirst({ \"cacheName\":\"start-url\", plugins: [{ cacheWillUpdate: async ({ request, response, event, state }) => { if (response && response.type === 'opaqueredirect') { return new Response(response.body, { status: 200, statusText: 'OK', headers: response.headers }) } return response } }] }), 'GET');\nworkbox_routing_registerRoute(/.*/i, new workbox_strategies_NetworkOnly({ \"cacheName\":\"dev\", plugins: [] }), 'GET');\n\n\n\n\n"],"names":["importScripts","self","skipWaiting","workbox_core_clientsClaim","workbox_routing_registerRoute","workbox_strategies_NetworkFirst","plugins","cacheWillUpdate","request","response","event","state","type","Response","body","status","statusText","headers","workbox_strategies_NetworkOnly"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgBAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAa,EAEZ,CAAA;EAQDC,CAAI,CAAA,CAAA,CAAA,CAACC,CAAW,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAE,CAAA;AAElBC,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAyB,EAAE,CAAA;AAI3BC,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAA6B,CAAC,CAAA,CAAA,CAAG,CAAE,CAAA,CAAA,CAAA,CAAA,CAAIC,oBAA+B,CAAC,CAAA;EAAE,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAW,EAAC,CAAW,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA;EAAEC,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAO,EAAE,CAAC,CAAA;GAAEC,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAe,EAAE,CAAO,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA;QAAEC,CAAO,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA;QAAEC,CAAQ,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA;QAAEC,CAAK,CAAA,CAAA,CAAA,CAAA,CAAA;AAAEC,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA;AAAM,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAC,CAAK,CAAA,CAAA,CAAA,CAAA,CAAA;EAAE,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAIF,QAAQ,CAAIA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAQ,CAACG,CAAI,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAK,gBAAgB,CAAE,CAAA,CAAA;AAAE,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,OAAO,CAAIC,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAQ,CAACJ,CAAQ,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAACK,IAAI,CAAE,CAAA,CAAA;EAAEC,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAM,EAAE,CAAG,CAAA,CAAA,CAAA;EAAEC,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAU,EAAE,CAAI,CAAA,CAAA,CAAA,CAAA;YAAEC,CAAO,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAER,CAAQ,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAACQ,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA;AAAQ,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAC,CAAC,CAAA;EAAC,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA;EAAE,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAOR,QAAQ,CAAA;EAAC,CAAA,CAAA,CAAA,CAAA,CAAA;KAAG,CAAA;AAAE,CAAA,CAAA,CAAC,CAAC,CAAA,CAAE,CAAK,CAAA,CAAA,CAAA,CAAA,CAAC,CAAA;AACxWL,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAA6B,CAAC,CAAA,CAAA,CAAA,CAAA,CAAK,CAAE,CAAA,CAAA,CAAA,CAAA,CAAIc,mBAA8B,CAAC,CAAA;EAAE,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAW,EAAC,CAAK,CAAA,CAAA,CAAA,CAAA,CAAA;EAAEZ,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAAA,CAAO,EAAE,CAAA,CAAA;EAAG,CAAC,CAAC,CAAE,CAAA,CAAA,CAAA,CAAA,CAAA,CAAK,CAAC,CAAA;;"} \ No newline at end of file