commit 500e8b74a7476387421d975e3109966aff1265d1 Author: hanxuanyu <2252193204@qq.com> Date: Wed Jan 28 20:44:34 2026 +0800 Initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..afeaabe --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +.git +.idea +.vscode +bin +obj +output +logs +storage_data +*.db +*.exe +*.tar.gz +docker-compose.yaml +Dockerfile +.dockerignore +README.md +README_en.md +scripts/ +webapp/node_modules +webapp/dist +web diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..33bdf10 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,37 @@ +name: Build + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +jobs: + build: + name: Build Check + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + cache: true + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: webapp/package-lock.json + + - name: Build Frontend + run: | + cd webapp + npm install + npm run build + + - name: Build Go + run: go build -v . diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..dc76e1d --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,46 @@ +name: Docker Publish + +on: + push: + tags: + - 'v*' + +jobs: + build-and-push: + runs-on: ubuntu-latest + environment: prod + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.DOCKER_HUB_USERNAME }}/filerelay + tags: | + type=ref,event=tag + type=raw,value=latest + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + platforms: linux/amd64,linux/arm64 + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a81f39b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,45 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + release: + name: Build and Release + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + cache: true + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: webapp/package-lock.json + + - name: Build Multi-Platform + run: | + chmod +x scripts/build.sh + ./scripts/build.sh + + - name: Release + uses: softprops/action-gh-release@v2 + with: + files: output/*.tar.gz + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..26ef19c --- /dev/null +++ b/.gitignore @@ -0,0 +1,87 @@ +# ========================= +# Go 常用 .gitignore +# ========================= + +# 编译产物 / 可执行文件 +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.a +*.o +*.out +*.test +*.prof +*.pprof +*.cover +*.cov +*.trace + +# Go workspace / 依赖缓存(本地开发常见,不建议入库) +/bin/ +/pkg/ +/dist/ +/build/ +/out/ + +# Go build cache(通常不需要忽略;如你有需要可开启) +# /tmp/ +# /cache/ + +# 调试/日志/临时文件 +*.log +*.tmp +*.swp +*.swo +*.bak +*.old +*.pid + +# 运行时数据/本地数据 +*.db +*.sqlite +*.sqlite3 +data/ +tmp/ + +# 环境变量与配置(按需:如果你会提交示例配置,建议仅忽略真实配置文件) +.env +.env.* +!.env.example +config.local.* +*.local.yaml +*.local.yml +*.local.json + +# Go 测试覆盖率 +coverage.out +cover.out + +# vendor(Go Modules 通常不提交 vendor;如你需要 vendor 则删除这一行) +/vendor/ + +# 工具生成文件 +*.gen.go + +# IDE / 编辑器 +.idea/ +.vscode/ +*.code-workspace + +# macOS / Windows +.DS_Store +Thumbs.db +desktop.ini + +# Vim / Emacs +*~ +\#*\# +.\#* + +# GoLand/IntelliJ +*.iml +/storage_data/ +/logs/ +/output/ +/web/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..570dd40 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,50 @@ +# Frontend build stage +FROM node:20-alpine AS frontend-builder +ARG NPM_REGISTRY +# 如果设置了 NPM_REGISTRY,则配置 npm 镜像 +RUN if [ -n "$NPM_REGISTRY" ]; then npm config set registry $NPM_REGISTRY; fi + +WORKDIR /webapp +# 仅拷贝包定义文件以利用缓存 +COPY webapp/package*.json ./ +RUN npm ci +# 拷贝前端源码并构建 +COPY webapp/ ./ +RUN npm run build + +# Backend build stage +FROM golang:1.24-alpine AS builder +ARG GOPROXY +ENV GOPROXY=$GOPROXY +WORKDIR /app +# 仅拷贝依赖文件以利用缓存 +COPY go.mod go.sum ./ +RUN go mod download +# 拷贝后端源码(不包含 webapp) +COPY internal/ ./internal/ +COPY main.go . +COPY docs/ ./docs/ +# 从前端构建阶段拷贝产物到 web 目录(Go embed 需要) +COPY --from=frontend-builder /web ./web +# 编译二进制,移除调试信息并进行静态链接优化 +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o filerelay main.go + +# Final stage +FROM alpine:3.21 +WORKDIR /app +# 合并指令以减少层数,安装必要包并创建所需目录 +RUN apk add --no-cache ca-certificates mailcap && \ + mkdir -p config data/storage_data data/logs +# 仅拷贝编译后的二进制文件,前端资源已通过 Go embed 包含在内 +COPY --from=builder /app/filerelay . + +EXPOSE 8080 +# 合并环境变量定义 +ENV FR_SITE_PORT=8080 \ + FR_STORAGE_LOCAL_PATH=data/storage_data \ + FR_DB_TYPE=sqlite \ + FR_DB_PATH=data/file_relay.db \ + FR_LOG_LEVEL=info \ + FR_LOG_FILE_PATH=data/logs/app.log + +CMD ["./filerelay"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..d0023f9 --- /dev/null +++ b/README.md @@ -0,0 +1,167 @@ +# FileRelay - 文件暂存柜 + +[![Build Status](https://github.com/hanxuanyu/FileRelay/actions/workflows/build.yml/badge.svg)](https://github.com/hanxuanyu/FileRelay/actions/workflows/build.yml) +[![Release Status](https://github.com/hanxuanyu/FileRelay/actions/workflows/release.yml/badge.svg)](https://github.com/hanxuanyu/FileRelay/actions/workflows/release.yml) +[![Docker Image](https://github.com/hanxuanyu/FileRelay/actions/workflows/docker-publish.yml/badge.svg)](https://github.com/hanxuanyu/FileRelay/actions/workflows/docker-publish.yml) + +中文 | [English](README_en.md) + +--- + +FileRelay 是一个简单高效、自托管的文件暂存柜系统,旨在为您提供便捷的临时文件分享和中转服务。 + +### 🚀 主要特性 + +- **临时分享**:上传文件或文本,生成唯一取件码,方便在不同设备间快速传输。 +- **多数据库支持**:支持 SQLite, MySQL 和 PostgreSQL,默认为 SQLite 开箱即用,生产环境可无缝切换至 MySQL 或 PostgreSQL。 +- **多种存储后端**:支持本地磁盘存储、Amazon S3(及兼容 S3 的服务)以及 WebDAV 存储。 +- **灵活的过期策略**:支持按时间过期和按下载次数过期(需在管理后台配置或手动清理)。 +- **取件保护**:支持自定义取件码长度,内置尝试次数限制防暴力破解。 +- **管理后台**:内置美观的管理界面,支持: + - 动态修改系统配置。 + - 实时查看和管理上传批次。 + - API Token 管理(支持权限细分:上传、取件、管理)。 +- **高性能**:采用 Go 语言编写,内存占用极低,支持高并发访问。 +- **易于部署**:支持单一二进制文件运行,前端资源已嵌入,无需额外配置 Web 服务器。 + +### 🛠️ 技术栈 + +- **后端**: Go 1.24+ (Gin, GORM) +- **数据库**: SQLite, MySQL, PostgreSQL (支持多种数据库,灵活扩展) +- **前端**: Vue 3 + TailwindCSS (位于 `webapp` 目录),已通过 `embed` 嵌入二进制。 +- **文档**: 集成 Swagger UI。 + +### 🏗️ 开发与构建 + +#### 环境要求 + +- **Go**: 1.24 或更高版本 +- **Node.js**: 20 或更高版本 (用于前端构建) +- **npm**: 随 Node.js 安装 + +#### 本地开发 + +1. **前端开发**: + ```bash + cd webapp + npm install + npm run dev + ``` + 前端开发服务器默认运行在 `http://localhost:5173`。 + +2. **后端开发**: + ```bash + # 返回项目根目录 + go run main.go -config config/config.yaml + ``` + +#### 完整构建 + +项目提供了自动化的构建脚本,会自动完成前端构建、资源嵌入以及 Go 编译: + +- **Windows (CMD)**: `scripts\build.bat` +- **Windows (PowerShell)**: `.\scripts\build.ps1` +- **Linux/macOS**: `chmod +x scripts/build.sh && ./scripts/build.sh` + +构建产物将存放在 `output` 目录下。 + +### 📦 快速开始 + +#### 1. 获取程序 + +您可以从 Release 页面下载对应平台的二进制文件,或者按照上述“完整构建”步骤自行编译。 + +#### 2. 配置文件 + +在运行之前,请根据需求修改 `config/config.yaml`。详细配置说明请参考 [docs/config_specification.md](docs/config_specification.md)。 + +主要配置项包括: +- 存储类型 (`local`, `s3`, `webdav`)。 +- 管理员密码哈希。 +- 数据库配置 (`type`, `host`, `port`, `user`, `password`, `dbname` 等)。系统支持自动迁移,首次连接新数据库时将自动创建表结构。此外,`scripts/sql/` 目录下也提供了各数据库的初始化 SQL 脚本供参考。 + +#### 3. 运行 + +```bash +./filerelay -config config/config.yaml +``` + +### 🐳 Docker 部署 + +我们提供了 Docker 和 Docker Compose 支持,实现一键部署: + +#### 使用 Docker 镜像 (推荐) + +如果您不想自行构建,可以直接从 Docker Hub 拉取已构建好的镜像: + +```bash +docker run -d \ + --name filerelay \ + -p 8080:8080 \ + -v $(pwd)/data:/app/data \ + -v $(pwd)/config:/app/config \ + hxuanyu521/filerelay:latest +``` + +**提示:** +- 如果您需要使用自定义的 Web 页面(外置前端资源),可以将资源目录挂载到容器内,并通过环境变量 `FR_WEB_PATH` 指定路径。例如: + `-v $(pwd)/my-web:/app/my-web -e FR_WEB_PATH=/app/my-web` +- 默认情况下,程序会优先寻找 `FR_WEB_PATH`(或配置文件中的 `web.path`)指定的外部资源,若找不到则使用内置的嵌入资源。 + +#### 使用 Docker Compose + +1. 确保已安装 Docker 和 Docker Compose。 +2. 在项目根目录下运行: + +```bash +docker-compose up -d +``` + +程序默认会在当前目录下的 `./data` 文件夹中持久化存储数据、日志、数据库和配置。 + +#### 环境变量控制 + +支持通过环境变量覆盖配置文件中的关键项,方便在 Docker 环境下动态调整: + +| 环境变量 | 说明 | 默认值 | +| :--- | :--- | :--- | +| **站点设置** | | | +| `FR_SITE_NAME` | 站点名称 | 文件暂存柜 | +| `FR_SITE_BASE_URL` | 站点外部访问地址 (用于生成分享链接) | (空) | +| `FR_SITE_PORT` | 服务监听端口 | 8080 | +| **安全设置** | | | +| `FR_SECURITY_JWT_SECRET` | JWT 签名密钥 | file-relay-secret | +| **上传设置** | | | +| `FR_UPLOAD_MAX_SIZE` | 单个文件最大大小 (MB) | 100 | +| `FR_UPLOAD_RETENTION_DAYS` | 文件最大保留天数 | 30 | +| **数据库设置** | | | +| `FR_DB_TYPE` | 数据库类型 (sqlite, mysql, postgres) | sqlite | +| `FR_DB_PATH` | SQLite 数据库文件路径 | data/file_relay.db | +| `FR_DB_HOST` | 数据库主机地址 (MySQL/Postgres) | (空) | +| `FR_DB_PORT` | 数据库端口 (MySQL/Postgres) | (空) | +| `FR_DB_USER` | 数据库用户名 (MySQL/Postgres) | (空) | +| `FR_DB_PASSWORD` | 数据库密码 (MySQL/Postgres) | (空) | +| `FR_DB_NAME` | 数据库名称 (MySQL/Postgres) | (空) | +| **存储设置** | | | +| `FR_STORAGE_TYPE` | 存储类型 (local, s3, webdav) | local | +| `FR_STORAGE_LOCAL_PATH` | 本地存储路径 | data/storage_data | +| **日志设置** | | | +| `FR_LOG_LEVEL` | 日志级别 (debug, info, warn, error) | info | +| `FR_LOG_FILE_PATH` | 日志文件路径 | data/logs/app.log | +| **Web 设置** | | | +| `FR_WEB_PATH` | 外部 Web 静态资源路径 | web | + +程序启动后,您可以通过以下地址访问(默认端口 8080): +- **Web 界面**: `http://localhost:8080` +- **管理后台**: `http://localhost:8080/admin` (前端路由) +- **API 文档**: `http://localhost:8080/swagger/index.html` + +### 📖 接口说明 + +FileRelay 提供了丰富的 API 接口,支持通过 API Token 进行集成。 + +- **上传**: `POST /api/batches` +- **取件**: `GET /api/batches/:pickup_code` +- **下载**: `GET /api/files/:file_id/download` + +详细接口定义请参考内置的 Swagger 文档。 diff --git a/README_en.md b/README_en.md new file mode 100644 index 0000000..cab8066 --- /dev/null +++ b/README_en.md @@ -0,0 +1,167 @@ +# FileRelay - File Relay Station + +[![Build Status](https://github.com/hanxuanyu/FileRelay/actions/workflows/build.yml/badge.svg)](https://github.com/hanxuanyu/FileRelay/actions/workflows/build.yml) +[![Release Status](https://github.com/hanxuanyu/FileRelay/actions/workflows/release.yml/badge.svg)](https://github.com/hanxuanyu/FileRelay/actions/workflows/release.yml) +[![Docker Image](https://github.com/hanxuanyu/FileRelay/actions/workflows/docker-publish.yml/badge.svg)](https://github.com/hanxuanyu/FileRelay/actions/workflows/docker-publish.yml) + +[中文](README.md) | English + +--- + +FileRelay is a simple, efficient, self-hosted file relay/temporary storage system designed to provide convenient temporary file sharing and transfer services. + +### 🚀 Key Features + +- **Temporary Sharing**: Upload files or text and generate a unique pickup code for quick transfer across different devices. +- **Multi-database Support**: Supports SQLite, MySQL, and PostgreSQL. Defaults to SQLite for out-of-the-box use, with seamless switching to MySQL or PostgreSQL for production. +- **Multiple Storage Backends**: Supports local disk storage, Amazon S3 (and S3-compatible services), and WebDAV. +- **Flexible Expiration**: Supports expiration by time and download counts (configurable in admin or via manual cleanup). +- **Pickup Protection**: Customizable pickup code length and built-in rate limiting to prevent brute-force attacks. +- **Admin Dashboard**: Built-in management interface for: + - Dynamic system configuration updates. + - Real-time batch management (view, delete, clean). + - API Token management with granular scopes (upload, pickup, admin). +- **High Performance**: Written in Go with low memory footprint and high concurrency support. +- **Easy Deployment**: Single binary execution with embedded frontend assets, no extra Web server needed. + +### 🛠️ Tech Stack + +- **Backend**: Go 1.24+ (Gin, GORM) +- **Database**: SQLite, MySQL, PostgreSQL (supports multiple databases for scalability) +- **Frontend**: Vue 3 + TailwindCSS (located in `webapp` directory), embedded into binary via `embed`. +- **Documentation**: Integrated Swagger UI. + +### 🏗️ Development & Build + +#### Prerequisites + +- **Go**: 1.24 or higher +- **Node.js**: 20 or higher (for frontend build) +- **npm**: Installed with Node.js + +#### Local Development + +1. **Frontend**: + ```bash + cd webapp + npm install + npm run dev + ``` + The frontend dev server runs at `http://localhost:5173` by default. + +2. **Backend**: + ```bash + # Go back to the project root + go run main.go -config config/config.yaml + ``` + +#### Full Build + +The project provides automated build scripts that handle frontend building, asset embedding, and Go compilation: + +- **Windows (CMD)**: `scripts\build.bat` +- **Windows (PowerShell)**: `.\scripts\build.ps1` +- **Linux/macOS**: `chmod +x scripts/build.sh && ./scripts/build.sh` + +The build artifacts will be stored in the `output` directory. + +### 📦 Quick Start + +#### 1. Get the Program + +Download the binary for your platform from the Release page, or build it yourself using the "Full Build" steps above. + +#### 2. Configuration + +Modify `config/config.yaml` before running. See [docs/config_specification.md](docs/config_specification.md) for detailed field descriptions. + +Key settings: +- Storage type (`local`, `s3`, `webdav`). +- Admin password hash. +- Database configuration (`type`, `host`, `port`, `user`, `password`, `dbname`, etc.). The system supports auto-migration and will create tables automatically on the first connection. Manual SQL scripts are also available in the `scripts/sql/` directory. + +#### 3. Running + +```bash +./filerelay -config config/config.yaml +``` + +### 🐳 Docker Deployment + +We provide Docker and Docker Compose support for one-click deployment: + +#### Using Docker Image (Recommended) + +If you don't want to build it yourself, you can pull the pre-built image from Docker Hub: + +```bash +docker run -d \ + --name filerelay \ + -p 8080:8080 \ + -v $(pwd)/data:/app/data \ + -v $(pwd)/config:/app/config \ + hxuanyu521/filerelay:latest +``` + +**Tips:** +- If you need to use custom Web pages (external frontend assets), you can mount the assets directory into the container and specify the path via the `FR_WEB_PATH` environment variable. For example: + `-v $(pwd)/my-web:/app/my-web -e FR_WEB_PATH=/app/my-web` +- By default, the program will first look for external assets specified by `FR_WEB_PATH` (or `web.path` in the config file), and fallback to embedded assets if not found. + +#### Using Docker Compose + +1. Ensure Docker and Docker Compose are installed. +2. Run in the project root: + +```bash +docker-compose up -d +``` + +By default, data, logs, database, and configuration will be persisted in the `./data` folder in the current directory. + +#### Environment Variables + +You can override key configuration items using environment variables, which is convenient for dynamic adjustments in Docker: + +| Env Var | Description | Default | +| :--- | :--- | :--- | +| **Site Settings** | | | +| `FR_SITE_NAME` | Site Name | 文件暂存柜 | +| `FR_SITE_BASE_URL` | Site Base URL (for sharing links) | (empty) | +| `FR_SITE_PORT` | Service Port | 8080 | +| **Security Settings** | | | +| `FR_SECURITY_JWT_SECRET` | JWT Secret | file-relay-secret | +| **Upload Settings** | | | +| `FR_UPLOAD_MAX_SIZE` | Max File Size (MB) | 100 | +| `FR_UPLOAD_RETENTION_DAYS` | Max Retention Days | 30 | +| **Database Settings** | | | +| `FR_DB_TYPE` | DB Type (sqlite, mysql, postgres) | sqlite | +| `FR_DB_PATH` | SQLite DB Path | data/file_relay.db | +| `FR_DB_HOST` | DB Host (MySQL/Postgres) | (empty) | +| `FR_DB_PORT` | DB Port (MySQL/Postgres) | (empty) | +| `FR_DB_USER` | DB User (MySQL/Postgres) | (empty) | +| `FR_DB_PASSWORD` | DB Password (MySQL/Postgres) | (empty) | +| `FR_DB_NAME` | DB Name (MySQL/Postgres) | (empty) | +| **Storage Settings** | | | +| `FR_STORAGE_TYPE` | Storage Type (local, s3, webdav) | local | +| `FR_STORAGE_LOCAL_PATH` | Local Storage Path | data/storage_data | +| **Log Settings** | | | +| `FR_LOG_LEVEL` | Log Level (debug, info, warn, error) | info | +| `FR_LOG_FILE_PATH` | Log File Path | data/logs/app.log | +| **Web Settings** | | | +| `FR_WEB_PATH` | External Web Assets Path | web | + +Access via: +- **Web UI**: `http://localhost:8080` +- **Admin Panel**: `http://localhost:8080/admin` +- **API Docs**: `http://localhost:8080/swagger/index.html` + +### 📖 API Reference + +FileRelay provides a rich set of APIs for integration via API Tokens. + +- **Upload**: `POST /api/batches` +- **Pickup**: `GET /api/batches/:pickup_code` +- **Download**: `GET /api/files/:file_id/download` + +Refer to the built-in Swagger documentation for details. diff --git a/config/config.yaml b/config/config.yaml new file mode 100644 index 0000000..a9e52bb --- /dev/null +++ b/config/config.yaml @@ -0,0 +1,60 @@ +site: + name: 文件暂存柜 + description: 临时文件中转服务 + logo: /favicon.png + base_url: "" + port: 8080 +security: + admin_password_hash: $2a$10$Bm0TEmU4uj.bVHYiIPFBheUkcdg6XHpsanLvmpoAtgU1UnKbo9.vy + pickup_code_length: 6 + pickup_fail_limit: 5 + jwt_secret: file-relay-secret +upload: + max_file_size_mb: 100 + max_batch_files: 20 + max_retention_days: 30 + require_token: true +storage: + type: local + local: + path: data/storage_data + webdav: + url: https://dav.example.com + username: user + password: pass + root: /file-relay + s3: + endpoint: s3.amazonaws.com + region: us-east-1 + access_key: your-access-key + secret_key: your-secret-key + bucket: file-relay-bucket + use_ssl: false +api_token: + enabled: true + allow_admin_api: true + max_tokens: 20 +database: + type: sqlite + path: data/file_relay.db + # mysql 示例: + # type: mysql + # host: 127.0.0.1 + # port: 3306 + # user: root + # password: password + # dbname: file_relay + # config: charset=utf8mb4&parseTime=True&loc=Local + # postgres 示例: + # type: postgres + # host: 127.0.0.1 + # port: 5432 + # user: postgres + # password: password + # dbname: file_relay + # config: sslmode=disable +web: + path: web +log: + level: debug + file_path: data/logs/app.log diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..076a0a4 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,23 @@ +services: + filerelay: + build: + context: . + args: + - GOPROXY=${GOPROXY:-https://proxy.golang.org,direct} + - NPM_REGISTRY=${NPM_REGISTRY:-https://registry.npmjs.org/} + image: filerelay:latest + container_name: filerelay + ports: + - "${HOST_PORT:-8080}:${FR_SITE_PORT:-8080}" + volumes: + - ./data:/app/data + - ./data/config:/app/config + environment: + - TZ=${TZ:-Asia/Shanghai} + - FR_SITE_NAME=${FR_SITE_NAME:-文件暂存柜} + - FR_SITE_PORT=${FR_SITE_PORT:-8080} + - FR_LOG_LEVEL=${FR_LOG_LEVEL:-info} + - FR_DB_PATH=${FR_DB_PATH:-data/file_relay.db} + - FR_STORAGE_LOCAL_PATH=${FR_STORAGE_LOCAL_PATH:-data/storage_data} + - FR_LOG_FILE_PATH=${FR_LOG_FILE_PATH:-data/logs/app.log} + restart: unless-stopped \ No newline at end of file diff --git a/docs/config_specification.md b/docs/config_specification.md new file mode 100644 index 0000000..f2da1df --- /dev/null +++ b/docs/config_specification.md @@ -0,0 +1,143 @@ +# FileRelay 配置项详细说明文档 + +本文档整理了 FileRelay 系统 `config.yaml` 配置文件中各字段的含义、类型及示例,供前端配置页面开发参考。 + +## 1. 站点设置 (site) +用于定义前端展示的站点基本信息。 + +| 字段名 | 类型 | 含义 | 示例 | +| :--- | :--- | :--- | :--- | +| `name` | string | 站点名称,显示在网页标题和页头 | `文件暂存柜` | +| `description` | string | 站点描述,显示在首页或元标签中 | `临时文件中转服务` | +| `logo` | string | 站点 Logo 的 URL 地址 | `/logo.png` | +| `base_url` | string | 站点外部访问地址。若配置则固定使用该地址拼接直链;若留空,系统将尝试从请求头(如 `X-Forwarded-Proto`, `:scheme`, `Forwarded` 等)或 `Referer` 中自动检测协议及主机名,以确保在 HTTPS 代理环境下链接正确。 | `https://file.example.com` | +| `port` | int | 后端服务监听端口 | `8080` | + +## 2. 安全设置 (security) +涉及系统鉴权、取件保护相关的核心安全配置。 + +| 字段名 | 类型 | 含义 | 示例 | +| :--- | :--- | :--- | :--- | +| `admin_password_hash` | string | 管理员密码的 bcrypt 哈希值。可以通过更新配置接口修改,修改后立即生效,且不再依赖数据库存储。 | `$2a$10$...` | +| `pickup_code_length` | int | 自动生成的取件码长度。变更后系统将自动对存量取件码进行右侧补零或截取以适配新长度。 | `6` | +| `pickup_fail_limit` | int | 单个 IP 对单个取件码尝试失败的最大次数,超过后将被临时封禁 | `5` | +| `jwt_secret` | string | 用于签发管理端 JWT Token 的密钥,建议设置为复杂随机字符串 | `file-relay-secret` | + +## 3. 上传设置 (upload) +控制文件上传的限制和策略。 + +| 字段名 | 类型 | 含义 | 示例 | +| :--- | :--- | :--- | :--- | +| `max_file_size_mb` | int64 | 单个文件的最大允许大小(单位:MB) | `100` | +| `max_batch_files` | int | 一个取件批次中允许包含的最大文件数量 | `20` | +| `max_retention_days` | int | 文件在服务器上的最长保留天数(针对 time 类型的过期策略) | `30` | +| `require_token` | bool | 是否强制要求提供 API Token 才能进行上传操作 | `false` | + +## 4. 存储设置 (storage) +定义文件的实际物理存储方式。系统支持多种存储后端。 + +| 字段名 | 类型 | 含义 | 示例 | +| :--- | :--- | :--- | :--- | +| `type` | string | 当前激活的存储类型。可选值:`local`, `webdav`, `s3` | `local` | + +### 4.1 本地存储 (local) +当 `type` 为 `local` 时生效。 + +| 字段名 | 类型 | 含义 | 示例 | +| :--- | :--- | :--- | :--- | +| `path` | string | 文件存储在服务器本地的相对或绝对路径 | `data/storage_data` | + +### 4.2 WebDAV 存储 (webdav) +当 `type` 为 `webdav` 时生效。 + +| 字段名 | 类型 | 含义 | 示例 | +| :--- | :--- | :--- | :--- | +| `url` | string | WebDAV 服务器的 API 地址 | `https://dav.example.com` | +| `username` | string | WebDAV 登录用户名 | `user` | +| `password` | string | WebDAV 登录密码 | `pass` | +| `root` | string | WebDAV 上的基础存储根目录 | `/file-relay` | + +### 4.3 S3 存储 (s3) +当 `type` 为 `s3` 时生效。 + +| 字段名 | 类型 | 含义 | 示例 | +| :--- | :--- | :--- | :--- | +| `endpoint` | string | S3 服务端点 | `s3.amazonaws.com` | +| `region` | string | S3 区域 | `us-east-1` | +| `access_key` | string | S3 Access Key | `your-access-key` | +| `secret_key` | string | S3 Secret Key | `your-secret-key` | +| `bucket` | string | S3 存储桶名称 | `file-relay-bucket` | +| `use_ssl` | bool | 是否强制使用 SSL (HTTPS) 连接 | `false` | + +## 5. API Token 设置 (api_token) +控制系统对外开放的 API Token 管理功能。 + +| 字段名 | 类型 | 含义 | 示例 | +| :--- | :--- | :--- | :--- | +| `enabled` | bool | 是否启用 API Token 功能模块 | `true` | +| `allow_admin_api` | bool | 是否允许具备 `admin` 权限的 API Token 访问管理接口 | `true` | +| `max_tokens` | int | 系统允许创建的 API Token 最大总数限制 | `20` | + +### 5.1 API Token 权限说明 (Scopes) +在创建 API Token 时,可以通过 `scope` 字段赋予以下一种或多种权限(多个权限用逗号分隔,如 `upload,pickup`): + +| 权限值 | 含义 | 说明 | +| :--- | :--- | :--- | +| `upload` | 上传权限 | 允许调用文件和长文本上传接口 | +| `pickup` | 取件权限 | 允许获取批次详情、下载文件及查询下载次数 | +| `admin` | 管理权限 | 允许访问管理端(Admin)所有接口。需开启 `allow_admin_api` 且 Token 功能已启用 | + +## 6. Web 前端设置 (web) +定义前端静态资源的加载方式。 + +| 字段名 | 类型 | 含义 | 示例 | +| :--- | :--- | :--- | :--- | +| `path` | string | 外部前端资源目录路径。若该路径存在且包含 `index.html`,系统将优先使用此目录;否则回退使用内置前端资源。 | `web` | + +## 7. 数据库设置 (database) +系统元数据存储配置。支持 SQLite, MySQL 和 PostgreSQL。 + +**特性说明**: +- **动态切换**:通过管理接口更新数据库配置后,系统会自动尝试连接新数据库,无需重启应用。 +- **自动迁移**:切换数据库时,系统会自动将原数据库中的元数据(如存量批次、文件记录、API Token 等)同步迁移至新数据库中。 + +| 字段名 | 类型 | 含义 | 示例 | +| :--- | :--- | :--- | :--- | +| `type` | string | 数据库类型。可选值:`sqlite`, `mysql`, `postgres` | `sqlite` | +| `path` | string | SQLite 数据库文件的路径 (仅在 `type` 为 `sqlite` 时生效) | `data/file_relay.db` | +| `host` | string | 数据库地址 (MySQL/PostgreSQL) | `127.0.0.1` | +| `port` | int | 数据库端口 (MySQL/PostgreSQL) | `3306` 或 `5432` | +| `user` | string | 数据库用户名 (MySQL/PostgreSQL) | `root` | +| `password` | string | 数据库密码 (MySQL/PostgreSQL) | `password` | +| `dbname` | string | 数据库名称 (MySQL/PostgreSQL) | `file_relay` | +| `config` | string | 额外 DSN 配置参数。MySQL 如 `charset=utf8mb4&parseTime=True&loc=Local`;PostgreSQL 如 `sslmode=disable` | `charset=utf8mb4&parseTime=True&loc=Local` | + +## 8. 日志设置 (log) +控制系统日志的输出级别和目的地。 + +| 字段名 | 类型 | 含义 | 示例 | +| :--- | :--- | :--- | :--- | +| `level` | string | 日志级别。可选值:`debug`, `info`, `warn`, `error` | `info` | +| `file_path` | string | 日志文件路径。如果设置,日志将同时输出到控制台和该文件;若为空则仅输出到控制台。 | `data/logs/app.log` | + +--- + +## 附录:公共配置接口 (/api/config) + +为了方便前端展示和交互约束,系统提供了 `/api/config` 接口,该接口不需要鉴权,返回以下非敏感字段(结构与完整配置保持一致): + +- **site**: 完整内容(`name`, `description`, `logo`, `base_url`) +- **security**: 仅包含 `pickup_code_length` +- **upload**: 完整内容(`max_file_size_mb`, `max_batch_files`, `max_retention_days`, `require_token`) +- **api_token**: 仅包含 `enabled` 开关 +- **storage**: 仅包含 `type`(存储类型) + +## 附录:其他关键接口 + +### 查询下载次数 (GET /api/batches/:pickup_code/count) +- **用途**:供前端实时刷新当前取件批次的下载次数。 +- **特性**:支持查询已过期的文件。 + +### 恢复 API Token (POST /api/admin/api-tokens/:id/recover) +- **权限**:需要管理员权限。 +- **用途**:将状态为“已撤销”的 Token 重新恢复为有效状态。 diff --git a/docs/docs.go b/docs/docs.go new file mode 100644 index 0000000..ca07dda --- /dev/null +++ b/docs/docs.go @@ -0,0 +1,1738 @@ +// Package docs Code generated by swaggo/swag. DO NOT EDIT +package docs + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "name": "API Support", + "url": "http://www.swagger.io/support", + "email": "support@swagger.io" + }, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/api/admin/api-tokens": { + "get": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "获取系统中所有 API Token 的详细信息(不包含哈希)", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "获取 API Token 列表", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/model.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.APIToken" + } + } + } + } + ] + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + }, + "post": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "创建一个新的 API Token,返回原始 Token(仅显示一次)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "创建 API Token", + "parameters": [ + { + "description": "Token 信息", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/admin.CreateTokenRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/model.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/admin.CreateTokenResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/admin/api-tokens/{id}": { + "delete": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "根据 ID 永久删除 API Token", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "删除 API Token", + "parameters": [ + { + "type": "integer", + "description": "Token ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/admin/api-tokens/{id}/recover": { + "post": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "将已撤销的 API Token 恢复为有效状态", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "恢复 API Token", + "parameters": [ + { + "type": "integer", + "description": "Token ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/admin/api-tokens/{id}/revoke": { + "post": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "将 API Token 标记为已撤销,使其失效但保留记录", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "撤销 API Token", + "parameters": [ + { + "type": "integer", + "description": "Token ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/admin/batches": { + "get": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "分页查询所有文件批次,支持按状态过滤和取件码模糊搜索", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "获取批次列表", + "parameters": [ + { + "type": "integer", + "description": "页码 (默认 1)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量 (默认 20)", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "状态 (active/expired/deleted)", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "取件码 (模糊搜索)", + "name": "pickup_code", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/model.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/admin.ListBatchesResponse" + } + } + } + ] + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/admin/batches/clean": { + "post": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "手动扫描并物理删除所有已过期或标记为删除的文件批次及其关联文件", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "手动触发清理", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/admin/batches/{batch_id}": { + "get": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "根据批次 ID 获取批次信息及关联的文件列表", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "获取批次详情", + "parameters": [ + { + "type": "string", + "description": "批次 ID (UUID)", + "name": "batch_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/model.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.FileBatch" + } + } + } + ] + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + }, + "put": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "允许修改备注、过期策略、最大下载次数、状态等", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "修改批次信息", + "parameters": [ + { + "type": "string", + "description": "批次 ID (UUID)", + "name": "batch_id", + "in": "path", + "required": true + }, + { + "description": "修改内容", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/admin.UpdateBatchRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/model.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.FileBatch" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + }, + "delete": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "标记批次为已删除,并物理删除关联的存储文件", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "删除批次", + "parameters": [ + { + "type": "string", + "description": "批次 ID (UUID)", + "name": "batch_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/admin/config": { + "get": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "获取系统的完整配置文件内容(仅管理员)", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "获取完整配置", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/model.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/config.Config" + } + } + } + ] + } + } + } + }, + "put": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "更新系统的配置文件内容(仅管理员)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "更新配置", + "parameters": [ + { + "description": "新配置内容", + "name": "config", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/config.Config" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/model.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/config.Config" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/model.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/admin/login": { + "post": { + "description": "通过密码换取 JWT Token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "管理员登录", + "parameters": [ + { + "description": "登录请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/admin.LoginRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/model.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/admin.LoginResponse" + } + } + } + ] + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/batches": { + "post": { + "security": [ + { + "APITokenAuth": [] + } + ], + "description": "上传一个或多个文件并创建一个提取批次。如果配置了 require_token,则必须提供带 upload scope 的 API Token。", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Public" + ], + "summary": "上传文件", + "parameters": [ + { + "type": "file", + "description": "文件列表", + "name": "files", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "备注", + "name": "remark", + "in": "formData" + }, + { + "type": "string", + "description": "过期类型 (time/download/permanent)", + "name": "expire_type", + "in": "formData" + }, + { + "type": "integer", + "description": "过期天数 (针对 time 类型)", + "name": "expire_days", + "in": "formData" + }, + { + "type": "integer", + "description": "最大下载次数 (针对 download 类型)", + "name": "max_downloads", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/model.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/public.UploadResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/model.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/batches/text": { + "post": { + "security": [ + { + "APITokenAuth": [] + } + ], + "description": "中转一段长文本内容并创建一个提取批次。如果配置了 require_token,则必须提供带 upload scope 的 API Token。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Public" + ], + "summary": "发送长文本", + "parameters": [ + { + "description": "文本内容及配置", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/public.UploadTextRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/model.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/public.UploadResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/model.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/batches/{pickup_code}": { + "get": { + "security": [ + { + "APITokenAuth": [] + } + ], + "description": "根据取件码获取文件批次详细信息和文件列表。可选提供带 pickup scope 的 API Token。", + "produces": [ + "application/json" + ], + "tags": [ + "Public" + ], + "summary": "获取批次信息", + "parameters": [ + { + "type": "string", + "description": "取件码", + "name": "pickup_code", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/model.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/public.PickupResponse" + } + } + } + ] + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/batches/{pickup_code}/count": { + "get": { + "description": "根据取件码查询当前下载次数和最大允许下载次数。支持已过期的批次。", + "produces": [ + "application/json" + ], + "tags": [ + "Public" + ], + "summary": "查询下载次数", + "parameters": [ + { + "type": "string", + "description": "取件码", + "name": "pickup_code", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/model.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/public.DownloadCountResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/model.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/batches/{pickup_code}/download": { + "get": { + "security": [ + { + "APITokenAuth": [] + } + ], + "description": "根据取件码将批次内的所有文件打包为 ZIP 格式一次性下载。可选提供带 pickup scope 的 API Token。", + "produces": [ + "application/zip" + ], + "tags": [ + "Public" + ], + "summary": "批量下载文件", + "parameters": [ + { + "type": "string", + "description": "取件码", + "name": "pickup_code", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/config": { + "get": { + "description": "获取前端展示所需的非敏感配置数据", + "produces": [ + "application/json" + ], + "tags": [ + "Public" + ], + "summary": "获取公共配置", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/model.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/public.PublicConfig" + } + } + } + ] + } + } + } + } + }, + "/api/files/{file_id}/download": { + "get": { + "security": [ + { + "APITokenAuth": [] + } + ], + "description": "根据文件 ID 下载单个文件。支持直观的文件名结尾以方便下载工具识别。可选提供带 pickup scope 的 API Token。", + "produces": [ + "application/octet-stream" + ], + "tags": [ + "Public" + ], + "summary": "下载单个文件", + "parameters": [ + { + "type": "string", + "description": "文件 ID (UUID)", + "name": "file_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/model.Response" + } + }, + "410": { + "description": "Gone", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/files/{file_id}/{filename}": { + "get": { + "security": [ + { + "APITokenAuth": [] + } + ], + "description": "根据文件 ID 下载单个文件。支持直观的文件名结尾以方便下载工具识别。可选提供带 pickup scope 的 API Token。", + "produces": [ + "application/octet-stream" + ], + "tags": [ + "Public" + ], + "summary": "下载单个文件", + "parameters": [ + { + "type": "string", + "description": "文件 ID (UUID)", + "name": "file_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "文件名", + "name": "filename", + "in": "path" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/model.Response" + } + }, + "410": { + "description": "Gone", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + } + }, + "definitions": { + "admin.CreateTokenRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "expire_at": { + "type": "string" + }, + "name": { + "type": "string", + "example": "Test Token" + }, + "scope": { + "type": "string", + "enum": [ + "upload", + "pickup", + "admin" + ], + "example": "upload,pickup" + } + } + }, + "admin.CreateTokenResponse": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.APIToken" + }, + "token": { + "type": "string" + } + } + }, + "admin.ListBatchesResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.FileBatch" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "admin.LoginRequest": { + "type": "object", + "required": [ + "password" + ], + "properties": { + "password": { + "type": "string", + "example": "admin" + } + } + }, + "admin.LoginResponse": { + "type": "object", + "properties": { + "token": { + "type": "string" + } + } + }, + "admin.UpdateBatchRequest": { + "type": "object", + "properties": { + "download_count": { + "type": "integer" + }, + "expire_at": { + "type": "string" + }, + "expire_type": { + "type": "string" + }, + "max_downloads": { + "type": "integer" + }, + "remark": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "config.APITokenConfig": { + "type": "object", + "properties": { + "allow_admin_api": { + "description": "是否允许 API Token 访问管理接口", + "type": "boolean" + }, + "enabled": { + "description": "是否启用 API Token", + "type": "boolean" + }, + "max_tokens": { + "description": "最大 Token 数量", + "type": "integer" + } + } + }, + "config.Config": { + "type": "object", + "properties": { + "api_token": { + "description": "API Token 设置", + "allOf": [ + { + "$ref": "#/definitions/config.APITokenConfig" + } + ] + }, + "database": { + "description": "数据库设置", + "allOf": [ + { + "$ref": "#/definitions/config.DatabaseConfig" + } + ] + }, + "log": { + "description": "日志设置", + "allOf": [ + { + "$ref": "#/definitions/config.LogConfig" + } + ] + }, + "security": { + "description": "安全设置", + "allOf": [ + { + "$ref": "#/definitions/config.SecurityConfig" + } + ] + }, + "site": { + "description": "站点设置", + "allOf": [ + { + "$ref": "#/definitions/config.SiteConfig" + } + ] + }, + "storage": { + "description": "存储设置", + "allOf": [ + { + "$ref": "#/definitions/config.StorageConfig" + } + ] + }, + "upload": { + "description": "上传设置", + "allOf": [ + { + "$ref": "#/definitions/config.UploadConfig" + } + ] + }, + "web": { + "description": "Web 前端设置", + "allOf": [ + { + "$ref": "#/definitions/config.WebConfig" + } + ] + } + } + }, + "config.DatabaseConfig": { + "type": "object", + "properties": { + "config": { + "description": "额外配置参数 (DSN)", + "type": "string" + }, + "dbname": { + "description": "数据库名称", + "type": "string" + }, + "host": { + "description": "数据库地址", + "type": "string" + }, + "password": { + "description": "数据库密码", + "type": "string" + }, + "path": { + "description": "SQLite 数据库文件路径", + "type": "string" + }, + "port": { + "description": "数据库端口", + "type": "integer" + }, + "type": { + "description": "数据库类型: sqlite, mysql, postgres", + "type": "string" + }, + "user": { + "description": "数据库用户名", + "type": "string" + } + } + }, + "config.LogConfig": { + "type": "object", + "properties": { + "file_path": { + "description": "日志文件路径,为空则仅输出到控制台", + "type": "string" + }, + "level": { + "description": "日志级别: debug, info, warn, error", + "type": "string" + } + } + }, + "config.SecurityConfig": { + "type": "object", + "properties": { + "admin_password": { + "description": "管理员密码明文 (仅用于更新请求,不保存到文件)", + "type": "string" + }, + "admin_password_hash": { + "description": "管理员密码哈希 (bcrypt)", + "type": "string" + }, + "jwt_secret": { + "description": "JWT 签名密钥", + "type": "string" + }, + "pickup_code_length": { + "description": "取件码长度 (变更后将自动通过右侧补零或截取调整存量数据)", + "type": "integer" + }, + "pickup_fail_limit": { + "description": "取件失败尝试限制", + "type": "integer" + } + } + }, + "config.SiteConfig": { + "type": "object", + "properties": { + "base_url": { + "description": "站点外部访问地址 (例如: https://file.example.com)", + "type": "string" + }, + "description": { + "description": "站点描述", + "type": "string" + }, + "logo": { + "description": "站点 Logo URL", + "type": "string" + }, + "name": { + "description": "站点名称", + "type": "string" + }, + "port": { + "description": "监听端口", + "type": "integer" + } + } + }, + "config.StorageConfig": { + "type": "object", + "properties": { + "local": { + "type": "object", + "properties": { + "path": { + "description": "本地存储路径", + "type": "string" + } + } + }, + "s3": { + "type": "object", + "properties": { + "access_key": { + "description": "S3 Access Key", + "type": "string" + }, + "bucket": { + "description": "S3 Bucket", + "type": "string" + }, + "endpoint": { + "description": "S3 端点", + "type": "string" + }, + "region": { + "description": "S3 区域", + "type": "string" + }, + "secret_key": { + "description": "S3 Secret Key", + "type": "string" + }, + "use_ssl": { + "description": "是否使用 SSL", + "type": "boolean" + } + } + }, + "type": { + "description": "存储类型: local, webdav, s3", + "type": "string" + }, + "webdav": { + "type": "object", + "properties": { + "password": { + "description": "WebDAV 密码", + "type": "string" + }, + "root": { + "description": "WebDAV 根目录", + "type": "string" + }, + "url": { + "description": "WebDAV 地址", + "type": "string" + }, + "username": { + "description": "WebDAV 用户名", + "type": "string" + } + } + } + } + }, + "config.UploadConfig": { + "type": "object", + "properties": { + "max_batch_files": { + "description": "每个批次最大文件数", + "type": "integer" + }, + "max_file_size_mb": { + "description": "单个文件最大大小 (MB)", + "type": "integer" + }, + "max_retention_days": { + "description": "最大保留天数", + "type": "integer" + }, + "require_token": { + "description": "是否强制要求上传 Token", + "type": "boolean" + } + } + }, + "config.WebConfig": { + "type": "object", + "properties": { + "path": { + "description": "Web 前端资源路径", + "type": "string" + } + } + }, + "model.APIToken": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "expire_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "last_used_at": { + "type": "string" + }, + "name": { + "type": "string" + }, + "revoked": { + "type": "boolean" + }, + "scope": { + "type": "string" + } + } + }, + "model.FileBatch": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "download_count": { + "type": "integer" + }, + "expire_at": { + "type": "string" + }, + "expire_type": { + "description": "time / download / permanent", + "type": "string" + }, + "file_items": { + "type": "array", + "items": { + "$ref": "#/definitions/model.FileItem" + } + }, + "id": { + "type": "string" + }, + "max_downloads": { + "type": "integer" + }, + "pickup_code": { + "type": "string" + }, + "remark": { + "type": "string" + }, + "status": { + "description": "active / expired / deleted", + "type": "string" + }, + "type": { + "description": "file / text", + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "model.FileItem": { + "type": "object", + "properties": { + "batch_id": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "download_url": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mime_type": { + "type": "string" + }, + "original_name": { + "type": "string" + }, + "size": { + "type": "integer" + }, + "storage_path": { + "type": "string" + } + } + }, + "model.Response": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 200 + }, + "data": {}, + "msg": { + "type": "string", + "example": "success" + } + } + }, + "public.DownloadCountResponse": { + "type": "object", + "properties": { + "download_count": { + "type": "integer" + }, + "max_downloads": { + "type": "integer" + } + } + }, + "public.PickupResponse": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "download_count": { + "type": "integer" + }, + "expire_at": { + "type": "string" + }, + "expire_type": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/definitions/model.FileItem" + } + }, + "max_downloads": { + "type": "integer" + }, + "remark": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "public.PublicAPITokenConfig": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + } + }, + "public.PublicConfig": { + "type": "object", + "properties": { + "api_token": { + "$ref": "#/definitions/public.PublicAPITokenConfig" + }, + "security": { + "$ref": "#/definitions/public.PublicSecurityConfig" + }, + "site": { + "$ref": "#/definitions/config.SiteConfig" + }, + "storage": { + "$ref": "#/definitions/public.PublicStorageConfig" + }, + "upload": { + "$ref": "#/definitions/config.UploadConfig" + } + } + }, + "public.PublicSecurityConfig": { + "type": "object", + "properties": { + "pickup_code_length": { + "type": "integer" + } + } + }, + "public.PublicStorageConfig": { + "type": "object", + "properties": { + "type": { + "type": "string" + } + } + }, + "public.UploadResponse": { + "type": "object", + "properties": { + "batch_id": { + "type": "string" + }, + "expire_at": { + "type": "string" + }, + "pickup_code": { + "type": "string" + } + } + }, + "public.UploadTextRequest": { + "type": "object", + "required": [ + "content" + ], + "properties": { + "content": { + "type": "string", + "example": "这是一段长文本内容..." + }, + "expire_days": { + "type": "integer", + "example": 7 + }, + "expire_type": { + "type": "string", + "example": "time" + }, + "max_downloads": { + "type": "integer", + "example": 5 + }, + "remark": { + "type": "string", + "example": "文本备注" + } + } + } + }, + "securityDefinitions": { + "APITokenAuth": { + "description": "Type \"Bearer \u003cAPI-Token\u003e\" to authenticate. Required scope depends on the endpoint.", + "type": "apiKey", + "name": "Authorization", + "in": "header" + }, + "AdminAuth": { + "description": "Type \"Bearer \u003cJWT-Token\u003e\" or \"Bearer \u003cAPI-Token\u003e\" to authenticate. API Token must have 'admin' scope.", + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "1.0", + Host: "", + BasePath: "/", + Schemes: []string{}, + Title: "文件暂存柜 API", + Description: "自托管的文件暂存柜后端系统 API 文档", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/docs/swagger.json b/docs/swagger.json new file mode 100644 index 0000000..bf837ef --- /dev/null +++ b/docs/swagger.json @@ -0,0 +1,1713 @@ +{ + "swagger": "2.0", + "info": { + "description": "自托管的文件暂存柜后端系统 API 文档", + "title": "文件暂存柜 API", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "name": "API Support", + "url": "http://www.swagger.io/support", + "email": "support@swagger.io" + }, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, + "version": "1.0" + }, + "basePath": "/", + "paths": { + "/api/admin/api-tokens": { + "get": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "获取系统中所有 API Token 的详细信息(不包含哈希)", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "获取 API Token 列表", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/model.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.APIToken" + } + } + } + } + ] + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + }, + "post": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "创建一个新的 API Token,返回原始 Token(仅显示一次)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "创建 API Token", + "parameters": [ + { + "description": "Token 信息", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/admin.CreateTokenRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/model.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/admin.CreateTokenResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/admin/api-tokens/{id}": { + "delete": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "根据 ID 永久删除 API Token", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "删除 API Token", + "parameters": [ + { + "type": "integer", + "description": "Token ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/admin/api-tokens/{id}/recover": { + "post": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "将已撤销的 API Token 恢复为有效状态", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "恢复 API Token", + "parameters": [ + { + "type": "integer", + "description": "Token ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/admin/api-tokens/{id}/revoke": { + "post": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "将 API Token 标记为已撤销,使其失效但保留记录", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "撤销 API Token", + "parameters": [ + { + "type": "integer", + "description": "Token ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/admin/batches": { + "get": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "分页查询所有文件批次,支持按状态过滤和取件码模糊搜索", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "获取批次列表", + "parameters": [ + { + "type": "integer", + "description": "页码 (默认 1)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量 (默认 20)", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "状态 (active/expired/deleted)", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "取件码 (模糊搜索)", + "name": "pickup_code", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/model.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/admin.ListBatchesResponse" + } + } + } + ] + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/admin/batches/clean": { + "post": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "手动扫描并物理删除所有已过期或标记为删除的文件批次及其关联文件", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "手动触发清理", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/admin/batches/{batch_id}": { + "get": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "根据批次 ID 获取批次信息及关联的文件列表", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "获取批次详情", + "parameters": [ + { + "type": "string", + "description": "批次 ID (UUID)", + "name": "batch_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/model.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.FileBatch" + } + } + } + ] + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + }, + "put": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "允许修改备注、过期策略、最大下载次数、状态等", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "修改批次信息", + "parameters": [ + { + "type": "string", + "description": "批次 ID (UUID)", + "name": "batch_id", + "in": "path", + "required": true + }, + { + "description": "修改内容", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/admin.UpdateBatchRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/model.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.FileBatch" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + }, + "delete": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "标记批次为已删除,并物理删除关联的存储文件", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "删除批次", + "parameters": [ + { + "type": "string", + "description": "批次 ID (UUID)", + "name": "batch_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/admin/config": { + "get": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "获取系统的完整配置文件内容(仅管理员)", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "获取完整配置", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/model.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/config.Config" + } + } + } + ] + } + } + } + }, + "put": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "更新系统的配置文件内容(仅管理员)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "更新配置", + "parameters": [ + { + "description": "新配置内容", + "name": "config", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/config.Config" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/model.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/config.Config" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/model.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/admin/login": { + "post": { + "description": "通过密码换取 JWT Token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "管理员登录", + "parameters": [ + { + "description": "登录请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/admin.LoginRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/model.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/admin.LoginResponse" + } + } + } + ] + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/batches": { + "post": { + "security": [ + { + "APITokenAuth": [] + } + ], + "description": "上传一个或多个文件并创建一个提取批次。如果配置了 require_token,则必须提供带 upload scope 的 API Token。", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Public" + ], + "summary": "上传文件", + "parameters": [ + { + "type": "file", + "description": "文件列表", + "name": "files", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "备注", + "name": "remark", + "in": "formData" + }, + { + "type": "string", + "description": "过期类型 (time/download/permanent)", + "name": "expire_type", + "in": "formData" + }, + { + "type": "integer", + "description": "过期天数 (针对 time 类型)", + "name": "expire_days", + "in": "formData" + }, + { + "type": "integer", + "description": "最大下载次数 (针对 download 类型)", + "name": "max_downloads", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/model.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/public.UploadResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/model.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/batches/text": { + "post": { + "security": [ + { + "APITokenAuth": [] + } + ], + "description": "中转一段长文本内容并创建一个提取批次。如果配置了 require_token,则必须提供带 upload scope 的 API Token。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Public" + ], + "summary": "发送长文本", + "parameters": [ + { + "description": "文本内容及配置", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/public.UploadTextRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/model.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/public.UploadResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/model.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/batches/{pickup_code}": { + "get": { + "security": [ + { + "APITokenAuth": [] + } + ], + "description": "根据取件码获取文件批次详细信息和文件列表。可选提供带 pickup scope 的 API Token。", + "produces": [ + "application/json" + ], + "tags": [ + "Public" + ], + "summary": "获取批次信息", + "parameters": [ + { + "type": "string", + "description": "取件码", + "name": "pickup_code", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/model.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/public.PickupResponse" + } + } + } + ] + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/batches/{pickup_code}/count": { + "get": { + "description": "根据取件码查询当前下载次数和最大允许下载次数。支持已过期的批次。", + "produces": [ + "application/json" + ], + "tags": [ + "Public" + ], + "summary": "查询下载次数", + "parameters": [ + { + "type": "string", + "description": "取件码", + "name": "pickup_code", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/model.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/public.DownloadCountResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/model.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/batches/{pickup_code}/download": { + "get": { + "security": [ + { + "APITokenAuth": [] + } + ], + "description": "根据取件码将批次内的所有文件打包为 ZIP 格式一次性下载。可选提供带 pickup scope 的 API Token。", + "produces": [ + "application/zip" + ], + "tags": [ + "Public" + ], + "summary": "批量下载文件", + "parameters": [ + { + "type": "string", + "description": "取件码", + "name": "pickup_code", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/config": { + "get": { + "description": "获取前端展示所需的非敏感配置数据", + "produces": [ + "application/json" + ], + "tags": [ + "Public" + ], + "summary": "获取公共配置", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/model.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/public.PublicConfig" + } + } + } + ] + } + } + } + } + }, + "/api/files/{file_id}/download": { + "get": { + "security": [ + { + "APITokenAuth": [] + } + ], + "description": "根据文件 ID 下载单个文件。支持直观的文件名结尾以方便下载工具识别。可选提供带 pickup scope 的 API Token。", + "produces": [ + "application/octet-stream" + ], + "tags": [ + "Public" + ], + "summary": "下载单个文件", + "parameters": [ + { + "type": "string", + "description": "文件 ID (UUID)", + "name": "file_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/model.Response" + } + }, + "410": { + "description": "Gone", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/files/{file_id}/{filename}": { + "get": { + "security": [ + { + "APITokenAuth": [] + } + ], + "description": "根据文件 ID 下载单个文件。支持直观的文件名结尾以方便下载工具识别。可选提供带 pickup scope 的 API Token。", + "produces": [ + "application/octet-stream" + ], + "tags": [ + "Public" + ], + "summary": "下载单个文件", + "parameters": [ + { + "type": "string", + "description": "文件 ID (UUID)", + "name": "file_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "文件名", + "name": "filename", + "in": "path" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/model.Response" + } + }, + "410": { + "description": "Gone", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + } + }, + "definitions": { + "admin.CreateTokenRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "expire_at": { + "type": "string" + }, + "name": { + "type": "string", + "example": "Test Token" + }, + "scope": { + "type": "string", + "enum": [ + "upload", + "pickup", + "admin" + ], + "example": "upload,pickup" + } + } + }, + "admin.CreateTokenResponse": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.APIToken" + }, + "token": { + "type": "string" + } + } + }, + "admin.ListBatchesResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.FileBatch" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "admin.LoginRequest": { + "type": "object", + "required": [ + "password" + ], + "properties": { + "password": { + "type": "string", + "example": "admin" + } + } + }, + "admin.LoginResponse": { + "type": "object", + "properties": { + "token": { + "type": "string" + } + } + }, + "admin.UpdateBatchRequest": { + "type": "object", + "properties": { + "download_count": { + "type": "integer" + }, + "expire_at": { + "type": "string" + }, + "expire_type": { + "type": "string" + }, + "max_downloads": { + "type": "integer" + }, + "remark": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "config.APITokenConfig": { + "type": "object", + "properties": { + "allow_admin_api": { + "description": "是否允许 API Token 访问管理接口", + "type": "boolean" + }, + "enabled": { + "description": "是否启用 API Token", + "type": "boolean" + }, + "max_tokens": { + "description": "最大 Token 数量", + "type": "integer" + } + } + }, + "config.Config": { + "type": "object", + "properties": { + "api_token": { + "description": "API Token 设置", + "allOf": [ + { + "$ref": "#/definitions/config.APITokenConfig" + } + ] + }, + "database": { + "description": "数据库设置", + "allOf": [ + { + "$ref": "#/definitions/config.DatabaseConfig" + } + ] + }, + "log": { + "description": "日志设置", + "allOf": [ + { + "$ref": "#/definitions/config.LogConfig" + } + ] + }, + "security": { + "description": "安全设置", + "allOf": [ + { + "$ref": "#/definitions/config.SecurityConfig" + } + ] + }, + "site": { + "description": "站点设置", + "allOf": [ + { + "$ref": "#/definitions/config.SiteConfig" + } + ] + }, + "storage": { + "description": "存储设置", + "allOf": [ + { + "$ref": "#/definitions/config.StorageConfig" + } + ] + }, + "upload": { + "description": "上传设置", + "allOf": [ + { + "$ref": "#/definitions/config.UploadConfig" + } + ] + }, + "web": { + "description": "Web 前端设置", + "allOf": [ + { + "$ref": "#/definitions/config.WebConfig" + } + ] + } + } + }, + "config.DatabaseConfig": { + "type": "object", + "properties": { + "config": { + "description": "额外配置参数 (DSN)", + "type": "string" + }, + "dbname": { + "description": "数据库名称", + "type": "string" + }, + "host": { + "description": "数据库地址", + "type": "string" + }, + "password": { + "description": "数据库密码", + "type": "string" + }, + "path": { + "description": "SQLite 数据库文件路径", + "type": "string" + }, + "port": { + "description": "数据库端口", + "type": "integer" + }, + "type": { + "description": "数据库类型: sqlite, mysql, postgres", + "type": "string" + }, + "user": { + "description": "数据库用户名", + "type": "string" + } + } + }, + "config.LogConfig": { + "type": "object", + "properties": { + "file_path": { + "description": "日志文件路径,为空则仅输出到控制台", + "type": "string" + }, + "level": { + "description": "日志级别: debug, info, warn, error", + "type": "string" + } + } + }, + "config.SecurityConfig": { + "type": "object", + "properties": { + "admin_password": { + "description": "管理员密码明文 (仅用于更新请求,不保存到文件)", + "type": "string" + }, + "admin_password_hash": { + "description": "管理员密码哈希 (bcrypt)", + "type": "string" + }, + "jwt_secret": { + "description": "JWT 签名密钥", + "type": "string" + }, + "pickup_code_length": { + "description": "取件码长度 (变更后将自动通过右侧补零或截取调整存量数据)", + "type": "integer" + }, + "pickup_fail_limit": { + "description": "取件失败尝试限制", + "type": "integer" + } + } + }, + "config.SiteConfig": { + "type": "object", + "properties": { + "base_url": { + "description": "站点外部访问地址 (例如: https://file.example.com)", + "type": "string" + }, + "description": { + "description": "站点描述", + "type": "string" + }, + "logo": { + "description": "站点 Logo URL", + "type": "string" + }, + "name": { + "description": "站点名称", + "type": "string" + }, + "port": { + "description": "监听端口", + "type": "integer" + } + } + }, + "config.StorageConfig": { + "type": "object", + "properties": { + "local": { + "type": "object", + "properties": { + "path": { + "description": "本地存储路径", + "type": "string" + } + } + }, + "s3": { + "type": "object", + "properties": { + "access_key": { + "description": "S3 Access Key", + "type": "string" + }, + "bucket": { + "description": "S3 Bucket", + "type": "string" + }, + "endpoint": { + "description": "S3 端点", + "type": "string" + }, + "region": { + "description": "S3 区域", + "type": "string" + }, + "secret_key": { + "description": "S3 Secret Key", + "type": "string" + }, + "use_ssl": { + "description": "是否使用 SSL", + "type": "boolean" + } + } + }, + "type": { + "description": "存储类型: local, webdav, s3", + "type": "string" + }, + "webdav": { + "type": "object", + "properties": { + "password": { + "description": "WebDAV 密码", + "type": "string" + }, + "root": { + "description": "WebDAV 根目录", + "type": "string" + }, + "url": { + "description": "WebDAV 地址", + "type": "string" + }, + "username": { + "description": "WebDAV 用户名", + "type": "string" + } + } + } + } + }, + "config.UploadConfig": { + "type": "object", + "properties": { + "max_batch_files": { + "description": "每个批次最大文件数", + "type": "integer" + }, + "max_file_size_mb": { + "description": "单个文件最大大小 (MB)", + "type": "integer" + }, + "max_retention_days": { + "description": "最大保留天数", + "type": "integer" + }, + "require_token": { + "description": "是否强制要求上传 Token", + "type": "boolean" + } + } + }, + "config.WebConfig": { + "type": "object", + "properties": { + "path": { + "description": "Web 前端资源路径", + "type": "string" + } + } + }, + "model.APIToken": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "expire_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "last_used_at": { + "type": "string" + }, + "name": { + "type": "string" + }, + "revoked": { + "type": "boolean" + }, + "scope": { + "type": "string" + } + } + }, + "model.FileBatch": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "download_count": { + "type": "integer" + }, + "expire_at": { + "type": "string" + }, + "expire_type": { + "description": "time / download / permanent", + "type": "string" + }, + "file_items": { + "type": "array", + "items": { + "$ref": "#/definitions/model.FileItem" + } + }, + "id": { + "type": "string" + }, + "max_downloads": { + "type": "integer" + }, + "pickup_code": { + "type": "string" + }, + "remark": { + "type": "string" + }, + "status": { + "description": "active / expired / deleted", + "type": "string" + }, + "type": { + "description": "file / text", + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "model.FileItem": { + "type": "object", + "properties": { + "batch_id": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "download_url": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mime_type": { + "type": "string" + }, + "original_name": { + "type": "string" + }, + "size": { + "type": "integer" + }, + "storage_path": { + "type": "string" + } + } + }, + "model.Response": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 200 + }, + "data": {}, + "msg": { + "type": "string", + "example": "success" + } + } + }, + "public.DownloadCountResponse": { + "type": "object", + "properties": { + "download_count": { + "type": "integer" + }, + "max_downloads": { + "type": "integer" + } + } + }, + "public.PickupResponse": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "download_count": { + "type": "integer" + }, + "expire_at": { + "type": "string" + }, + "expire_type": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/definitions/model.FileItem" + } + }, + "max_downloads": { + "type": "integer" + }, + "remark": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "public.PublicAPITokenConfig": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + } + }, + "public.PublicConfig": { + "type": "object", + "properties": { + "api_token": { + "$ref": "#/definitions/public.PublicAPITokenConfig" + }, + "security": { + "$ref": "#/definitions/public.PublicSecurityConfig" + }, + "site": { + "$ref": "#/definitions/config.SiteConfig" + }, + "storage": { + "$ref": "#/definitions/public.PublicStorageConfig" + }, + "upload": { + "$ref": "#/definitions/config.UploadConfig" + } + } + }, + "public.PublicSecurityConfig": { + "type": "object", + "properties": { + "pickup_code_length": { + "type": "integer" + } + } + }, + "public.PublicStorageConfig": { + "type": "object", + "properties": { + "type": { + "type": "string" + } + } + }, + "public.UploadResponse": { + "type": "object", + "properties": { + "batch_id": { + "type": "string" + }, + "expire_at": { + "type": "string" + }, + "pickup_code": { + "type": "string" + } + } + }, + "public.UploadTextRequest": { + "type": "object", + "required": [ + "content" + ], + "properties": { + "content": { + "type": "string", + "example": "这是一段长文本内容..." + }, + "expire_days": { + "type": "integer", + "example": 7 + }, + "expire_type": { + "type": "string", + "example": "time" + }, + "max_downloads": { + "type": "integer", + "example": 5 + }, + "remark": { + "type": "string", + "example": "文本备注" + } + } + } + }, + "securityDefinitions": { + "APITokenAuth": { + "description": "Type \"Bearer \u003cAPI-Token\u003e\" to authenticate. Required scope depends on the endpoint.", + "type": "apiKey", + "name": "Authorization", + "in": "header" + }, + "AdminAuth": { + "description": "Type \"Bearer \u003cJWT-Token\u003e\" or \"Bearer \u003cAPI-Token\u003e\" to authenticate. API Token must have 'admin' scope.", + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +} \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml new file mode 100644 index 0000000..0065702 --- /dev/null +++ b/docs/swagger.yaml @@ -0,0 +1,1076 @@ +basePath: / +definitions: + admin.CreateTokenRequest: + properties: + expire_at: + type: string + name: + example: Test Token + type: string + scope: + enum: + - upload + - pickup + - admin + example: upload,pickup + type: string + required: + - name + type: object + admin.CreateTokenResponse: + properties: + data: + $ref: '#/definitions/model.APIToken' + token: + type: string + type: object + admin.ListBatchesResponse: + properties: + data: + items: + $ref: '#/definitions/model.FileBatch' + type: array + page: + type: integer + page_size: + type: integer + total: + type: integer + type: object + admin.LoginRequest: + properties: + password: + example: admin + type: string + required: + - password + type: object + admin.LoginResponse: + properties: + token: + type: string + type: object + admin.UpdateBatchRequest: + properties: + download_count: + type: integer + expire_at: + type: string + expire_type: + type: string + max_downloads: + type: integer + remark: + type: string + status: + type: string + type: object + config.APITokenConfig: + properties: + allow_admin_api: + description: 是否允许 API Token 访问管理接口 + type: boolean + enabled: + description: 是否启用 API Token + type: boolean + max_tokens: + description: 最大 Token 数量 + type: integer + type: object + config.Config: + properties: + api_token: + allOf: + - $ref: '#/definitions/config.APITokenConfig' + description: API Token 设置 + database: + allOf: + - $ref: '#/definitions/config.DatabaseConfig' + description: 数据库设置 + log: + allOf: + - $ref: '#/definitions/config.LogConfig' + description: 日志设置 + security: + allOf: + - $ref: '#/definitions/config.SecurityConfig' + description: 安全设置 + site: + allOf: + - $ref: '#/definitions/config.SiteConfig' + description: 站点设置 + storage: + allOf: + - $ref: '#/definitions/config.StorageConfig' + description: 存储设置 + upload: + allOf: + - $ref: '#/definitions/config.UploadConfig' + description: 上传设置 + web: + allOf: + - $ref: '#/definitions/config.WebConfig' + description: Web 前端设置 + type: object + config.DatabaseConfig: + properties: + config: + description: 额外配置参数 (DSN) + type: string + dbname: + description: 数据库名称 + type: string + host: + description: 数据库地址 + type: string + password: + description: 数据库密码 + type: string + path: + description: SQLite 数据库文件路径 + type: string + port: + description: 数据库端口 + type: integer + type: + description: '数据库类型: sqlite, mysql, postgres' + type: string + user: + description: 数据库用户名 + type: string + type: object + config.LogConfig: + properties: + file_path: + description: 日志文件路径,为空则仅输出到控制台 + type: string + level: + description: '日志级别: debug, info, warn, error' + type: string + type: object + config.SecurityConfig: + properties: + admin_password: + description: 管理员密码明文 (仅用于更新请求,不保存到文件) + type: string + admin_password_hash: + description: 管理员密码哈希 (bcrypt) + type: string + jwt_secret: + description: JWT 签名密钥 + type: string + pickup_code_length: + description: 取件码长度 (变更后将自动通过右侧补零或截取调整存量数据) + type: integer + pickup_fail_limit: + description: 取件失败尝试限制 + type: integer + type: object + config.SiteConfig: + properties: + base_url: + description: '站点外部访问地址 (例如: https://file.example.com)' + type: string + description: + description: 站点描述 + type: string + logo: + description: 站点 Logo URL + type: string + name: + description: 站点名称 + type: string + port: + description: 监听端口 + type: integer + type: object + config.StorageConfig: + properties: + local: + properties: + path: + description: 本地存储路径 + type: string + type: object + s3: + properties: + access_key: + description: S3 Access Key + type: string + bucket: + description: S3 Bucket + type: string + endpoint: + description: S3 端点 + type: string + region: + description: S3 区域 + type: string + secret_key: + description: S3 Secret Key + type: string + use_ssl: + description: 是否使用 SSL + type: boolean + type: object + type: + description: '存储类型: local, webdav, s3' + type: string + webdav: + properties: + password: + description: WebDAV 密码 + type: string + root: + description: WebDAV 根目录 + type: string + url: + description: WebDAV 地址 + type: string + username: + description: WebDAV 用户名 + type: string + type: object + type: object + config.UploadConfig: + properties: + max_batch_files: + description: 每个批次最大文件数 + type: integer + max_file_size_mb: + description: 单个文件最大大小 (MB) + type: integer + max_retention_days: + description: 最大保留天数 + type: integer + require_token: + description: 是否强制要求上传 Token + type: boolean + type: object + config.WebConfig: + properties: + path: + description: Web 前端资源路径 + type: string + type: object + model.APIToken: + properties: + created_at: + type: string + expire_at: + type: string + id: + type: integer + last_used_at: + type: string + name: + type: string + revoked: + type: boolean + scope: + type: string + type: object + model.FileBatch: + properties: + content: + type: string + created_at: + type: string + download_count: + type: integer + expire_at: + type: string + expire_type: + description: time / download / permanent + type: string + file_items: + items: + $ref: '#/definitions/model.FileItem' + type: array + id: + type: string + max_downloads: + type: integer + pickup_code: + type: string + remark: + type: string + status: + description: active / expired / deleted + type: string + type: + description: file / text + type: string + updated_at: + type: string + type: object + model.FileItem: + properties: + batch_id: + type: string + created_at: + type: string + download_url: + type: string + id: + type: string + mime_type: + type: string + original_name: + type: string + size: + type: integer + storage_path: + type: string + type: object + model.Response: + properties: + code: + example: 200 + type: integer + data: {} + msg: + example: success + type: string + type: object + public.DownloadCountResponse: + properties: + download_count: + type: integer + max_downloads: + type: integer + type: object + public.PickupResponse: + properties: + content: + type: string + download_count: + type: integer + expire_at: + type: string + expire_type: + type: string + files: + items: + $ref: '#/definitions/model.FileItem' + type: array + max_downloads: + type: integer + remark: + type: string + type: + type: string + type: object + public.PublicAPITokenConfig: + properties: + enabled: + type: boolean + type: object + public.PublicConfig: + properties: + api_token: + $ref: '#/definitions/public.PublicAPITokenConfig' + security: + $ref: '#/definitions/public.PublicSecurityConfig' + site: + $ref: '#/definitions/config.SiteConfig' + storage: + $ref: '#/definitions/public.PublicStorageConfig' + upload: + $ref: '#/definitions/config.UploadConfig' + type: object + public.PublicSecurityConfig: + properties: + pickup_code_length: + type: integer + type: object + public.PublicStorageConfig: + properties: + type: + type: string + type: object + public.UploadResponse: + properties: + batch_id: + type: string + expire_at: + type: string + pickup_code: + type: string + type: object + public.UploadTextRequest: + properties: + content: + example: 这是一段长文本内容... + type: string + expire_days: + example: 7 + type: integer + expire_type: + example: time + type: string + max_downloads: + example: 5 + type: integer + remark: + example: 文本备注 + type: string + required: + - content + type: object +info: + contact: + email: support@swagger.io + name: API Support + url: http://www.swagger.io/support + description: 自托管的文件暂存柜后端系统 API 文档 + license: + name: Apache 2.0 + url: http://www.apache.org/licenses/LICENSE-2.0.html + termsOfService: http://swagger.io/terms/ + title: 文件暂存柜 API + version: "1.0" +paths: + /api/admin/api-tokens: + get: + description: 获取系统中所有 API Token 的详细信息(不包含哈希) + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/model.Response' + - properties: + data: + items: + $ref: '#/definitions/model.APIToken' + type: array + type: object + "401": + description: Unauthorized + schema: + $ref: '#/definitions/model.Response' + security: + - AdminAuth: [] + summary: 获取 API Token 列表 + tags: + - Admin + post: + consumes: + - application/json + description: 创建一个新的 API Token,返回原始 Token(仅显示一次) + parameters: + - description: Token 信息 + in: body + name: request + required: true + schema: + $ref: '#/definitions/admin.CreateTokenRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + allOf: + - $ref: '#/definitions/model.Response' + - properties: + data: + $ref: '#/definitions/admin.CreateTokenResponse' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/model.Response' + security: + - AdminAuth: [] + summary: 创建 API Token + tags: + - Admin + /api/admin/api-tokens/{id}: + delete: + description: 根据 ID 永久删除 API Token + parameters: + - description: Token ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/model.Response' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/model.Response' + security: + - AdminAuth: [] + summary: 删除 API Token + tags: + - Admin + /api/admin/api-tokens/{id}/recover: + post: + description: 将已撤销的 API Token 恢复为有效状态 + parameters: + - description: Token ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/model.Response' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/model.Response' + security: + - AdminAuth: [] + summary: 恢复 API Token + tags: + - Admin + /api/admin/api-tokens/{id}/revoke: + post: + description: 将 API Token 标记为已撤销,使其失效但保留记录 + parameters: + - description: Token ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/model.Response' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/model.Response' + security: + - AdminAuth: [] + summary: 撤销 API Token + tags: + - Admin + /api/admin/batches: + get: + description: 分页查询所有文件批次,支持按状态过滤和取件码模糊搜索 + parameters: + - description: 页码 (默认 1) + in: query + name: page + type: integer + - description: 每页数量 (默认 20) + in: query + name: page_size + type: integer + - description: 状态 (active/expired/deleted) + in: query + name: status + type: string + - description: 取件码 (模糊搜索) + in: query + name: pickup_code + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/model.Response' + - properties: + data: + $ref: '#/definitions/admin.ListBatchesResponse' + type: object + "401": + description: Unauthorized + schema: + $ref: '#/definitions/model.Response' + security: + - AdminAuth: [] + summary: 获取批次列表 + tags: + - Admin + /api/admin/batches/{batch_id}: + delete: + description: 标记批次为已删除,并物理删除关联的存储文件 + parameters: + - description: 批次 ID (UUID) + in: path + name: batch_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/model.Response' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/model.Response' + security: + - AdminAuth: [] + summary: 删除批次 + tags: + - Admin + get: + description: 根据批次 ID 获取批次信息及关联的文件列表 + parameters: + - description: 批次 ID (UUID) + in: path + name: batch_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/model.Response' + - properties: + data: + $ref: '#/definitions/model.FileBatch' + type: object + "404": + description: Not Found + schema: + $ref: '#/definitions/model.Response' + security: + - AdminAuth: [] + summary: 获取批次详情 + tags: + - Admin + put: + consumes: + - application/json + description: 允许修改备注、过期策略、最大下载次数、状态等 + parameters: + - description: 批次 ID (UUID) + in: path + name: batch_id + required: true + type: string + - description: 修改内容 + in: body + name: request + required: true + schema: + $ref: '#/definitions/admin.UpdateBatchRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/model.Response' + - properties: + data: + $ref: '#/definitions/model.FileBatch' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/model.Response' + security: + - AdminAuth: [] + summary: 修改批次信息 + tags: + - Admin + /api/admin/batches/clean: + post: + description: 手动扫描并物理删除所有已过期或标记为删除的文件批次及其关联文件 + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/model.Response' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/model.Response' + security: + - AdminAuth: [] + summary: 手动触发清理 + tags: + - Admin + /api/admin/config: + get: + description: 获取系统的完整配置文件内容(仅管理员) + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/model.Response' + - properties: + data: + $ref: '#/definitions/config.Config' + type: object + security: + - AdminAuth: [] + summary: 获取完整配置 + tags: + - Admin + put: + consumes: + - application/json + description: 更新系统的配置文件内容(仅管理员) + parameters: + - description: 新配置内容 + in: body + name: config + required: true + schema: + $ref: '#/definitions/config.Config' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/model.Response' + - properties: + data: + $ref: '#/definitions/config.Config' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/model.Response' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/model.Response' + security: + - AdminAuth: [] + summary: 更新配置 + tags: + - Admin + /api/admin/login: + post: + consumes: + - application/json + description: 通过密码换取 JWT Token + parameters: + - description: 登录请求 + in: body + name: request + required: true + schema: + $ref: '#/definitions/admin.LoginRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/model.Response' + - properties: + data: + $ref: '#/definitions/admin.LoginResponse' + type: object + "401": + description: Unauthorized + schema: + $ref: '#/definitions/model.Response' + summary: 管理员登录 + tags: + - Admin + /api/batches: + post: + consumes: + - multipart/form-data + description: 上传一个或多个文件并创建一个提取批次。如果配置了 require_token,则必须提供带 upload scope 的 API + Token。 + parameters: + - description: 文件列表 + in: formData + name: files + required: true + type: file + - description: 备注 + in: formData + name: remark + type: string + - description: 过期类型 (time/download/permanent) + in: formData + name: expire_type + type: string + - description: 过期天数 (针对 time 类型) + in: formData + name: expire_days + type: integer + - description: 最大下载次数 (针对 download 类型) + in: formData + name: max_downloads + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/model.Response' + - properties: + data: + $ref: '#/definitions/public.UploadResponse' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/model.Response' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/model.Response' + security: + - APITokenAuth: [] + summary: 上传文件 + tags: + - Public + /api/batches/{pickup_code}: + get: + description: 根据取件码获取文件批次详细信息和文件列表。可选提供带 pickup scope 的 API Token。 + parameters: + - description: 取件码 + in: path + name: pickup_code + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/model.Response' + - properties: + data: + $ref: '#/definitions/public.PickupResponse' + type: object + "404": + description: Not Found + schema: + $ref: '#/definitions/model.Response' + security: + - APITokenAuth: [] + summary: 获取批次信息 + tags: + - Public + /api/batches/{pickup_code}/count: + get: + description: 根据取件码查询当前下载次数和最大允许下载次数。支持已过期的批次。 + parameters: + - description: 取件码 + in: path + name: pickup_code + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/model.Response' + - properties: + data: + $ref: '#/definitions/public.DownloadCountResponse' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/model.Response' + "404": + description: Not Found + schema: + $ref: '#/definitions/model.Response' + summary: 查询下载次数 + tags: + - Public + /api/batches/{pickup_code}/download: + get: + description: 根据取件码将批次内的所有文件打包为 ZIP 格式一次性下载。可选提供带 pickup scope 的 API Token。 + parameters: + - description: 取件码 + in: path + name: pickup_code + required: true + type: string + produces: + - application/zip + responses: + "200": + description: OK + schema: + type: file + "404": + description: Not Found + schema: + $ref: '#/definitions/model.Response' + security: + - APITokenAuth: [] + summary: 批量下载文件 + tags: + - Public + /api/batches/text: + post: + consumes: + - application/json + description: 中转一段长文本内容并创建一个提取批次。如果配置了 require_token,则必须提供带 upload scope 的 API + Token。 + parameters: + - description: 文本内容及配置 + in: body + name: request + required: true + schema: + $ref: '#/definitions/public.UploadTextRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/model.Response' + - properties: + data: + $ref: '#/definitions/public.UploadResponse' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/model.Response' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/model.Response' + security: + - APITokenAuth: [] + summary: 发送长文本 + tags: + - Public + /api/config: + get: + description: 获取前端展示所需的非敏感配置数据 + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/model.Response' + - properties: + data: + $ref: '#/definitions/public.PublicConfig' + type: object + summary: 获取公共配置 + tags: + - Public + /api/files/{file_id}/{filename}: + get: + description: 根据文件 ID 下载单个文件。支持直观的文件名结尾以方便下载工具识别。可选提供带 pickup scope 的 API Token。 + parameters: + - description: 文件 ID (UUID) + in: path + name: file_id + required: true + type: string + - description: 文件名 + in: path + name: filename + type: string + produces: + - application/octet-stream + responses: + "200": + description: OK + schema: + type: file + "404": + description: Not Found + schema: + $ref: '#/definitions/model.Response' + "410": + description: Gone + schema: + $ref: '#/definitions/model.Response' + security: + - APITokenAuth: [] + summary: 下载单个文件 + tags: + - Public + /api/files/{file_id}/download: + get: + description: 根据文件 ID 下载单个文件。支持直观的文件名结尾以方便下载工具识别。可选提供带 pickup scope 的 API Token。 + parameters: + - description: 文件 ID (UUID) + in: path + name: file_id + required: true + type: string + produces: + - application/octet-stream + responses: + "200": + description: OK + schema: + type: file + "404": + description: Not Found + schema: + $ref: '#/definitions/model.Response' + "410": + description: Gone + schema: + $ref: '#/definitions/model.Response' + security: + - APITokenAuth: [] + summary: 下载单个文件 + tags: + - Public +securityDefinitions: + APITokenAuth: + description: Type "Bearer " to authenticate. Required scope depends + on the endpoint. + in: header + name: Authorization + type: apiKey + AdminAuth: + description: Type "Bearer " or "Bearer " to authenticate. + API Token must have 'admin' scope. + in: header + name: Authorization + type: apiKey +swagger: "2.0" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..08d2158 --- /dev/null +++ b/go.mod @@ -0,0 +1,102 @@ +module FileRelay + +go 1.24.0 + +toolchain go1.24.11 + +require ( + github.com/aws/aws-sdk-go-v2 v1.41.1 + github.com/aws/aws-sdk-go-v2/config v1.32.7 + github.com/aws/aws-sdk-go-v2/credentials v1.19.7 + github.com/aws/aws-sdk-go-v2/service/s3 v1.95.1 + github.com/gin-contrib/cors v1.7.6 + github.com/gin-gonic/gin v1.11.0 + github.com/glebarez/sqlite v1.11.0 + github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/google/uuid v1.6.0 + github.com/stretchr/testify v1.11.1 + github.com/studio-b12/gowebdav v0.11.0 + github.com/swaggo/files v1.0.1 + github.com/swaggo/gin-swagger v1.6.1 + github.com/swaggo/swag v1.16.6 + golang.org/x/crypto v0.47.0 + gopkg.in/yaml.v3 v3.0.1 + gorm.io/driver/mysql v1.6.0 + gorm.io/driver/postgres v1.6.0 + gorm.io/gorm v1.31.1 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/PuerkitoBio/purell v1.1.1 // indirect + github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect + github.com/aws/smithy-go v1.24.0 // indirect + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.9 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/jsonreference v0.19.6 // indirect + github.com/go-openapi/spec v0.20.4 // indirect + github.com/go-openapi/swag v0.19.15 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/go-sql-driver/mysql v1.8.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.6.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mailru/easyjson v0.7.6 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.54.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + go.uber.org/mock v0.5.0 // indirect + golang.org/x/arch v0.20.0 // indirect + golang.org/x/mod v0.31.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect + golang.org/x/tools v0.40.0 // indirect + google.golang.org/protobuf v1.36.9 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e3db903 --- /dev/null +++ b/go.sum @@ -0,0 +1,257 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= +github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= +github.com/aws/aws-sdk-go-v2/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY= +github.com/aws/aws-sdk-go-v2/config v1.32.7/go.mod h1:2/Qm5vKUU/r7Y+zUk/Ptt2MDAEKAfUtKc1+3U1Mo3oY= +github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 h1:bGeHBsGZx0Dvu/eJC0Lh9adJa3M1xREcndxLNZlve2U= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g= +github.com/aws/aws-sdk-go-v2/service/s3 v1.95.1 h1:C2dUPSnEpy4voWFIq3JNd8gN0Y5vYGDo44eUE58a/p8= +github.com/aws/aws-sdk-go-v2/service/s3 v1.95.1/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 h1:gd84Omyu9JLriJVCbGApcLzVR3XtmC4ZDPcAI6Ftvds= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ= +github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= +github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= +github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= +github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= +github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk= +github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= +github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= +github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= +github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= +github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= +github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/studio-b12/gowebdav v0.11.0 h1:qbQzq4USxY28ZYsGJUfO5jR+xkFtcnwWgitp4Zp1irU= +github.com/studio-b12/gowebdav v0.11.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE= +github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= +github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= +github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY= +github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw= +github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= +github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= +gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= diff --git a/internal/api/admin/auth.go b/internal/api/admin/auth.go new file mode 100644 index 0000000..c015f45 --- /dev/null +++ b/internal/api/admin/auth.go @@ -0,0 +1,70 @@ +package admin + +import ( + "FileRelay/internal/auth" + "FileRelay/internal/config" + "FileRelay/internal/model" + "log/slog" + "net/http" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" +) + +type AuthHandler struct{} + +func NewAuthHandler() *AuthHandler { + return &AuthHandler{} +} + +type LoginRequest struct { + Password string `json:"password" binding:"required" example:"admin"` +} + +type LoginResponse struct { + Token string `json:"token"` +} + +// Login 管理员登录 +// @Summary 管理员登录 +// @Description 通过密码换取 JWT Token +// @Tags Admin +// @Accept json +// @Produce json +// @Param request body LoginRequest true "登录请求" +// @Success 200 {object} model.Response{data=LoginResponse} +// @Failure 401 {object} model.Response +// @Router /api/admin/login [post] +func (h *AuthHandler) Login(c *gin.Context) { + var req LoginRequest + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.ErrorResponse(model.CodeBadRequest, "Invalid request")) + return + } + + passwordHash := config.GlobalConfig.Security.AdminPasswordHash + if passwordHash == "" { + c.JSON(http.StatusInternalServerError, model.ErrorResponse(model.CodeInternalError, "Admin password hash not configured")) + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil { + slog.Warn("Failed admin login attempt", "ip", c.ClientIP()) + c.JSON(http.StatusUnauthorized, model.ErrorResponse(model.CodeUnauthorized, "Incorrect password")) + return + } + + // 使用固定 ID 1 代表管理员(因为不再有数据库记录) + token, err := auth.GenerateToken(1) + if err != nil { + slog.Error("Failed to generate admin token", "error", err) + c.JSON(http.StatusInternalServerError, model.ErrorResponse(model.CodeInternalError, "Failed to generate token")) + return + } + + slog.Info("Admin logged in", "ip", c.ClientIP()) + c.JSON(http.StatusOK, model.SuccessResponse(LoginResponse{ + Token: token, + })) +} diff --git a/internal/api/admin/batch.go b/internal/api/admin/batch.go new file mode 100644 index 0000000..a648dec --- /dev/null +++ b/internal/api/admin/batch.go @@ -0,0 +1,256 @@ +package admin + +import ( + "FileRelay/internal/bootstrap" + "FileRelay/internal/model" + "FileRelay/internal/service" + "bytes" + "encoding/json" + "io" + "log/slog" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" +) + +type BatchHandler struct { + batchService *service.BatchService +} + +func NewBatchHandler() *BatchHandler { + return &BatchHandler{ + batchService: service.NewBatchService(), + } +} + +type ListBatchesResponse struct { + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + Data []model.FileBatch `json:"data"` +} + +type UpdateBatchRequest struct { + Remark *string `json:"remark"` + ExpireType *string `json:"expire_type"` + ExpireAt *time.Time `json:"expire_at"` + MaxDownloads *int `json:"max_downloads"` + DownloadCount *int `json:"download_count"` + Status *string `json:"status"` +} + +// ListBatches 获取批次列表 +// @Summary 获取批次列表 +// @Description 分页查询所有文件批次,支持按状态过滤和取件码模糊搜索 +// @Tags Admin +// @Security AdminAuth +// @Param page query int false "页码 (默认 1)" +// @Param page_size query int false "每页数量 (默认 20)" +// @Param status query string false "状态 (active/expired/deleted)" +// @Param pickup_code query string false "取件码 (模糊搜索)" +// @Produce json +// @Success 200 {object} model.Response{data=ListBatchesResponse} +// @Failure 401 {object} model.Response +// @Router /api/admin/batches [get] +func (h *BatchHandler) ListBatches(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + status := c.Query("status") + pickupCode := c.Query("pickup_code") + + query := bootstrap.DB.Model(&model.FileBatch{}) + if status != "" { + query = query.Where("status = ?", status) + } + if pickupCode != "" { + query = query.Where("pickup_code LIKE ?", "%"+pickupCode+"%") + } + + var total int64 + query.Count(&total) + + var batches []model.FileBatch + err := query.Offset((page - 1) * pageSize).Limit(pageSize).Order("created_at DESC").Find(&batches).Error + if err != nil { + c.JSON(http.StatusInternalServerError, model.ErrorResponse(model.CodeInternalError, err.Error())) + return + } + + c.JSON(http.StatusOK, model.SuccessResponse(ListBatchesResponse{ + Total: total, + Page: page, + PageSize: pageSize, + Data: batches, + })) +} + +// GetBatch 获取批次详情 +// @Summary 获取批次详情 +// @Description 根据批次 ID 获取批次信息及关联的文件列表 +// @Tags Admin +// @Security AdminAuth +// @Param batch_id path string true "批次 ID (UUID)" +// @Produce json +// @Success 200 {object} model.Response{data=model.FileBatch} +// @Failure 404 {object} model.Response +// @Router /api/admin/batches/{batch_id} [get] +func (h *BatchHandler) GetBatch(c *gin.Context) { + id := c.Param("batch_id") + var batch model.FileBatch + if err := bootstrap.DB.Preload("FileItems").First(&batch, "id = ?", id).Error; err != nil { + c.JSON(http.StatusNotFound, model.ErrorResponse(model.CodeNotFound, "batch not found")) + return + } + c.JSON(http.StatusOK, model.SuccessResponse(batch)) +} + +// UpdateBatch 修改批次信息 +// @Summary 修改批次信息 +// @Description 允许修改备注、过期策略、最大下载次数、状态等 +// @Tags Admin +// @Security AdminAuth +// @Accept json +// @Produce json +// @Param batch_id path string true "批次 ID (UUID)" +// @Param request body UpdateBatchRequest true "修改内容" +// @Success 200 {object} model.Response{data=model.FileBatch} +// @Failure 400 {object} model.Response +// @Router /api/admin/batches/{batch_id} [put] +func (h *BatchHandler) UpdateBatch(c *gin.Context) { + id := c.Param("batch_id") + var batch model.FileBatch + if err := bootstrap.DB.First(&batch, "id = ?", id).Error; err != nil { + c.JSON(http.StatusNotFound, model.ErrorResponse(model.CodeNotFound, "batch not found")) + return + } + + rawBody, err := c.GetRawData() + if err != nil { + c.JSON(http.StatusBadRequest, model.ErrorResponse(model.CodeBadRequest, "failed to read body")) + return + } + c.Request.Body = io.NopCloser(bytes.NewBuffer(rawBody)) + + var input UpdateBatchRequest + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, model.ErrorResponse(model.CodeBadRequest, err.Error())) + return + } + + var rawMap map[string]interface{} + json.Unmarshal(rawBody, &rawMap) + + updates := make(map[string]interface{}) + if input.Remark != nil { + updates["remark"] = *input.Remark + } + if input.ExpireType != nil { + newType := *input.ExpireType + updates["expire_type"] = newType + + // 如果类型发生变化,根据新类型清除不相关的配置 + if newType != batch.ExpireType { + if newType == "download" { + updates["expire_at"] = nil + } else if newType == "time" { + updates["max_downloads"] = 0 + } else if newType == "permanent" { + updates["expire_at"] = nil + updates["max_downloads"] = 0 + } + } + } + + // 显式提供的值具有最高优先级,但仅在逻辑允许的情况下 + // 例如:如果切换到了 download 类型,用户可以同时提供一个新的 max_downloads + if _, ok := rawMap["expire_at"]; ok { + updates["expire_at"] = input.ExpireAt + } + if input.MaxDownloads != nil { + updates["max_downloads"] = *input.MaxDownloads + } + + // 强制校验:如果最终结果是 permanent,确保限制被清空 + // 这样即使用户在请求中显式传了非零值,也会被修正 + finalType := batch.ExpireType + if t, ok := updates["expire_type"].(string); ok { + finalType = t + } + + if finalType == "permanent" { + updates["expire_at"] = nil + updates["max_downloads"] = 0 + } else if finalType == "time" { + // 如果是时间过期,max_downloads 应该始终为 0 + updates["max_downloads"] = 0 + } else if finalType == "download" { + // 如果是下载次数过期,expire_at 应该始终为 null + updates["expire_at"] = nil + } + if input.DownloadCount != nil { + updates["download_count"] = *input.DownloadCount + } + if input.Status != nil { + updates["status"] = *input.Status + } + + if len(updates) > 0 { + if err := bootstrap.DB.Model(&batch).Updates(updates).Error; err != nil { + c.JSON(http.StatusInternalServerError, model.ErrorResponse(model.CodeInternalError, err.Error())) + return + } + // 重新从数据库读取,确保返回的是完整且最新的数据 + bootstrap.DB.First(&batch, "id = ?", id) + } + + c.JSON(http.StatusOK, model.SuccessResponse(batch)) +} + +// CleanBatches 手动触发清理过期或已删除的批次 +// @Summary 手动触发清理 +// @Description 手动扫描并物理删除所有已过期或标记为删除的文件批次及其关联文件 +// @Tags Admin +// @Security AdminAuth +// @Produce json +// @Success 200 {object} model.Response +// @Failure 500 {object} model.Response +// @Router /api/admin/batches/clean [post] +func (h *BatchHandler) CleanBatches(c *gin.Context) { + slog.Info("Admin triggered manual cleanup") + if err := h.batchService.Cleanup(c.Request.Context()); err != nil { + slog.Error("Manual cleanup failed", "error", err) + c.JSON(http.StatusInternalServerError, model.ErrorResponse(model.CodeInternalError, "cleanup failed: "+err.Error())) + return + } + c.JSON(http.StatusOK, model.SuccessResponse(nil)) +} + +// DeleteBatch 删除批次 +// @Summary 删除批次 +// @Description 标记批次为已删除,并物理删除关联的存储文件 +// @Tags Admin +// @Security AdminAuth +// @Param batch_id path string true "批次 ID (UUID)" +// @Produce json +// @Success 200 {object} model.Response +// @Failure 500 {object} model.Response +// @Router /api/admin/batches/{batch_id} [delete] +func (h *BatchHandler) DeleteBatch(c *gin.Context) { + id := c.Param("batch_id") + + if err := h.batchService.DeleteBatch(c.Request.Context(), id); err != nil { + c.JSON(http.StatusInternalServerError, model.ErrorResponse(model.CodeInternalError, err.Error())) + return + } + + c.JSON(http.StatusOK, model.SuccessResponse(map[string]interface{}{})) +} diff --git a/internal/api/admin/config.go b/internal/api/admin/config.go new file mode 100644 index 0000000..df50faa --- /dev/null +++ b/internal/api/admin/config.go @@ -0,0 +1,107 @@ +package admin + +import ( + "FileRelay/internal/bootstrap" + "FileRelay/internal/config" + "FileRelay/internal/model" + "FileRelay/internal/service" + "net/http" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" +) + +type ConfigHandler struct{} + +func NewConfigHandler() *ConfigHandler { + return &ConfigHandler{} +} + +// GetConfig 获取当前完整配置 +// @Summary 获取完整配置 +// @Description 获取系统的完整配置文件内容(仅管理员) +// @Tags Admin +// @Security AdminAuth +// @Produce json +// @Success 200 {object} model.Response{data=config.Config} +// @Router /api/admin/config [get] +func (h *ConfigHandler) GetConfig(c *gin.Context) { + c.JSON(http.StatusOK, model.SuccessResponse(config.GlobalConfig)) +} + +// UpdateConfig 更新配置 +// @Summary 更新配置 +// @Description 更新系统的配置文件内容(仅管理员) +// @Tags Admin +// @Security AdminAuth +// @Accept json +// @Produce json +// @Param config body config.Config true "新配置内容" +// @Success 200 {object} model.Response{data=config.Config} +// @Failure 400 {object} model.Response +// @Failure 500 {object} model.Response +// @Router /api/admin/config [put] +func (h *ConfigHandler) UpdateConfig(c *gin.Context) { + var newConfig config.Config + if err := c.ShouldBindJSON(&newConfig); err != nil { + c.JSON(http.StatusBadRequest, model.ErrorResponse(model.CodeBadRequest, err.Error())) + return + } + + // 简单的校验,防止关键配置被改空 + if newConfig.Database.Path == "" { + newConfig.Database.Path = config.GlobalConfig.Database.Path + } + if newConfig.Site.Port <= 0 || newConfig.Site.Port > 65535 { + newConfig.Site.Port = 8080 + } + + // 如果传入了明文密码,则重新生成 hash + if newConfig.Security.AdminPassword != "" { + hash, err := bcrypt.GenerateFromPassword([]byte(newConfig.Security.AdminPassword), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, model.ErrorResponse(model.CodeInternalError, "Failed to hash password: "+err.Error())) + return + } + newConfig.Security.AdminPasswordHash = string(hash) + } + + // 检查取件码长度是否变化 + pickupCodeLengthChanged := newConfig.Security.PickupCodeLength != config.GlobalConfig.Security.PickupCodeLength && newConfig.Security.PickupCodeLength > 0 + // 检查数据库配置是否变化 + dbConfigChanged := newConfig.Database != config.GlobalConfig.Database + + // 如果长度变化,同步更新现有取件码 (在可能切换数据库前,先处理旧库数据) + if pickupCodeLengthChanged { + batchService := service.NewBatchService() + if err := batchService.UpdateAllPickupCodes(newConfig.Security.PickupCodeLength); err != nil { + c.JSON(http.StatusInternalServerError, model.ErrorResponse(model.CodeInternalError, "Failed to update existing pickup codes: "+err.Error())) + return + } + } + + // 更新内存配置 + config.UpdateGlobalConfig(&newConfig) + + // 重新连接数据库并迁移数据(如果配置发生变化) + if dbConfigChanged { + if err := bootstrap.ReloadDB(newConfig.Database); err != nil { + c.JSON(http.StatusInternalServerError, model.ErrorResponse(model.CodeInternalError, "Failed to reload database: "+err.Error())) + return + } + } + + // 重新初始化存储(热更新业务逻辑) + if err := bootstrap.ReloadStorage(); err != nil { + c.JSON(http.StatusInternalServerError, model.ErrorResponse(model.CodeInternalError, "Failed to reload storage: "+err.Error())) + return + } + + // 保存到文件 + if err := config.SaveConfig(); err != nil { + c.JSON(http.StatusInternalServerError, model.ErrorResponse(model.CodeInternalError, "Failed to save config: "+err.Error())) + return + } + + c.JSON(http.StatusOK, model.SuccessResponse(config.GlobalConfig)) +} diff --git a/internal/api/admin/token.go b/internal/api/admin/token.go new file mode 100644 index 0000000..344a161 --- /dev/null +++ b/internal/api/admin/token.go @@ -0,0 +1,138 @@ +package admin + +import ( + "FileRelay/internal/bootstrap" + "FileRelay/internal/model" + "FileRelay/internal/service" + "net/http" + "time" + + "github.com/gin-gonic/gin" +) + +type TokenHandler struct { + tokenService *service.TokenService +} + +func NewTokenHandler() *TokenHandler { + return &TokenHandler{ + tokenService: service.NewTokenService(), + } +} + +type CreateTokenRequest struct { + Name string `json:"name" binding:"required" example:"Test Token"` + Scope string `json:"scope" example:"upload,pickup" enums:"upload,pickup,admin"` + ExpireAt *time.Time `json:"expire_at"` +} + +type CreateTokenResponse struct { + Token string `json:"token"` + Data *model.APIToken `json:"data"` +} + +// ListTokens 获取 API Token 列表 +// @Summary 获取 API Token 列表 +// @Description 获取系统中所有 API Token 的详细信息(不包含哈希) +// @Tags Admin +// @Security AdminAuth +// @Produce json +// @Success 200 {object} model.Response{data=[]model.APIToken} +// @Failure 401 {object} model.Response +// @Router /api/admin/api-tokens [get] +func (h *TokenHandler) ListTokens(c *gin.Context) { + var tokens []model.APIToken + if err := bootstrap.DB.Find(&tokens).Error; err != nil { + c.JSON(http.StatusInternalServerError, model.ErrorResponse(model.CodeInternalError, err.Error())) + return + } + c.JSON(http.StatusOK, model.SuccessResponse(tokens)) +} + +// CreateToken 创建 API Token +// @Summary 创建 API Token +// @Description 创建一个新的 API Token,返回原始 Token(仅显示一次) +// @Tags Admin +// @Security AdminAuth +// @Accept json +// @Produce json +// @Param request body CreateTokenRequest true "Token 信息" +// @Success 201 {object} model.Response{data=CreateTokenResponse} +// @Failure 400 {object} model.Response +// @Router /api/admin/api-tokens [post] +func (h *TokenHandler) CreateToken(c *gin.Context) { + var input CreateTokenRequest + + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, model.ErrorResponse(model.CodeBadRequest, err.Error())) + return + } + + rawToken, token, err := h.tokenService.CreateToken(input.Name, input.Scope, input.ExpireAt) + if err != nil { + c.JSON(http.StatusInternalServerError, model.ErrorResponse(model.CodeInternalError, err.Error())) + return + } + + c.JSON(http.StatusCreated, model.SuccessResponse(CreateTokenResponse{ + Token: rawToken, + Data: token, + })) +} + +// RevokeToken 撤销 API Token +// @Summary 撤销 API Token +// @Description 将 API Token 标记为已撤销,使其失效但保留记录 +// @Tags Admin +// @Security AdminAuth +// @Param id path int true "Token ID" +// @Produce json +// @Success 200 {object} model.Response +// @Failure 500 {object} model.Response +// @Router /api/admin/api-tokens/{id}/revoke [post] +func (h *TokenHandler) RevokeToken(c *gin.Context) { + id := c.Param("id") + if err := bootstrap.DB.Model(&model.APIToken{}).Where("id = ?", id).Update("revoked", true).Error; err != nil { + c.JSON(http.StatusInternalServerError, model.ErrorResponse(model.CodeInternalError, err.Error())) + return + } + c.JSON(http.StatusOK, model.SuccessResponse(map[string]interface{}{})) +} + +// RecoverToken 恢复 API Token +// @Summary 恢复 API Token +// @Description 将已撤销的 API Token 恢复为有效状态 +// @Tags Admin +// @Security AdminAuth +// @Param id path int true "Token ID" +// @Produce json +// @Success 200 {object} model.Response +// @Failure 500 {object} model.Response +// @Router /api/admin/api-tokens/{id}/recover [post] +func (h *TokenHandler) RecoverToken(c *gin.Context) { + id := c.Param("id") + if err := bootstrap.DB.Model(&model.APIToken{}).Where("id = ?", id).Update("revoked", false).Error; err != nil { + c.JSON(http.StatusInternalServerError, model.ErrorResponse(model.CodeInternalError, err.Error())) + return + } + c.JSON(http.StatusOK, model.SuccessResponse(map[string]interface{}{})) +} + +// DeleteToken 删除 API Token +// @Summary 删除 API Token +// @Description 根据 ID 永久删除 API Token +// @Tags Admin +// @Security AdminAuth +// @Param id path int true "Token ID" +// @Produce json +// @Success 200 {object} model.Response +// @Failure 500 {object} model.Response +// @Router /api/admin/api-tokens/{id} [delete] +func (h *TokenHandler) DeleteToken(c *gin.Context) { + id := c.Param("id") + if err := bootstrap.DB.Delete(&model.APIToken{}, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, model.ErrorResponse(model.CodeInternalError, err.Error())) + return + } + c.JSON(http.StatusOK, model.SuccessResponse(map[string]interface{}{})) +} diff --git a/internal/api/middleware/auth.go b/internal/api/middleware/auth.go new file mode 100644 index 0000000..24014a8 --- /dev/null +++ b/internal/api/middleware/auth.go @@ -0,0 +1,119 @@ +package middleware + +import ( + "FileRelay/internal/auth" + "FileRelay/internal/config" + "FileRelay/internal/model" + "FileRelay/internal/service" + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +func AdminAuth() gin.HandlerFunc { + tokenService := service.NewTokenService() + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, model.ErrorResponse(model.CodeUnauthorized, "Authorization header required")) + c.Abort() + return + } + + parts := strings.SplitN(authHeader, " ", 2) + if !(len(parts) == 2 && parts[0] == "Bearer") { + c.JSON(http.StatusUnauthorized, model.ErrorResponse(model.CodeUnauthorized, "Invalid authorization format")) + c.Abort() + return + } + + tokenStr := parts[1] + + // 1. 尝试解析为管理员 JWT + claims, err := auth.ParseToken(tokenStr) + if err == nil { + c.Set("admin_id", claims.AdminID) + c.Next() + return + } + + // 2. 尝试解析为 API Token (如果配置允许) + if config.GlobalConfig.APIToken.Enabled && config.GlobalConfig.APIToken.AllowAdminAPI { + token, err := tokenService.ValidateToken(tokenStr, model.ScopeAdmin) + if err == nil { + c.Set("token_id", token.ID) + c.Set("token_scope", token.Scope) + c.Next() + return + } + } + + c.JSON(http.StatusUnauthorized, model.ErrorResponse(model.CodeUnauthorized, "Invalid or expired token")) + c.Abort() + } +} + +func APITokenAuth(requiredScope string, optional bool) gin.HandlerFunc { + tokenService := service.NewTokenService() + return func(c *gin.Context) { + handleAPITokenAuth(c, tokenService, requiredScope, optional) + } +} + +func UploadAuth() gin.HandlerFunc { + tokenService := service.NewTokenService() + return func(c *gin.Context) { + // 动态获取配置 + optional := !config.GlobalConfig.Upload.RequireToken + handleAPITokenAuth(c, tokenService, model.ScopeUpload, optional) + } +} + +func handleAPITokenAuth(c *gin.Context, tokenService *service.TokenService, requiredScope string, optional bool) { + // 如果是可选的,直接跳过校验,满足“未打开对应的开关时不需校验”的需求 + if optional { + c.Next() + return + } + + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, model.ErrorResponse(model.CodeUnauthorized, "Authorization header required")) + c.Abort() + return + } + + parts := strings.SplitN(authHeader, " ", 2) + if !(len(parts) == 2 && parts[0] == "Bearer") { + c.JSON(http.StatusUnauthorized, model.ErrorResponse(model.CodeUnauthorized, "Invalid authorization format")) + c.Abort() + return + } + + tokenStr := parts[1] + + // 1. 尝试解析为管理员 JWT + if claims, err := auth.ParseToken(tokenStr); err == nil { + c.Set("admin_id", claims.AdminID) + c.Next() + return + } + + if !config.GlobalConfig.APIToken.Enabled { + c.JSON(http.StatusForbidden, model.ErrorResponse(model.CodeForbidden, "API Token is disabled")) + c.Abort() + return + } + + token, err := tokenService.ValidateToken(tokenStr, requiredScope) + if err != nil { + c.JSON(http.StatusUnauthorized, model.ErrorResponse(model.CodeUnauthorized, err.Error())) + c.Abort() + return + } + + c.Set("token_id", token.ID) + c.Set("token_scope", token.Scope) + c.Next() +} diff --git a/internal/api/middleware/limit.go b/internal/api/middleware/limit.go new file mode 100644 index 0000000..9aba43e --- /dev/null +++ b/internal/api/middleware/limit.go @@ -0,0 +1,56 @@ +package middleware + +import ( + "FileRelay/internal/config" + "FileRelay/internal/model" + "log/slog" + "net/http" + "sync" + "time" + + "github.com/gin-gonic/gin" +) + +var ( + pickupFailures = make(map[string]int) + failureMutex sync.Mutex +) + +func PickupRateLimit() gin.HandlerFunc { + return func(c *gin.Context) { + key := c.ClientIP() + + failureMutex.Lock() + count, exists := pickupFailures[key] + failureMutex.Unlock() + + if exists && count >= config.GlobalConfig.Security.PickupFailLimit { + slog.Warn("Pickup rate limit exceeded", "ip", key, "count", count) + c.JSON(http.StatusTooManyRequests, model.ErrorResponse(model.CodeTooManyRequests, "Too many failed attempts. Please try again later.")) + c.Abort() + return + } + + c.Next() + } +} + +func RecordPickupFailure(ip string) { + key := ip + failureMutex.Lock() + pickupFailures[key]++ + + // 仅在第一次失败时启动清除记录的计时器 + if pickupFailures[key] == 1 { + go func() { + // 设置 1 分钟后清除记录 (简单实现) + time.Sleep(1 * time.Hour) + failureMutex.Lock() + delete(pickupFailures, key) + slog.Info("Pickup failure record cleared", "ip", key) + failureMutex.Unlock() + }() + } + + failureMutex.Unlock() +} diff --git a/internal/api/public/config.go b/internal/api/public/config.go new file mode 100644 index 0000000..1493245 --- /dev/null +++ b/internal/api/public/config.go @@ -0,0 +1,61 @@ +package public + +import ( + "FileRelay/internal/config" + "FileRelay/internal/model" + "net/http" + + "github.com/gin-gonic/gin" +) + +type ConfigHandler struct{} + +func NewConfigHandler() *ConfigHandler { + return &ConfigHandler{} +} + +// PublicConfig 公开配置结构 +type PublicConfig struct { + Site config.SiteConfig `json:"site"` + Security PublicSecurityConfig `json:"security"` + Upload config.UploadConfig `json:"upload"` + APIToken PublicAPITokenConfig `json:"api_token"` + Storage PublicStorageConfig `json:"storage"` +} + +type PublicSecurityConfig struct { + PickupCodeLength int `json:"pickup_code_length"` +} + +type PublicAPITokenConfig struct { + Enabled bool `json:"enabled"` +} + +type PublicStorageConfig struct { + Type string `json:"type"` +} + +// GetPublicConfig 获取非敏感配置 +// @Summary 获取公共配置 +// @Description 获取前端展示所需的非敏感配置数据 +// @Tags Public +// @Produce json +// @Success 200 {object} model.Response{data=PublicConfig} +// @Router /api/config [get] +func (h *ConfigHandler) GetPublicConfig(c *gin.Context) { + pub := PublicConfig{ + Site: config.GlobalConfig.Site, + Security: PublicSecurityConfig{ + PickupCodeLength: config.GlobalConfig.Security.PickupCodeLength, + }, + Upload: config.GlobalConfig.Upload, + APIToken: PublicAPITokenConfig{ + Enabled: config.GlobalConfig.APIToken.Enabled, + }, + Storage: PublicStorageConfig{ + Type: config.GlobalConfig.Storage.Type, + }, + } + + c.JSON(http.StatusOK, model.SuccessResponse(pub)) +} diff --git a/internal/api/public/pickup.go b/internal/api/public/pickup.go new file mode 100644 index 0000000..6498bca --- /dev/null +++ b/internal/api/public/pickup.go @@ -0,0 +1,314 @@ +package public + +import ( + "FileRelay/internal/api/middleware" + "FileRelay/internal/bootstrap" + "FileRelay/internal/config" + "FileRelay/internal/model" + "FileRelay/internal/service" + "FileRelay/internal/storage" + "archive/zip" + "fmt" + "io" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +type PickupResponse struct { + Remark string `json:"remark"` + ExpireAt *time.Time `json:"expire_at"` + ExpireType string `json:"expire_type"` + DownloadCount int `json:"download_count"` + MaxDownloads int `json:"max_downloads"` + Type string `json:"type"` + Content string `json:"content,omitempty"` + Files []model.FileItem `json:"files,omitempty"` +} + +type DownloadCountResponse struct { + DownloadCount int `json:"download_count"` + MaxDownloads int `json:"max_downloads"` +} + +// DownloadBatch 批量下载文件 (ZIP) +// @Summary 批量下载文件 +// @Description 根据取件码将批次内的所有文件打包为 ZIP 格式一次性下载。可选提供带 pickup scope 的 API Token。 +// @Tags Public +// @Security APITokenAuth +// @Param pickup_code path string true "取件码" +// @Produce application/zip +// @Success 200 {file} file +// @Failure 404 {object} model.Response +// @Router /api/batches/{pickup_code}/download [get] +func (h *PickupHandler) DownloadBatch(c *gin.Context) { + code := c.Param("pickup_code") + batch, err := h.batchService.GetBatchByPickupCode(code) + if err != nil { + c.JSON(http.StatusNotFound, model.ErrorResponse(model.CodeNotFound, "batch not found or expired")) + return + } + + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"batch_%s.zip\"", code)) + c.Header("Content-Type", "application/zip") + + zw := zip.NewWriter(c.Writer) + defer zw.Close() + + for _, item := range batch.FileItems { + reader, err := storage.GlobalStorage.Open(c.Request.Context(), item.StoragePath) + if err != nil { + continue // Skip failed files + } + + f, err := zw.Create(item.OriginalName) + if err != nil { + reader.Close() + continue + } + + _, _ = io.Copy(f, reader) + reader.Close() + } + + // 增加下载次数 + if err := h.batchService.IncrementDownloadCount(batch.ID); err != nil { + slog.Error("Failed to increment download count", "batch_id", batch.ID, "error", err) + } +} + +type PickupHandler struct { + batchService *service.BatchService +} + +func NewPickupHandler() *PickupHandler { + return &PickupHandler{ + batchService: service.NewBatchService(), + } +} + +// Pickup 获取批次信息 +// @Summary 获取批次信息 +// @Description 根据取件码获取文件批次详细信息和文件列表。可选提供带 pickup scope 的 API Token。 +// @Tags Public +// @Security APITokenAuth +// @Produce json +// @Param pickup_code path string true "取件码" +// @Success 200 {object} model.Response{data=PickupResponse} +// @Failure 404 {object} model.Response +// @Router /api/batches/{pickup_code} [get] +func (h *PickupHandler) Pickup(c *gin.Context) { + code := c.Param("pickup_code") + if code == "" { + c.JSON(http.StatusBadRequest, model.ErrorResponse(model.CodeBadRequest, "pickup code required")) + return + } + + batch, err := h.batchService.GetBatchByPickupCode(code) + if err != nil { + middleware.RecordPickupFailure(c.ClientIP()) + c.JSON(http.StatusNotFound, model.ErrorResponse(model.CodeNotFound, "batch not found or expired")) + return + } + + if batch.Type == "text" { + if err := h.batchService.IncrementDownloadCount(batch.ID); err != nil { + slog.Error("Failed to increment download count for batch", "batch_id", batch.ID, "error", err) + } else { + batch.DownloadCount++ + } + } + + baseURL := getBaseURL(c) + + for i := range batch.FileItems { + batch.FileItems[i].DownloadURL = fmt.Sprintf("%s/api/files/%s/%s", baseURL, batch.FileItems[i].ID, batch.FileItems[i].OriginalName) + } + + c.JSON(http.StatusOK, model.SuccessResponse(PickupResponse{ + Remark: batch.Remark, + ExpireAt: batch.ExpireAt, + ExpireType: batch.ExpireType, + DownloadCount: batch.DownloadCount, + MaxDownloads: batch.MaxDownloads, + Type: batch.Type, + Content: batch.Content, + Files: batch.FileItems, + })) +} + +// GetDownloadCount 查询下载次数 +// @Summary 查询下载次数 +// @Description 根据取件码查询当前下载次数和最大允许下载次数。支持已过期的批次。 +// @Tags Public +// @Produce json +// @Param pickup_code path string true "取件码" +// @Success 200 {object} model.Response{data=DownloadCountResponse} +// @Failure 400 {object} model.Response +// @Failure 404 {object} model.Response +// @Router /api/batches/{pickup_code}/count [get] +func (h *PickupHandler) GetDownloadCount(c *gin.Context) { + code := c.Param("pickup_code") + if code == "" { + c.JSON(http.StatusBadRequest, model.ErrorResponse(model.CodeBadRequest, "pickup code required")) + return + } + + count, max, err := h.batchService.GetDownloadCountByPickupCode(code) + if err != nil { + middleware.RecordPickupFailure(c.ClientIP()) + c.JSON(http.StatusNotFound, model.ErrorResponse(model.CodeNotFound, "batch not found")) + return + } + + c.JSON(http.StatusOK, model.SuccessResponse(DownloadCountResponse{ + DownloadCount: count, + MaxDownloads: max, + })) +} + +func getBaseURL(c *gin.Context) string { + // 优先使用配置中的 BaseURL + if config.GlobalConfig.Site.BaseURL != "" { + return strings.TrimSuffix(config.GlobalConfig.Site.BaseURL, "/") + } + + // 自动检测逻辑 + scheme := "http" + if c.Request.TLS != nil { + scheme = "https" + } else { + // 检查常用的代理协议头 (优先) + // 增加对用户提供的 :scheme (可能被某些代理转为普通 header) 的支持 + // 增加对 X-Forwarded-Proto 可能存在的逗号分隔列表的处理 + checkHeaders := []struct { + name string + values []string + }{ + {"X-Forwarded-Proto", []string{"https"}}, + {"X-Forwarded-Protocol", []string{"https"}}, + {"X-Url-Scheme", []string{"https"}}, + {"Front-End-Https", []string{"on", "https"}}, + {"X-Forwarded-Ssl", []string{"on", "https"}}, + {":scheme", []string{"https"}}, + {"X-Scheme", []string{"https"}}, + } + + found := false + for _, h := range checkHeaders { + val := c.GetHeader(h.name) + if val == "" { + continue + } + // 处理可能的逗号分隔列表 (如 X-Forwarded-Proto: https, http) + firstVal := strings.TrimSpace(strings.ToLower(strings.Split(val, ",")[0])) + for _, target := range h.values { + if firstVal == target { + scheme = "https" + found = true + break + } + } + if found { + break + } + } + + // 检查 Forwarded 头部 (RFC 7239) + if !found { + if forwarded := c.GetHeader("Forwarded"); forwarded != "" { + if strings.Contains(strings.ToLower(forwarded), "proto=https") { + scheme = "https" + found = true + } + } + } + + // 启发式判断:如果上述头部都没有,但 Referer 是 https,则认为也是 https + // 这在同域 API 请求时非常可靠 + if !found { + if referer := c.GetHeader("Referer"); strings.HasPrefix(strings.ToLower(referer), "https://") { + scheme = "https" + } + } + } + + host := c.Request.Host + if forwardedHost := c.GetHeader("X-Forwarded-Host"); forwardedHost != "" { + // 处理可能的逗号分隔列表 + host = strings.TrimSpace(strings.Split(forwardedHost, ",")[0]) + } + + return fmt.Sprintf("%s://%s", scheme, host) +} + +// DownloadFile 下载单个文件 +// @Summary 下载单个文件 +// @Description 根据文件 ID 下载单个文件。支持直观的文件名结尾以方便下载工具识别。可选提供带 pickup scope 的 API Token。 +// @Tags Public +// @Security APITokenAuth +// @Param file_id path string true "文件 ID (UUID)" +// @Param filename path string false "文件名" +// @Produce application/octet-stream +// @Success 200 {file} file +// @Failure 404 {object} model.Response +// @Failure 410 {object} model.Response +// @Router /api/files/{file_id}/{filename} [get] +// @Router /api/files/{file_id}/download [get] +func (h *PickupHandler) DownloadFile(c *gin.Context) { + fileID := c.Param("file_id") + + var item model.FileItem + if err := bootstrap.DB.First(&item, "id = ?", fileID).Error; err != nil { + c.JSON(http.StatusNotFound, model.ErrorResponse(model.CodeNotFound, "file not found")) + return + } + + var batch model.FileBatch + if err := bootstrap.DB.First(&batch, "id = ?", item.BatchID).Error; err != nil { + c.JSON(http.StatusNotFound, model.ErrorResponse(model.CodeNotFound, "batch not found")) + return + } + + if h.batchService.IsExpired(&batch) { + h.batchService.MarkAsExpired(&batch) + // 按照需求,如果不存在(已在上面处理)或达到上限,返回 404 + if batch.ExpireType == "download" && batch.DownloadCount >= batch.MaxDownloads { + c.JSON(http.StatusNotFound, model.ErrorResponse(model.CodeNotFound, "file not found or download limit reached")) + } else { + c.JSON(http.StatusGone, model.ErrorResponse(model.CodeGone, "batch expired")) + } + return + } + + // 打开文件 + reader, err := storage.GlobalStorage.Open(c.Request.Context(), item.StoragePath) + if err != nil { + c.JSON(http.StatusInternalServerError, model.ErrorResponse(model.CodeInternalError, "failed to open file")) + return + } + defer reader.Close() + + // 增加下载次数 + if err := h.batchService.IncrementDownloadCount(batch.ID); err != nil { + // 记录错误但不中断下载过程 + slog.Error("Failed to increment download count for batch", "batch_id", batch.ID, "error", err) + } + + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", item.OriginalName)) + c.Header("Content-Type", item.MimeType) + c.Header("Content-Length", strconv.FormatInt(item.Size, 10)) + + // 如果是 HEAD 请求,只返回 Header + if c.Request.Method == http.MethodHead { + return + } + + if _, err := io.Copy(c.Writer, reader); err != nil { + slog.Error("Error during file download", "file_id", item.ID, "error", err) + } +} diff --git a/internal/api/public/upload.go b/internal/api/public/upload.go new file mode 100644 index 0000000..4599a15 --- /dev/null +++ b/internal/api/public/upload.go @@ -0,0 +1,175 @@ +package public + +import ( + "FileRelay/internal/config" + "FileRelay/internal/model" + "FileRelay/internal/service" + "fmt" + "log/slog" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" +) + +type UploadHandler struct { + uploadService *service.UploadService +} + +func NewUploadHandler() *UploadHandler { + return &UploadHandler{ + uploadService: service.NewUploadService(), + } +} + +type UploadResponse struct { + PickupCode string `json:"pickup_code"` + ExpireAt *time.Time `json:"expire_at"` + BatchID string `json:"batch_id"` +} + +// Upload 上传文件并生成取件码 +// @Summary 上传文件 +// @Description 上传一个或多个文件并创建一个提取批次。如果配置了 require_token,则必须提供带 upload scope 的 API Token。 +// @Tags Public +// @Accept multipart/form-data +// @Produce json +// @Security APITokenAuth +// @Param files formData file true "文件列表" +// @Param remark formData string false "备注" +// @Param expire_type formData string false "过期类型 (time/download/permanent)" +// @Param expire_days formData int false "过期天数 (针对 time 类型)" +// @Param max_downloads formData int false "最大下载次数 (针对 download 类型)" +// @Success 200 {object} model.Response{data=UploadResponse} +// @Failure 400 {object} model.Response +// @Failure 500 {object} model.Response +// @Router /api/batches [post] +func (h *UploadHandler) Upload(c *gin.Context) { + form, err := c.MultipartForm() + if err != nil { + c.JSON(http.StatusBadRequest, model.ErrorResponse(model.CodeBadRequest, "invalid form")) + return + } + + files := form.File["files"] + if len(files) == 0 { + c.JSON(http.StatusBadRequest, model.ErrorResponse(model.CodeBadRequest, "no files uploaded")) + return + } + + if len(files) > config.GlobalConfig.Upload.MaxBatchFiles { + c.JSON(http.StatusBadRequest, model.ErrorResponse(model.CodeBadRequest, "too many files")) + return + } + + // 校验单个文件大小 + maxSize := config.GlobalConfig.Upload.MaxFileSizeMB * 1024 * 1024 + for _, file := range files { + if file.Size > maxSize { + c.JSON(http.StatusBadRequest, model.ErrorResponse(model.CodeBadRequest, fmt.Sprintf("文件 %s 超过最大限制 (%dMB)", file.Filename, config.GlobalConfig.Upload.MaxFileSizeMB))) + return + } + } + + remark := c.PostForm("remark") + expireType := c.PostForm("expire_type") // time / download / permanent + if expireType == "" { + expireType = "time" + } + + var expireValue interface{} + switch expireType { + case "time": + days, _ := strconv.Atoi(c.PostForm("expire_days")) + if days <= 0 { + days = config.GlobalConfig.Upload.MaxRetentionDays + } + expireValue = days + case "download": + max, _ := strconv.Atoi(c.PostForm("max_downloads")) + if max <= 0 { + max = 1 + } + expireValue = max + } + + batch, err := h.uploadService.CreateBatch(c.Request.Context(), files, remark, expireType, expireValue) + if err != nil { + slog.Error("Upload handler failed to create batch", "error", err) + c.JSON(http.StatusInternalServerError, model.ErrorResponse(model.CodeInternalError, err.Error())) + return + } + + c.JSON(http.StatusOK, model.SuccessResponse(UploadResponse{ + PickupCode: batch.PickupCode, + ExpireAt: batch.ExpireAt, + BatchID: batch.ID, + })) +} + +type UploadTextRequest struct { + Content string `json:"content" binding:"required" example:"这是一段长文本内容..."` + Remark string `json:"remark" example:"文本备注"` + ExpireType string `json:"expire_type" example:"time"` + ExpireDays int `json:"expire_days" example:"7"` + MaxDownloads int `json:"max_downloads" example:"5"` +} + +// UploadText 发送长文本并生成取件码 +// @Summary 发送长文本 +// @Description 中转一段长文本内容并创建一个提取批次。如果配置了 require_token,则必须提供带 upload scope 的 API Token。 +// @Tags Public +// @Accept json +// @Produce json +// @Security APITokenAuth +// @Param request body UploadTextRequest true "文本内容及配置" +// @Success 200 {object} model.Response{data=UploadResponse} +// @Failure 400 {object} model.Response +// @Failure 500 {object} model.Response +// @Router /api/batches/text [post] +func (h *UploadHandler) UploadText(c *gin.Context) { + var req UploadTextRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.ErrorResponse(model.CodeBadRequest, err.Error())) + return + } + + // 校验文本长度 + maxSize := config.GlobalConfig.Upload.MaxFileSizeMB * 1024 * 1024 + if int64(len(req.Content)) > maxSize { + c.JSON(http.StatusBadRequest, model.ErrorResponse(model.CodeBadRequest, fmt.Sprintf("文本内容超过最大限制 (%dMB)", config.GlobalConfig.Upload.MaxFileSizeMB))) + return + } + + if req.ExpireType == "" { + req.ExpireType = "time" + } + + var expireValue interface{} + switch req.ExpireType { + case "time": + if req.ExpireDays <= 0 { + req.ExpireDays = config.GlobalConfig.Upload.MaxRetentionDays + } + expireValue = req.ExpireDays + case "download": + if req.MaxDownloads <= 0 { + req.MaxDownloads = 1 + } + expireValue = req.MaxDownloads + } + + batch, err := h.uploadService.CreateTextBatch(c.Request.Context(), req.Content, req.Remark, req.ExpireType, expireValue) + if err != nil { + slog.Error("Upload handler failed to create text batch", "error", err) + c.JSON(http.StatusInternalServerError, model.ErrorResponse(model.CodeInternalError, err.Error())) + return + } + + c.JSON(http.StatusOK, model.SuccessResponse(UploadResponse{ + PickupCode: batch.PickupCode, + ExpireAt: batch.ExpireAt, + BatchID: batch.ID, + })) +} diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go new file mode 100644 index 0000000..40e0924 --- /dev/null +++ b/internal/auth/jwt.go @@ -0,0 +1,42 @@ +package auth + +import ( + "FileRelay/internal/config" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type Claims struct { + AdminID uint `json:"admin_id"` + jwt.RegisteredClaims +} + +func GenerateToken(adminID uint) (string, error) { + claims := Claims{ + AdminID: adminID, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(config.GlobalConfig.Security.JWTSecret)) +} + +func ParseToken(tokenString string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { + return []byte(config.GlobalConfig.Security.JWTSecret), nil + }) + + if err != nil { + return nil, err + } + + if claims, ok := token.Claims.(*Claims); ok && token.Valid { + return claims, nil + } + + return nil, jwt.ErrSignatureInvalid +} diff --git a/internal/bootstrap/init.go b/internal/bootstrap/init.go new file mode 100644 index 0000000..70ad0b9 --- /dev/null +++ b/internal/bootstrap/init.go @@ -0,0 +1,406 @@ +package bootstrap + +import ( + "FileRelay/internal/config" + "FileRelay/internal/model" + "FileRelay/internal/storage" + "context" + "crypto/rand" + "fmt" + "io" + "math/big" + "path/filepath" + "runtime" + + "log/slog" + "os" + "strings" + + "github.com/glebarez/sqlite" + "golang.org/x/crypto/bcrypt" + "gorm.io/driver/mysql" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +var DB *gorm.DB + +func InitLog() { + level := slog.LevelInfo + switch strings.ToLower(config.GlobalConfig.Log.Level) { + case "debug": + level = slog.LevelDebug + case "info": + level = slog.LevelInfo + case "warn": + level = slog.LevelWarn + case "error": + level = slog.LevelError + } + + var handlers []slog.Handler + + // 1. 控制台处理器 (简化格式) + handlers = append(handlers, &ConsoleHandler{ + out: os.Stdout, + level: level, + }) + + // 2. 文件处理器 (结构化 Text 格式) + if config.GlobalConfig.Log.FilePath != "" { + logDir := filepath.Dir(config.GlobalConfig.Log.FilePath) + if err := os.MkdirAll(logDir, 0755); err != nil { + fmt.Printf("Warning: Failed to create log directory: %v\n", err) + } else { + file, err := os.OpenFile(config.GlobalConfig.Log.FilePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + fmt.Printf("Warning: Failed to open log file: %v\n", err) + } else { + fileHandler := slog.NewTextHandler(file, &slog.HandlerOptions{ + Level: level, + AddSource: true, + ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { + if a.Key == slog.TimeKey { + return slog.String(a.Key, a.Value.Time().Format("2006-01-02 15:04:05.000")) + } + if a.Key == slog.SourceKey { + source := a.Value.Any().(*slog.Source) + return slog.String(a.Key, fmt.Sprintf("%s:%d", filepath.Base(source.File), source.Line)) + } + return a + }, + }) + handlers = append(handlers, fileHandler) + } + } + } + + var finalHandler slog.Handler + if len(handlers) == 1 { + finalHandler = handlers[0] + } else { + finalHandler = &MultiHandler{handlers: handlers} + } + + logger := slog.New(finalHandler).With("service", "filerelay") + slog.SetDefault(logger) +} + +func InitDB() { + var err error + cfg := config.GlobalConfig.Database + + DB, err = ConnectDB(cfg) + if err != nil { + slog.Error("Failed to initialize database", "type", cfg.Type, "error", err) + os.Exit(1) + } + + slog.Info("Database initialized and migrated", "type", cfg.Type) + + // 初始化存储 + if err := ReloadStorage(); err != nil { + slog.Error("Failed to initialize storage", "error", err) + os.Exit(1) + } + + // 初始化管理员 (如果不存在) + initAdmin() +} + +func ConnectDB(cfg config.DatabaseConfig) (*gorm.DB, error) { + var dialector gorm.Dialector + + switch strings.ToLower(cfg.Type) { + case "mysql": + params := cfg.Config + if params == "" { + params = "parseTime=True&loc=Local&charset=utf8mb4" + } else { + if !strings.Contains(strings.ToLower(params), "parsetime") { + params += "&parseTime=True" + } + if !strings.Contains(strings.ToLower(params), "loc=") { + params += "&loc=Local" + } + if !strings.Contains(strings.ToLower(params), "charset") { + params += "&charset=utf8mb4" + } + } + dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?%s", + cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.DBName, params) + dialector = mysql.Open(dsn) + case "postgres", "postgresql": + dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%d %s", + cfg.Host, cfg.User, cfg.Password, cfg.DBName, cfg.Port, cfg.Config) + dialector = postgres.Open(dsn) + case "sqlite", "sqlite3": + fallthrough + default: + dbPath := cfg.Path + if dbPath == "" { + dbPath = "data/file_relay.db" + } + dialector = sqlite.Open(dbPath) + } + + db, err := gorm.Open(dialector, &gorm.Config{}) + if err != nil { + return nil, err + } + + // 自动迁移 + err = db.AutoMigrate( + &model.FileBatch{}, + &model.FileItem{}, + &model.APIToken{}, + ) + if err != nil { + return nil, err + } + + return db, nil +} + +func ReloadDB(newCfg config.DatabaseConfig) error { + newDB, err := ConnectDB(newCfg) + if err != nil { + return err + } + + if DB != nil { + // 检查是否真的是不同的数据库,避免自迁移导致冲突 + // 这里简单判断类型或连接串是否变化,或者直接让用户决定 + // 为了安全,我们只在连接参数确实变化时才尝试迁移 + slog.Info("Starting data migration to new database...") + if err := MigrateData(DB, newDB); err != nil { + slog.Error("Data migration failed", "error", err) + // 迁移失败不一定需要中断,但需要记录 + } else { + slog.Info("Data migration completed successfully") + } + } + + DB = newDB + return nil +} + +func MigrateData(sourceDB, targetDB *gorm.DB) error { + // 迁移 APIToken + var tokens []model.APIToken + if err := sourceDB.Find(&tokens).Error; err == nil && len(tokens) > 0 { + if err := targetDB.Save(&tokens).Error; err != nil { + slog.Warn("Failed to migrate APITokens", "error", err) + } + } + + // 迁移 FileBatch (分批处理以节省内存) + var batches []model.FileBatch + err := sourceDB.Model(&model.FileBatch{}).FindInBatches(&batches, 100, func(tx *gorm.DB, batch int) error { + if err := targetDB.Save(&batches).Error; err != nil { + return err + } + return nil + }).Error + if err != nil { + slog.Warn("Failed to migrate FileBatches", "error", err) + } + + // 迁移 FileItem (分批处理以节省内存) + var items []model.FileItem + err = sourceDB.Model(&model.FileItem{}).FindInBatches(&items, 100, func(tx *gorm.DB, batch int) error { + if err := targetDB.Save(&items).Error; err != nil { + return err + } + return nil + }).Error + if err != nil { + slog.Warn("Failed to migrate FileItems", "error", err) + } + + return nil +} + +func ReloadStorage() error { + storageType := config.GlobalConfig.Storage.Type + switch storageType { + case "local": + storage.GlobalStorage = storage.NewLocalStorage(config.GlobalConfig.Storage.Local.Path) + case "webdav": + cfg := config.GlobalConfig.Storage.WebDAV + storage.GlobalStorage = storage.NewWebDAVStorage(cfg.URL, cfg.Username, cfg.Password, cfg.Root) + case "s3": + cfg := config.GlobalConfig.Storage.S3 + s3Storage, err := storage.NewS3Storage(context.Background(), cfg.Endpoint, cfg.Region, cfg.AccessKey, cfg.SecretKey, cfg.Bucket, cfg.UseSSL) + if err != nil { + return err + } + storage.GlobalStorage = s3Storage + default: + return fmt.Errorf("unsupported storage type: %s", storageType) + } + slog.Info("Storage initialized", "type", storageType) + return nil +} + +func initAdmin() { + passwordHash := config.GlobalConfig.Security.AdminPasswordHash + if passwordHash == "" { + // 生成随机密码 + password := generateRandomPassword(12) + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + slog.Error("Failed to generate password hash", "error", err) + os.Exit(1) + } + passwordHash = string(hash) + fmt.Printf("**************************************************\n") + fmt.Printf("NO ADMIN PASSWORD CONFIGURED. GENERATED RANDOM PASSWORD:\n") + fmt.Printf("Password: %s\n", password) + fmt.Printf("Please save this password or configure admin_password_hash in config.yaml\n") + fmt.Printf("**************************************************\n") + + // 将生成的哈希保存回配置文件 + config.GlobalConfig.Security.AdminPasswordHash = passwordHash + if err := config.SaveConfig(); err != nil { + slog.Warn("Failed to save generated password hash to config", "error", err) + } + } + slog.Info("Admin authentication initialized") +} + +func generateRandomPassword(length int) string { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*" + b := make([]byte, length) + for i := range b { + num, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) + if err != nil { + return "admin123" // 退路 + } + b[i] = charset[num.Int64()] + } + return string(b) +} + +// ConsoleHandler 实现 simplified 控制台日志 +type ConsoleHandler struct { + out io.Writer + level slog.Leveler + attrs []slog.Attr +} + +func (h *ConsoleHandler) Enabled(_ context.Context, l slog.Level) bool { + return l >= h.level.Level() +} + +func (h *ConsoleHandler) Handle(_ context.Context, r slog.Record) error { + var b strings.Builder + + // 时间: 15:04:05.000 + b.WriteString(r.Time.Format("15:04:05.000")) + b.WriteString(" ") + + // 级别: INFO + level := r.Level.String() + b.WriteString(fmt.Sprintf("%-5s", level)) + b.WriteString(" ") + + // 源码: [main.go:123] + pc := r.PC + if pc == 0 { + // 如果 Logger 没采集 PC (自定义 Handler 默认情况),我们手动采集 + // 跳过: 1:runtime.Callers, 2:Handle, 3:slog.(*Logger).log, 4:slog.(*Logger).Info + var pcs [1]uintptr + runtime.Callers(4, pcs[:]) + pc = pcs[0] + } + + if pc != 0 { + fs := runtime.CallersFrames([]uintptr{pc}) + f, _ := fs.Next() + b.WriteString(fmt.Sprintf("[%s:%d] ", filepath.Base(f.File), f.Line)) + } + + // 消息 + b.WriteString(r.Message) + + // 属性: 仅输出值 + writeAttr := func(a slog.Attr) { + if a.Key == "service" { + return + } + b.WriteString(" ") + val := a.Value.Resolve().String() + if strings.Contains(val, " ") { + b.WriteString(fmt.Sprintf("%q", val)) + } else { + b.WriteString(val) + } + } + + for _, a := range h.attrs { + writeAttr(a) + } + r.Attrs(func(a slog.Attr) bool { + writeAttr(a) + return true + }) + + b.WriteString("\n") + _, err := h.out.Write([]byte(b.String())) + return err +} + +func (h *ConsoleHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + newAttrs := make([]slog.Attr, len(h.attrs)+len(attrs)) + copy(newAttrs, h.attrs) + copy(newAttrs[len(h.attrs):], attrs) + return &ConsoleHandler{ + out: h.out, + level: h.level, + attrs: newAttrs, + } +} + +func (h *ConsoleHandler) WithGroup(name string) slog.Handler { + return h // 简化版暂不支持分组 +} + +// MultiHandler 实现多路分发 +type MultiHandler struct { + handlers []slog.Handler +} + +func (h *MultiHandler) Enabled(ctx context.Context, l slog.Level) bool { + for _, hh := range h.handlers { + if hh.Enabled(ctx, l) { + return true + } + } + return false +} + +func (h *MultiHandler) Handle(ctx context.Context, r slog.Record) error { + for _, hh := range h.handlers { + if hh.Enabled(ctx, r.Level) { + _ = hh.Handle(ctx, r) + } + } + return nil +} + +func (h *MultiHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + newHandlers := make([]slog.Handler, len(h.handlers)) + for i, hh := range h.handlers { + newHandlers[i] = hh.WithAttrs(attrs) + } + return &MultiHandler{handlers: newHandlers} +} + +func (h *MultiHandler) WithGroup(name string) slog.Handler { + newHandlers := make([]slog.Handler, len(h.handlers)) + for i, hh := range h.handlers { + newHandlers[i] = hh.WithGroup(name) + } + return &MultiHandler{handlers: newHandlers} +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..f517fd1 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,282 @@ +package config + +import ( + "os" + "path/filepath" + "strconv" + "sync" + + "gopkg.in/yaml.v3" +) + +type Config struct { + Site SiteConfig `yaml:"site" json:"site"` // 站点设置 + Security SecurityConfig `yaml:"security" json:"security"` // 安全设置 + Upload UploadConfig `yaml:"upload" json:"upload"` // 上传设置 + Storage StorageConfig `yaml:"storage" json:"storage"` // 存储设置 + APIToken APITokenConfig `yaml:"api_token" json:"api_token"` // API Token 设置 + Database DatabaseConfig `yaml:"database" json:"database"` // 数据库设置 + Web WebConfig `yaml:"web" json:"web"` // Web 前端设置 + Log LogConfig `yaml:"log" json:"log"` // 日志设置 +} + +type LogConfig struct { + Level string `yaml:"level" json:"level"` // 日志级别: debug, info, warn, error + FilePath string `yaml:"file_path" json:"file_path"` // 日志文件路径,为空则仅输出到控制台 +} + +type WebConfig struct { + Path string `yaml:"path" json:"path"` // Web 前端资源路径 +} + +type SiteConfig struct { + Name string `yaml:"name" json:"name"` // 站点名称 + Description string `yaml:"description" json:"description"` // 站点描述 + Logo string `yaml:"logo" json:"logo"` // 站点 Logo URL + BaseURL string `yaml:"base_url" json:"base_url"` // 站点外部访问地址 (例如: https://file.example.com) + Port int `yaml:"port" json:"port"` // 监听端口 +} + +type SecurityConfig struct { + AdminPasswordHash string `yaml:"admin_password_hash" json:"admin_password_hash"` // 管理员密码哈希 (bcrypt) + AdminPassword string `yaml:"-" json:"admin_password,omitempty"` // 管理员密码明文 (仅用于更新请求,不保存到文件) + PickupCodeLength int `yaml:"pickup_code_length" json:"pickup_code_length"` // 取件码长度 (变更后将自动通过右侧补零或截取调整存量数据) + PickupFailLimit int `yaml:"pickup_fail_limit" json:"pickup_fail_limit"` // 取件失败尝试限制 + JWTSecret string `yaml:"jwt_secret" json:"jwt_secret"` // JWT 签名密钥 +} + +type UploadConfig struct { + MaxFileSizeMB int64 `yaml:"max_file_size_mb" json:"max_file_size_mb"` // 单个文件最大大小 (MB) + MaxBatchFiles int `yaml:"max_batch_files" json:"max_batch_files"` // 每个批次最大文件数 + MaxRetentionDays int `yaml:"max_retention_days" json:"max_retention_days"` // 最大保留天数 + RequireToken bool `yaml:"require_token" json:"require_token"` // 是否强制要求上传 Token +} + +type StorageConfig struct { + Type string `yaml:"type" json:"type"` // 存储类型: local, webdav, s3 + Local struct { + Path string `yaml:"path" json:"path"` // 本地存储路径 + } `yaml:"local" json:"local"` + WebDAV struct { + URL string `yaml:"url" json:"url"` // WebDAV 地址 + Username string `yaml:"username" json:"username"` // WebDAV 用户名 + Password string `yaml:"password" json:"password"` // WebDAV 密码 + Root string `yaml:"root" json:"root"` // WebDAV 根目录 + } `yaml:"webdav" json:"webdav"` + S3 struct { + Endpoint string `yaml:"endpoint" json:"endpoint"` // S3 端点 + Region string `yaml:"region" json:"region"` // S3 区域 + AccessKey string `yaml:"access_key" json:"access_key"` // S3 Access Key + SecretKey string `yaml:"secret_key" json:"secret_key"` // S3 Secret Key + Bucket string `yaml:"bucket" json:"bucket"` // S3 Bucket + UseSSL bool `yaml:"use_ssl" json:"use_ssl"` // 是否使用 SSL + } `yaml:"s3" json:"s3"` +} + +type APITokenConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` // 是否启用 API Token + AllowAdminAPI bool `yaml:"allow_admin_api" json:"allow_admin_api"` // 是否允许 API Token 访问管理接口 + MaxTokens int `yaml:"max_tokens" json:"max_tokens"` // 最大 Token 数量 +} + +type DatabaseConfig struct { + Type string `yaml:"type" json:"type"` // 数据库类型: sqlite, mysql, postgres + Path string `yaml:"path" json:"path"` // SQLite 数据库文件路径 + Host string `yaml:"host" json:"host"` // 数据库地址 + Port int `yaml:"port" json:"port"` // 数据库端口 + User string `yaml:"user" json:"user"` // 数据库用户名 + Password string `yaml:"password" json:"password"` // 数据库密码 + DBName string `yaml:"dbname" json:"dbname"` // 数据库名称 + Config string `yaml:"config" json:"config"` // 额外配置参数 (DSN) +} + +var ( + GlobalConfig *Config + ConfigPath string + configLock sync.RWMutex +) + +func LoadConfig(path string) error { + configLock.Lock() + defer configLock.Unlock() + + // 检查文件是否存在 + if _, err := os.Stat(path); os.IsNotExist(err) { + // 创建默认配置 + cfg := GetDefaultConfig() + data, err := yaml.Marshal(cfg) + if err != nil { + return err + } + // 确保目录存在 + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + if err := os.WriteFile(path, data, 0644); err != nil { + return err + } + cfg.overrideWithEnv() + GlobalConfig = cfg + ConfigPath = path + return nil + } + + data, err := os.ReadFile(path) + if err != nil { + return err + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return err + } + + cfg.overrideWithEnv() + GlobalConfig = &cfg + ConfigPath = path + return nil +} + +func (cfg *Config) overrideWithEnv() { + // Site settings + if val := os.Getenv("FR_SITE_NAME"); val != "" { + cfg.Site.Name = val + } + if val := os.Getenv("FR_SITE_BASE_URL"); val != "" { + cfg.Site.BaseURL = val + } + if val := os.Getenv("FR_SITE_PORT"); val != "" { + if port, err := strconv.Atoi(val); err == nil { + cfg.Site.Port = port + } + } + + // Security settings + if val := os.Getenv("FR_SECURITY_JWT_SECRET"); val != "" { + cfg.Security.JWTSecret = val + } + + // Upload settings + if val := os.Getenv("FR_UPLOAD_MAX_SIZE"); val != "" { + if size, err := strconv.ParseInt(val, 10, 64); err == nil { + cfg.Upload.MaxFileSizeMB = size + } + } + if val := os.Getenv("FR_UPLOAD_RETENTION_DAYS"); val != "" { + if days, err := strconv.Atoi(val); err == nil { + cfg.Upload.MaxRetentionDays = days + } + } + + // Database settings + if val := os.Getenv("FR_DB_TYPE"); val != "" { + cfg.Database.Type = val + } + if val := os.Getenv("FR_DB_PATH"); val != "" { + cfg.Database.Path = val + } + if val := os.Getenv("FR_DB_HOST"); val != "" { + cfg.Database.Host = val + } + if val := os.Getenv("FR_DB_PORT"); val != "" { + if port, err := strconv.Atoi(val); err == nil { + cfg.Database.Port = port + } + } + if val := os.Getenv("FR_DB_USER"); val != "" { + cfg.Database.User = val + } + if val := os.Getenv("FR_DB_PASSWORD"); val != "" { + cfg.Database.Password = val + } + if val := os.Getenv("FR_DB_NAME"); val != "" { + cfg.Database.DBName = val + } + + // Storage settings + if val := os.Getenv("FR_STORAGE_TYPE"); val != "" { + cfg.Storage.Type = val + } + if val := os.Getenv("FR_STORAGE_LOCAL_PATH"); val != "" { + cfg.Storage.Local.Path = val + } + + // Log settings + if val := os.Getenv("FR_LOG_LEVEL"); val != "" { + cfg.Log.Level = val + } + if val := os.Getenv("FR_LOG_FILE_PATH"); val != "" { + cfg.Log.FilePath = val + } + + // Web settings + if val := os.Getenv("FR_WEB_PATH"); val != "" { + cfg.Web.Path = val + } +} + +func GetDefaultConfig() *Config { + return &Config{ + Site: SiteConfig{ + Name: "文件暂存柜", + Description: "临时文件中转服务", + Logo: "/favicon.png", + BaseURL: "", + Port: 8080, + }, + Security: SecurityConfig{ + AdminPasswordHash: "$2a$10$Bm0TEmU4uj.bVHYiIPFBheUkcdg6XHpsanLvmpoAtgU1UnKbo9.vy", // 默认密码: admin123 + PickupCodeLength: 6, + PickupFailLimit: 5, + JWTSecret: "file-relay-secret", + }, + Upload: UploadConfig{ + MaxFileSizeMB: 100, + MaxBatchFiles: 20, + MaxRetentionDays: 30, + RequireToken: false, + }, + Storage: StorageConfig{ + Type: "local", + Local: struct { + Path string `yaml:"path" json:"path"` + }{ + Path: "data/storage_data", + }, + }, + APIToken: APITokenConfig{ + Enabled: true, + AllowAdminAPI: true, + MaxTokens: 20, + }, + Database: DatabaseConfig{ + Type: "sqlite", + Path: "data/file_relay.db", + }, + Web: WebConfig{ + Path: "web", + }, + Log: LogConfig{ + Level: "info", + FilePath: "data/logs/app.log", + }, + } +} + +func SaveConfig() error { + configLock.RLock() + defer configLock.RUnlock() + + data, err := yaml.Marshal(GlobalConfig) + if err != nil { + return err + } + + return os.WriteFile(ConfigPath, data, 0644) +} + +func UpdateGlobalConfig(cfg *Config) { + configLock.Lock() + defer configLock.Unlock() + GlobalConfig = cfg +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..d1617bc --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,43 @@ +package config + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetDefaultConfig(t *testing.T) { + cfg := GetDefaultConfig() + assert.Equal(t, "data/storage_data", cfg.Storage.Local.Path) + assert.Equal(t, "data/file_relay.db", cfg.Database.Path) + assert.Equal(t, "data/logs/app.log", cfg.Log.FilePath) +} + +func TestOverrideWithEnv(t *testing.T) { + // 设置环境变量 + os.Setenv("FR_SITE_NAME", "EnvSiteName") + os.Setenv("FR_SITE_PORT", "9999") + os.Setenv("FR_DB_TYPE", "mysql") + os.Setenv("FR_DB_PATH", "custom_db_path.db") + os.Setenv("FR_UPLOAD_MAX_SIZE", "500") + os.Setenv("FR_LOG_FILE_PATH", "custom_log_path.log") + + cfg := GetDefaultConfig() + cfg.overrideWithEnv() + + assert.Equal(t, "EnvSiteName", cfg.Site.Name) + assert.Equal(t, 9999, cfg.Site.Port) + assert.Equal(t, "mysql", cfg.Database.Type) + assert.Equal(t, "custom_db_path.db", cfg.Database.Path) + assert.Equal(t, int64(500), cfg.Upload.MaxFileSizeMB) + assert.Equal(t, "custom_log_path.log", cfg.Log.FilePath) + + // 清理环境变量 + os.Unsetenv("FR_SITE_NAME") + os.Unsetenv("FR_SITE_PORT") + os.Unsetenv("FR_DB_TYPE") + os.Unsetenv("FR_DB_PATH") + os.Unsetenv("FR_UPLOAD_MAX_SIZE") + os.Unsetenv("FR_LOG_FILE_PATH") +} diff --git a/internal/model/admin.go b/internal/model/admin.go new file mode 100644 index 0000000..5b8f6e5 --- /dev/null +++ b/internal/model/admin.go @@ -0,0 +1,11 @@ +package model + +import ( + "time" +) + +// AdminSession 管理员会话信息 (不再存库,仅用于 JWT 或 API 交互) +type AdminSession struct { + ID uint `json:"id"` + LastLogin *time.Time `json:"last_login"` +} diff --git a/internal/model/api_token.go b/internal/model/api_token.go new file mode 100644 index 0000000..1318c9c --- /dev/null +++ b/internal/model/api_token.go @@ -0,0 +1,22 @@ +package model + +import ( + "time" +) + +const ( + ScopeUpload = "upload" // 上传权限 + ScopePickup = "pickup" // 取件/下载权限 + ScopeAdmin = "admin" // 管理权限 +) + +type APIToken struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `json:"name"` + TokenHash string `gorm:"uniqueIndex;not null" json:"-"` + Scope string `json:"scope"` + ExpireAt *time.Time `json:"expire_at"` + LastUsedAt *time.Time `json:"last_used_at"` + Revoked bool `gorm:"default:false" json:"revoked"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/internal/model/file_batch.go b/internal/model/file_batch.go new file mode 100644 index 0000000..3478336 --- /dev/null +++ b/internal/model/file_batch.go @@ -0,0 +1,24 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +type FileBatch struct { + ID string `gorm:"primaryKey;type:varchar(36)" json:"id"` + PickupCode string `gorm:"uniqueIndex;not null" json:"pickup_code"` + Remark string `json:"remark"` + ExpireType string `json:"expire_type"` // time / download / permanent + ExpireAt *time.Time `json:"expire_at"` + MaxDownloads int `json:"max_downloads"` + DownloadCount int `gorm:"default:0" json:"download_count"` + Status string `gorm:"default:'active'" json:"status"` // active / expired / deleted + Type string `gorm:"default:'file'" json:"type"` // file / text + Content string `json:"content,omitempty"` + FileItems []FileItem `gorm:"foreignKey:BatchID" json:"file_items,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} diff --git a/internal/model/file_item.go b/internal/model/file_item.go new file mode 100644 index 0000000..578d869 --- /dev/null +++ b/internal/model/file_item.go @@ -0,0 +1,16 @@ +package model + +import ( + "time" +) + +type FileItem struct { + ID string `gorm:"primaryKey;type:varchar(36)" json:"id"` + BatchID string `gorm:"index;not null;type:varchar(36)" json:"batch_id"` + OriginalName string `json:"original_name"` + StoragePath string `json:"storage_path"` + Size int64 `json:"size"` + MimeType string `json:"mime_type"` + DownloadURL string `gorm:"-" json:"download_url,omitempty"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/internal/model/response.go b/internal/model/response.go new file mode 100644 index 0000000..d18c148 --- /dev/null +++ b/internal/model/response.go @@ -0,0 +1,46 @@ +package model + +// Response 统一响应模型 +type Response struct { + Code int `json:"code" example:"200"` + Msg string `json:"msg" example:"success"` + Data interface{} `json:"data"` +} + +// 错误码定义 +const ( + CodeSuccess = 200 + CodeBadRequest = 400 + CodeUnauthorized = 401 + CodeForbidden = 403 + CodeNotFound = 404 + CodeGone = 410 + CodeInternalError = 500 + CodeTooManyRequests = 429 +) + +// NewResponse 创建响应 +func NewResponse(code int, msg string, data interface{}) Response { + return Response{ + Code: code, + Msg: msg, + Data: data, + } +} + +// SuccessResponse 成功响应 +func SuccessResponse(data interface{}) Response { + return Response{ + Code: CodeSuccess, + Msg: "success", + Data: data, + } +} + +// ErrorResponse 错误响应 +func ErrorResponse(code int, msg string) Response { + return Response{ + Code: code, + Msg: msg, + } +} diff --git a/internal/service/batch_service.go b/internal/service/batch_service.go new file mode 100644 index 0000000..e3c0245 --- /dev/null +++ b/internal/service/batch_service.go @@ -0,0 +1,276 @@ +package service + +import ( + "FileRelay/internal/bootstrap" + "FileRelay/internal/model" + "FileRelay/internal/storage" + "context" + "errors" + "log/slog" + "math/big" + "strings" + "time" + + "crypto/rand" + + "gorm.io/gorm" +) + +type BatchService struct { + db *gorm.DB +} + +func NewBatchService() *BatchService { + return &BatchService{db: bootstrap.DB} +} + +func (s *BatchService) GetBatchByPickupCode(code string) (*model.FileBatch, error) { + var batch model.FileBatch + err := s.db.Preload("FileItems").Where("pickup_code = ? AND status = ?", code, "active").First(&batch).Error + if err != nil { + return nil, err + } + + // 检查是否过期 + if s.IsExpired(&batch) { + s.MarkAsExpired(&batch) + return nil, errors.New("batch expired") + } + + return &batch, nil +} + +func (s *BatchService) GetDownloadCountByPickupCode(code string) (int, int, error) { + var batch model.FileBatch + // 查询活跃或已过期的批次 + err := s.db.Where("pickup_code = ? AND (status = ? OR status = ?)", code, "active", "expired").First(&batch).Error + if err != nil { + return 0, 0, err + } + return batch.DownloadCount, batch.MaxDownloads, nil +} + +func (s *BatchService) IsExpired(batch *model.FileBatch) bool { + if batch.Status != "active" { + return true + } + + switch batch.ExpireType { + case "time": + if batch.ExpireAt != nil && time.Now().After(*batch.ExpireAt) { + return true + } + case "download": + if batch.MaxDownloads > 0 && batch.DownloadCount >= batch.MaxDownloads { + return true + } + } + return false +} + +func (s *BatchService) MarkAsExpired(batch *model.FileBatch) error { + slog.Info("Marking batch as expired", "batch_id", batch.ID, "pickup_code", batch.PickupCode) + return s.db.Model(batch).Update("status", "expired").Error +} + +func (s *BatchService) DeleteBatch(ctx context.Context, batchID string) error { + var batch model.FileBatch + // 使用 Unscoped 以确保即使是已软删除的批次也能找到并清理其物理文件 + if err := s.db.Unscoped().Preload("FileItems").First(&batch, "id = ?", batchID).Error; err != nil { + return err + } + + slog.Info("Deleting batch", "batch_id", batch.ID, "files_count", len(batch.FileItems)) + + // 删除物理文件 + for _, item := range batch.FileItems { + if err := storage.GlobalStorage.Delete(ctx, item.StoragePath); err != nil { + slog.Error("Failed to delete physical file", "path", item.StoragePath, "error", err) + } + } + + // 删除数据库记录 (彻底删除,不再保留元数据以便清理任务不再扫描到它) + return s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("batch_id = ?", batch.ID).Delete(&model.FileItem{}).Error; err != nil { + return err + } + if err := tx.Unscoped().Delete(&batch).Error; err != nil { + return err + } + return nil + }) +} + +func (s *BatchService) IncrementDownloadCount(batchID string) error { + if batchID == "" { + return errors.New("batch id is empty") + } + result := s.db.Model(&model.FileBatch{}).Where("id = ?", batchID). + UpdateColumn("download_count", gorm.Expr("download_count + ?", 1)) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return errors.New("batch not found or already deleted") + } + return nil +} + +func (s *BatchService) GeneratePickupCode(length int) (string, error) { + const charset = "0123456789" + b := make([]byte, length) + for i := range b { + num, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) + if err != nil { + return "", err + } + b[i] = charset[num.Int64()] + } + // 检查是否冲突 (排除已删除的,但包括活跃的和已过期的) + var count int64 + s.db.Model(&model.FileBatch{}).Where("pickup_code = ?", string(b)).Count(&count) + if count > 0 { + return s.GeneratePickupCode(length) // 递归生成 + } + return string(b), nil +} + +func (s *BatchService) UpdateAllPickupCodes(newLength int) error { + var batches []model.FileBatch + // 只更新未删除的记录,包括 active 和 expired + if err := s.db.Find(&batches).Error; err != nil { + return err + } + + return s.db.Transaction(func(tx *gorm.DB) error { + for _, batch := range batches { + oldCode := batch.PickupCode + if len(oldCode) == newLength { + continue + } + + var newCode string + if len(oldCode) < newLength { + // 右侧补零,方便用户输入原码后通过补 0 完成输入 + newCode = oldCode + strings.Repeat("0", newLength-len(oldCode)) + } else { + // 截取前 newLength 位,保留原码头部 + newCode = oldCode[:newLength] + } + + // 检查是否冲突 (在事务中检查) + var count int64 + tx.Model(&model.FileBatch{}).Where("pickup_code = ? AND id != ?", newCode, batch.ID).Count(&count) + if count > 0 { + // 如果冲突,生成一个新的随机码 + var err error + newCode, err = s.generateUniquePickupCodeInTx(tx, newLength) + if err != nil { + return err + } + } + + if err := tx.Model(&batch).Update("pickup_code", newCode).Error; err != nil { + return err + } + } + return nil + }) +} + +func (s *BatchService) Cleanup(ctx context.Context) error { + slog.Debug("Starting cleanup scan") + // 1. 寻找并标记过期的 Active Batches + var batches []model.FileBatch + now := time.Now() + // 同时检查时间过期 and 下载次数过期 + err := s.db.Where("status = ? AND ("+ + "(expire_type = 'time' AND expire_at < ?) OR "+ + "(expire_type = 'download' AND max_downloads > 0 AND download_count >= max_downloads)"+ + ")", "active", now).Find(&batches).Error + if err != nil { + slog.Error("Failed to query active batches for cleanup", "error", err) + return err + } + + if len(batches) > 0 { + slog.Info("Found expired batches to mark", "count", len(batches)) + } + + for _, batch := range batches { + _ = s.MarkAsExpired(&batch) + } + + // 2. 检查异常文件 (物理文件缺失) + var activeBatches []model.FileBatch + if err := s.db.Preload("FileItems").Where("status = ?", "active").Find(&activeBatches).Error; err == nil { + for _, batch := range activeBatches { + var missingItems []model.FileItem + for _, item := range batch.FileItems { + exists, err := storage.GlobalStorage.Exists(ctx, item.StoragePath) + if err != nil { + slog.Error("Failed to check file existence", "path", item.StoragePath, "error", err) + continue + } + if !exists { + missingItems = append(missingItems, item) + } + } + + if len(missingItems) > 0 { + slog.Info("Removing missing files from batch", "batch_id", batch.ID, "count", len(missingItems)) + for _, item := range missingItems { + if err := s.db.Delete(&item).Error; err != nil { + slog.Error("Failed to remove missing file record", "item_id", item.ID, "error", err) + } + } + + if len(missingItems) == len(batch.FileItems) { + slog.Warn("All files missing for batch, marking as expired", "batch_id", batch.ID) + _ = s.MarkAsExpired(&batch) + } + } + } + } + + // 3. 彻底清理标记为 expired 或 deleted 的批次 + var toDelete []model.FileBatch + // Unscoped 用于包含已软删除但尚未物理清理的记录 + err = s.db.Unscoped().Where("status IN ? OR deleted_at IS NOT NULL", []string{"expired", "deleted"}).Find(&toDelete).Error + if err != nil { + slog.Error("Failed to query batches for physical deletion", "error", err) + return err + } + + if len(toDelete) > 0 { + slog.Info("Found batches for physical deletion", "count", len(toDelete)) + } + + for _, batch := range toDelete { + if err := s.DeleteBatch(ctx, batch.ID); err != nil { + slog.Error("Failed to physically delete batch", "batch_id", batch.ID, "error", err) + } + } + + return nil +} + +func (s *BatchService) generateUniquePickupCodeInTx(tx *gorm.DB, length int) (string, error) { + const charset = "0123456789" + for { + b := make([]byte, length) + for i := range b { + num, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) + if err != nil { + return "", err + } + b[i] = charset[num.Int64()] + } + + var count int64 + tx.Model(&model.FileBatch{}).Where("pickup_code = ?", string(b)).Count(&count) + if count == 0 { + return string(b), nil + } + } +} diff --git a/internal/service/token_service.go b/internal/service/token_service.go new file mode 100644 index 0000000..4258265 --- /dev/null +++ b/internal/service/token_service.go @@ -0,0 +1,83 @@ +package service + +import ( + "FileRelay/internal/bootstrap" + "FileRelay/internal/model" + "crypto/sha256" + "encoding/hex" + "errors" + "strings" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type TokenService struct { + db *gorm.DB +} + +func NewTokenService() *TokenService { + return &TokenService{db: bootstrap.DB} +} + +func (s *TokenService) CreateToken(name string, scope string, expireAt *time.Time) (string, *model.APIToken, error) { + rawToken := uuid.New().String() + hash := s.hashToken(rawToken) + + token := &model.APIToken{ + Name: name, + TokenHash: hash, + Scope: scope, + ExpireAt: expireAt, + } + + if err := s.db.Create(token).Error; err != nil { + return "", nil, err + } + + return rawToken, token, nil +} + +func (s *TokenService) ValidateToken(rawToken string, requiredScope string) (*model.APIToken, error) { + hash := s.hashToken(rawToken) + var token model.APIToken + if err := s.db.Where("token_hash = ? AND revoked = ?", hash, false).First(&token).Error; err != nil { + return nil, errors.New("invalid token") + } + + if token.ExpireAt != nil && time.Now().After(*token.ExpireAt) { + return nil, errors.New("token expired") + } + + // 检查 Scope (简单包含判断) + // 在实际应用中可以实现更复杂的逻辑 + if requiredScope != "" && !s.checkScope(token.Scope, requiredScope) { + return nil, errors.New("insufficient scope") + } + + // 更新最后使用时间 + now := time.Now() + s.db.Model(&token).Update("last_used_at", &now) + + return &token, nil +} + +func (s *TokenService) hashToken(token string) string { + h := sha256.New() + h.Write([]byte(token)) + return hex.EncodeToString(h.Sum(nil)) +} + +func (s *TokenService) checkScope(tokenScope, requiredScope string) bool { + if requiredScope == "" { + return true + } + scopes := strings.Split(tokenScope, ",") + for _, s := range scopes { + if strings.TrimSpace(s) == requiredScope { + return true + } + } + return false +} diff --git a/internal/service/upload_service.go b/internal/service/upload_service.go new file mode 100644 index 0000000..4a50b21 --- /dev/null +++ b/internal/service/upload_service.go @@ -0,0 +1,149 @@ +package service + +import ( + "FileRelay/internal/bootstrap" + "FileRelay/internal/config" + "FileRelay/internal/model" + "FileRelay/internal/storage" + "context" + "fmt" + "log/slog" + "mime/multipart" + "path/filepath" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type UploadService struct { + db *gorm.DB +} + +func NewUploadService() *UploadService { + return &UploadService{db: bootstrap.DB} +} + +func (s *UploadService) CreateBatch(ctx context.Context, files []*multipart.FileHeader, remark string, expireType string, expireValue interface{}) (*model.FileBatch, error) { + // 1. 生成取件码 + batchService := NewBatchService() + pickupCode, err := batchService.GeneratePickupCode(config.GlobalConfig.Security.PickupCodeLength) + if err != nil { + return nil, err + } + + // 2. 准备 Batch + batch := &model.FileBatch{ + ID: uuid.New().String(), + PickupCode: pickupCode, + Remark: remark, + ExpireType: expireType, + Status: "active", + Type: "file", + } + + s.applyExpire(batch, expireType, expireValue) + + // 3. 处理文件上传 + err = s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(batch).Error; err != nil { + return err + } + + for _, fileHeader := range files { + fileItem, err := s.processFile(ctx, tx, batch.ID, fileHeader) + if err != nil { + return err + } + batch.FileItems = append(batch.FileItems, *fileItem) + } + return nil + }) + + if err == nil { + slog.Info("File batch created", "batch_id", batch.ID, "pickup_code", batch.PickupCode, "files_count", len(files)) + } else { + slog.Error("Failed to create file batch", "error", err) + } + + return batch, err +} + +func (s *UploadService) CreateTextBatch(ctx context.Context, content string, remark string, expireType string, expireValue interface{}) (*model.FileBatch, error) { + // 1. 生成取件码 + batchService := NewBatchService() + pickupCode, err := batchService.GeneratePickupCode(config.GlobalConfig.Security.PickupCodeLength) + if err != nil { + return nil, err + } + + // 2. 准备 Batch + batch := &model.FileBatch{ + ID: uuid.New().String(), + PickupCode: pickupCode, + Remark: remark, + ExpireType: expireType, + Status: "active", + Type: "text", + Content: content, + } + + s.applyExpire(batch, expireType, expireValue) + + // 3. 保存 + if err := s.db.Create(batch).Error; err != nil { + slog.Error("Failed to create text batch", "error", err) + return nil, err + } + + slog.Info("Text batch created", "batch_id", batch.ID, "pickup_code", batch.PickupCode) + return batch, nil +} + +func (s *UploadService) applyExpire(batch *model.FileBatch, expireType string, expireValue interface{}) { + switch expireType { + case "time": + if days, ok := expireValue.(int); ok { + expireAt := time.Now().Add(time.Duration(days) * 24 * time.Hour) + batch.ExpireAt = &expireAt + } + case "download": + if max, ok := expireValue.(int); ok { + batch.MaxDownloads = max + } + } +} + +func (s *UploadService) processFile(ctx context.Context, tx *gorm.DB, batchID string, fileHeader *multipart.FileHeader) (*model.FileItem, error) { + file, err := fileHeader.Open() + if err != nil { + return nil, err + } + defer file.Close() + + // 生成唯一存储路径 + ext := filepath.Ext(fileHeader.Filename) + fileID := uuid.New().String() + storagePath := fmt.Sprintf("%s/%s%s", batchID, fileID, ext) + + // 保存到存储层 + if err := storage.GlobalStorage.Save(ctx, storagePath, file); err != nil { + return nil, err + } + + // 创建数据库记录 + item := &model.FileItem{ + ID: fileID, + BatchID: batchID, + OriginalName: fileHeader.Filename, + StoragePath: storagePath, + Size: fileHeader.Size, + MimeType: fileHeader.Header.Get("Content-Type"), + } + + if err := tx.Create(item).Error; err != nil { + return nil, err + } + + return item, nil +} diff --git a/internal/storage/local.go b/internal/storage/local.go new file mode 100644 index 0000000..a64d71b --- /dev/null +++ b/internal/storage/local.go @@ -0,0 +1,59 @@ +package storage + +import ( + "context" + "io" + "os" + "path/filepath" +) + +type LocalStorage struct { + RootPath string +} + +func NewLocalStorage(rootPath string) *LocalStorage { + // 确保根目录存在 + if _, err := os.Stat(rootPath); os.IsNotExist(err) { + os.MkdirAll(rootPath, 0755) + } + return &LocalStorage{RootPath: rootPath} +} + +func (s *LocalStorage) Save(ctx context.Context, path string, reader io.Reader) error { + fullPath := filepath.Join(s.RootPath, path) + dir := filepath.Dir(fullPath) + if _, err := os.Stat(dir); os.IsNotExist(err) { + os.MkdirAll(dir, 0755) + } + + file, err := os.Create(fullPath) + if err != nil { + return err + } + defer file.Close() + + _, err = io.Copy(file, reader) + return err +} + +func (s *LocalStorage) Open(ctx context.Context, path string) (io.ReadCloser, error) { + fullPath := filepath.Join(s.RootPath, path) + return os.Open(fullPath) +} + +func (s *LocalStorage) Delete(ctx context.Context, path string) error { + fullPath := filepath.Join(s.RootPath, path) + return os.Remove(fullPath) +} + +func (s *LocalStorage) Exists(ctx context.Context, path string) (bool, error) { + fullPath := filepath.Join(s.RootPath, path) + _, err := os.Stat(fullPath) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, err +} diff --git a/internal/storage/s3.go b/internal/storage/s3.go new file mode 100644 index 0000000..80b29c9 --- /dev/null +++ b/internal/storage/s3.go @@ -0,0 +1,88 @@ +package storage + +import ( + "context" + "io" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" +) + +type S3Storage struct { + client *s3.Client + bucket string +} + +func NewS3Storage(ctx context.Context, endpoint, region, accessKey, secretKey, bucket string, useSSL bool) (*S3Storage, error) { + customResolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) { + if endpoint != "" { + return aws.Endpoint{ + URL: endpoint, + SigningRegion: region, + HostnameImmutable: true, + }, nil + } + return aws.Endpoint{}, &aws.EndpointNotFoundError{} + }) + + cfg, err := config.LoadDefaultConfig(ctx, + config.WithRegion(region), + config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")), + config.WithEndpointResolverWithOptions(customResolver), + ) + if err != nil { + return nil, err + } + + client := s3.NewFromConfig(cfg) + return &S3Storage{ + client: client, + bucket: bucket, + }, nil +} + +func (s *S3Storage) Save(ctx context.Context, path string, reader io.Reader) error { + _, err := s.client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(path), + Body: reader, + }) + return err +} + +func (s *S3Storage) Open(ctx context.Context, path string) (io.ReadCloser, error) { + output, err := s.client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(path), + }) + if err != nil { + return nil, err + } + return output.Body, nil +} + +func (s *S3Storage) Delete(ctx context.Context, path string) error { + _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(path), + }) + return err +} + +func (s *S3Storage) Exists(ctx context.Context, path string) (bool, error) { + _, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(path), + }) + if err != nil { + // In AWS SDK v2, we check if the error is 404 + if strings.Contains(err.Error(), "NotFound") || strings.Contains(err.Error(), "404") { + return false, nil + } + return false, err + } + return true, nil +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go new file mode 100644 index 0000000..1315335 --- /dev/null +++ b/internal/storage/storage.go @@ -0,0 +1,15 @@ +package storage + +import ( + "context" + "io" +) + +type Storage interface { + Save(ctx context.Context, path string, reader io.Reader) error + Open(ctx context.Context, path string) (io.ReadCloser, error) + Delete(ctx context.Context, path string) error + Exists(ctx context.Context, path string) (bool, error) +} + +var GlobalStorage Storage diff --git a/internal/storage/webdav.go b/internal/storage/webdav.go new file mode 100644 index 0000000..fa644df --- /dev/null +++ b/internal/storage/webdav.go @@ -0,0 +1,69 @@ +package storage + +import ( + "context" + "io" + "path/filepath" + "strings" + + "github.com/studio-b12/gowebdav" +) + +type WebDAVStorage struct { + client *gowebdav.Client + root string +} + +func NewWebDAVStorage(url, username, password, root string) *WebDAVStorage { + client := gowebdav.NewClient(url, username, password) + return &WebDAVStorage{ + client: client, + root: root, + } +} + +func (s *WebDAVStorage) getFullPath(path string) string { + return filepath.ToSlash(filepath.Join(s.root, path)) +} + +func (s *WebDAVStorage) Save(ctx context.Context, path string, reader io.Reader) error { + fullPath := s.getFullPath(path) + + // Ensure directory exists + dir := filepath.Dir(fullPath) + if dir != "." && dir != "/" { + parts := strings.Split(strings.Trim(dir, "/"), "/") + current := "" + for _, part := range parts { + current += "/" + part + _ = s.client.Mkdir(current, 0755) + } + } + + return s.client.WriteStream(fullPath, reader, 0644) +} + +func (s *WebDAVStorage) Open(ctx context.Context, path string) (io.ReadCloser, error) { + fullPath := s.getFullPath(path) + return s.client.ReadStream(fullPath) +} + +func (s *WebDAVStorage) Delete(ctx context.Context, path string) error { + fullPath := s.getFullPath(path) + return s.client.Remove(fullPath) +} + +func (s *WebDAVStorage) Exists(ctx context.Context, path string) (bool, error) { + fullPath := s.getFullPath(path) + _, err := s.client.Stat(fullPath) + if err != nil { + // gowebdav's Stat returns error if not found + // We could check for 404 but gowebdav doesn't export error types easily + // Usually we check if it's a 404 + if strings.Contains(err.Error(), "404") || strings.Contains(err.Error(), "not found") { + return false, nil + } + return false, err + } + return true, nil +} diff --git a/internal/task/cleaner.go b/internal/task/cleaner.go new file mode 100644 index 0000000..e6bff7c --- /dev/null +++ b/internal/task/cleaner.go @@ -0,0 +1,39 @@ +package task + +import ( + "FileRelay/internal/service" + "context" + "log/slog" + "time" +) + +type Cleaner struct { + batchService *service.BatchService +} + +func NewCleaner() *Cleaner { + return &Cleaner{ + batchService: service.NewBatchService(), + } +} + +func (c *Cleaner) Start(ctx context.Context) { + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + c.Clean() + } + } +} + +func (c *Cleaner) Clean() { + slog.Info("Running cleanup task") + if err := c.batchService.Cleanup(context.Background()); err != nil { + slog.Error("Error during cleanup", "error", err) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..1ae200b --- /dev/null +++ b/main.go @@ -0,0 +1,321 @@ +package main + +import ( + _ "FileRelay/docs" + "FileRelay/internal/api/admin" + "FileRelay/internal/api/middleware" + "FileRelay/internal/api/public" + "FileRelay/internal/bootstrap" + "FileRelay/internal/config" + "FileRelay/internal/model" + "FileRelay/internal/task" + "context" + "embed" + "flag" + "fmt" + "io" + "io/fs" + "log" + "log/slog" + "mime" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" + swaggerFiles "github.com/swaggo/files" + ginSwagger "github.com/swaggo/gin-swagger" +) + +//go:embed all:web +var webFS embed.FS + +// @title 文件暂存柜 API +// @version 1.0 +// @description 自托管的文件暂存柜后端系统 API 文档 +// @termsOfService http://swagger.io/terms/ + +// @contact.name API Support +// @contact.url http://www.swagger.io/support +// @contact.email support@swagger.io + +// @license.name Apache 2.0 +// @license.url http://www.apache.org/licenses/LICENSE-2.0.html + +// @BasePath / + +// @securityDefinitions.apikey AdminAuth +// @in header +// @name Authorization +// @description Type "Bearer " or "Bearer " to authenticate. API Token must have 'admin' scope. + +// @securityDefinitions.apikey APITokenAuth +// @in header +// @name Authorization +// @description Type "Bearer " to authenticate. Required scope depends on the endpoint. + +func main() { + // 注册常用 MIME 类型 + mime.AddExtensionType(".js", "application/javascript") + mime.AddExtensionType(".css", "text/css") + mime.AddExtensionType(".woff", "font/woff") + mime.AddExtensionType(".woff2", "font/woff2") + mime.AddExtensionType(".svg", "image/svg+xml") + + // 解析命令行参数 + configPath := flag.String("config", "config/config.yaml", "path to config file") + flag.Parse() + + // 1. 加载配置 + if err := config.LoadConfig(*configPath); err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + // 2. 初始化日志 + bootstrap.InitLog() + + port := config.GlobalConfig.Site.Port + if port <= 0 { + port = 8080 + } + + printBanner(*configPath, port) + + // 2. 初始化 + bootstrap.InitDB() + + // 3. 启动清理任务 + cleaner := task.NewCleaner() + go cleaner.Start(context.Background()) + + // 4. 设置路由 + r := gin.New() + r.Use(gin.Recovery()) + + // 自定义 Gin 日志中间件,使用 slog + r.Use(func(c *gin.Context) { + start := time.Now() + path := c.Request.URL.Path + query := c.Request.URL.RawQuery + + c.Next() + + end := time.Now() + latency := end.Sub(start) + + if len(c.Errors) > 0 { + for _, e := range c.Errors.Errors() { + slog.Error("Request error", "error", e, "path", path) + } + } else { + attributes := []any{ + "status", c.Writer.Status(), + "method", c.Request.Method, + "path", path, + "ip", c.ClientIP(), + "latency", latency, + } + if query != "" { + attributes = append(attributes, "query", query) + } + slog.Info("Request", attributes...) + } + }) + + // 配置更完善的 CORS + corsConfig := cors.DefaultConfig() + corsConfig.AllowAllOrigins = true + corsConfig.AllowHeaders = append(corsConfig.AllowHeaders, "Authorization", "Accept", "X-Requested-With") + corsConfig.AllowMethods = append(corsConfig.AllowMethods, "OPTIONS") + r.Use(cors.New(corsConfig)) + + // Swagger 文档 + r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) + + // 公共接口 + uploadHandler := public.NewUploadHandler() + pickupHandler := public.NewPickupHandler() + publicConfigHandler := public.NewConfigHandler() + + api := r.Group("/api") + { + api.GET("/config", publicConfigHandler.GetPublicConfig) + // 统一使用 /batches 作为资源路径 + api.POST("/batches", middleware.UploadAuth(), uploadHandler.Upload) + api.POST("/batches/text", middleware.UploadAuth(), uploadHandler.UploadText) + api.GET("/batches/:pickup_code", middleware.PickupRateLimit(), middleware.APITokenAuth(model.ScopePickup, true), pickupHandler.Pickup) + api.GET("/batches/:pickup_code/count", middleware.PickupRateLimit(), pickupHandler.GetDownloadCount) + api.GET("/batches/:pickup_code/download", middleware.APITokenAuth(model.ScopePickup, true), pickupHandler.DownloadBatch) + // 文件下载路由,支持直观的文件名结尾 + api.GET("/files/:file_id/:filename", middleware.APITokenAuth(model.ScopePickup, true), pickupHandler.DownloadFile) + // 保持旧路由兼容性 + api.GET("/files/:file_id/download", middleware.APITokenAuth(model.ScopePickup, true), pickupHandler.DownloadFile) + + // 保持旧路由兼容性 (可选,但为了平滑过渡通常建议保留一段时间或直接更新) + // 这里根据需求“调整不符合规范的”,我将直接采用新路由 + } + + // 管理员接口 + authHandler := admin.NewAuthHandler() + batchHandler := admin.NewBatchHandler() + tokenHandler := admin.NewTokenHandler() + configHandler := admin.NewConfigHandler() + + api.POST("/admin/login", authHandler.Login) + + adm := api.Group("/admin") + adm.Use(middleware.AdminAuth()) + { + adm.GET("/config", configHandler.GetConfig) + adm.PUT("/config", configHandler.UpdateConfig) + + adm.GET("/batches", batchHandler.ListBatches) + adm.POST("/batches/clean", batchHandler.CleanBatches) + adm.GET("/batches/:batch_id", batchHandler.GetBatch) + adm.PUT("/batches/:batch_id", batchHandler.UpdateBatch) + adm.DELETE("/batches/:batch_id", batchHandler.DeleteBatch) + + adm.GET("/api-tokens", tokenHandler.ListTokens) + adm.POST("/api-tokens", tokenHandler.CreateToken) + adm.DELETE("/api-tokens/:id", tokenHandler.DeleteToken) + adm.POST("/api-tokens/:id/revoke", tokenHandler.RevokeToken) + adm.POST("/api-tokens/:id/recover", tokenHandler.RecoverToken) + } + + // 静态资源服务 (放在最后,确保 API 路由优先) + webSub, _ := fs.Sub(webFS, "web") + + r.NoRoute(func(c *gin.Context) { + path := c.Request.URL.Path + + // 如果请求的是 API 或 Swagger,则不处理静态资源 (让其返回 404) + // 注意:此处不排除 /admin,因为 /admin 通常是前端 SPA 的路由地址 + if strings.HasPrefix(path, "/api") || strings.HasPrefix(path, "/swagger") { + return + } + + // 辅助函数:尝试从外部或嵌入服务文件 + serveFile := func(relPath string, allowExternal bool) bool { + // 1. 优先尝试外部路径 + if allowExternal && config.GlobalConfig.Web.Path != "" { + fullPath := filepath.Join(config.GlobalConfig.Web.Path, relPath) + if info, err := os.Stat(fullPath); err == nil && !info.IsDir() { + c.File(fullPath) + return true + } + } + + // 2. 尝试嵌入式文件 + f, err := webSub.Open(relPath) + if err == nil { + defer f.Close() + stat, err := f.Stat() + if err == nil && !stat.IsDir() { + // 使用 http.ServeContent 避免 c.FileFromFS 重定向问题 + if rs, ok := f.(io.ReadSeeker); ok { + // 显式设置 Content-Type,防止某些环境下识别失败 + ext := filepath.Ext(relPath) + ctype := mime.TypeByExtension(ext) + if ctype != "" { + c.Header("Content-Type", ctype) + } + http.ServeContent(c.Writer, c.Request, stat.Name(), stat.ModTime(), rs) + return true + } + } + } + return false + } + + // 1. 尝试直接请求的文件 (如果是 / 则尝试 index.html) + requestedPath := strings.TrimPrefix(path, "/") + if requestedPath == "" { + requestedPath = "index.html" + } + + if serveFile(requestedPath, true) { + return + } + + // 2. SPA 支持:对于非文件请求(没有后缀或不包含点),尝试返回 index.html + // 如果请求的是 assets 目录下的文件(包含点)却没找到,不应该回退到 index.html + isAsset := strings.Contains(requestedPath, ".") + if !isAsset || requestedPath == "index.html" { + if serveFile("index.html", true) { + return + } + } + + // 最终找不到则返回 404 + c.Status(http.StatusNotFound) + }) + + // 5. 运行 + localIP := getLocalIP() + fmt.Println("FileRelay service is ready:") + fmt.Printf(" - Local: http://localhost:%d\n", port) + fmt.Printf(" - LAN: http://%s:%d\n", localIP, port) + fmt.Printf(" - API Doc: http://localhost:%d/swagger/index.html\n", port) + fmt.Println() + + slog.Info("FileRelay service is ready", + "local_url", fmt.Sprintf("http://localhost:%d", port), + "lan_url", fmt.Sprintf("http://%s:%d", localIP, port), + "docs_url", fmt.Sprintf("http://localhost:%d/swagger/index.html", port), + ) + + r.Run(fmt.Sprintf(":%d", port)) +} + +func getLocalIP() string { + addrs, err := net.InterfaceAddrs() + if err != nil { + return "127.0.0.1" + } + for _, address := range addrs { + if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { + if ipnet.IP.To4() != nil { + return ipnet.IP.String() + } + } + } + return "127.0.0.1" +} + +func printBanner(configPath string, port int) { + fmt.Println(` + _____ _ _ _____ _ + | ___(_) | ___| _ \ ___| | __ _ _ _ + | |_ | | |/ _ \ |_) / _ \ |/ _' | | | | + | _| | | | __/ _ < __/ | (_| | |_| | + |_| |_|_|\___|_| \_\___|_|\__,_|\__, | + |___/ + `) + fmt.Printf(" Config: %s\n", configPath) + fmt.Printf(" Port: %d\n", port) + fmt.Printf(" Database: %s\n", config.GlobalConfig.Database.Type) + fmt.Printf(" Storage: %s\n", config.GlobalConfig.Storage.Type) + + webPath := config.GlobalConfig.Web.Path + if webPath != "" { + if info, err := os.Stat(webPath); err == nil && info.IsDir() { + fmt.Printf(" Web Assets: External (%s)\n", webPath) + } else { + fmt.Printf(" Web Assets: Embedded (External path %s not found)\n", webPath) + } + } else { + fmt.Println(" Web Assets: Embedded") + } + fmt.Println() + + slog.Info("Starting FileRelay service", + "config", configPath, + "port", port, + "db_type", config.GlobalConfig.Database.Type, + "storage_type", config.GlobalConfig.Storage.Type, + ) +} diff --git a/scripts/build.bat b/scripts/build.bat new file mode 100644 index 0000000..cadcce8 --- /dev/null +++ b/scripts/build.bat @@ -0,0 +1,67 @@ +@echo off +chcp 65001 > nul +setlocal enabledelayedexpansion + +:: лĿĿ¼ +cd /d "%~dp0.." + +set APP_NAME=filerelay +set OUTPUT_DIR=output + +echo ʼ %APP_NAME% ƽ̨ļ... + +:: output Ŀ¼ +if exist "%OUTPUT_DIR%" ( + echo %OUTPUT_DIR% Ŀ¼... + rd /s /q "%OUTPUT_DIR%" +) + +mkdir "%OUTPUT_DIR%" + +:: ǰ˹ +echo ڹǰĿ... +pushd webapp +call npm install +if %ERRORLEVEL% neq 0 ( + echo npm install ʧܣֹͣ롣 + popd + exit /b %ERRORLEVEL% +) +call npm run build +if %ERRORLEVEL% neq 0 ( + echo ǰ˹ʧܣֹͣ롣 + popd + exit /b %ERRORLEVEL% +) +popd + +:: Ŀƽ̨ (OS/Arch) +set PLATFORMS=linux/amd64 linux/arm64 windows/amd64 windows/arm64 darwin/amd64 darwin/arm64 + +for %%P in (%PLATFORMS%) do ( + for /f "tokens=1,2 delims=/" %%A in ("%%P") do ( + set GOOS=%%A + set GOARCH=%%B + + set OUTPUT_NAME=%APP_NAME%-%%A-%%B + if "%%A"=="windows" set OUTPUT_NAME=!OUTPUT_NAME!.exe + + echo ڱ %%A/%%B... + + go build -o "%OUTPUT_DIR%\!OUTPUT_NAME!" main.go + + if !ERRORLEVEL! equ 0 ( + echo %%A/%%B ɹ + :: ѹΪ tar.gz (Windows 10+ Դ tar) + tar -czf "%OUTPUT_DIR%\!OUTPUT_NAME!.tar.gz" -C "%OUTPUT_DIR%" "!OUTPUT_NAME!" + :: ɾԭʼļ + del "%OUTPUT_DIR%\!OUTPUT_NAME!" + ) else ( + echo %%A/%%B ʧ + ) + ) +) + +echo ---------------------------------------- +echo ƽ̨ɣĿ¼: %OUTPUT_DIR% +pause diff --git a/scripts/build.ps1 b/scripts/build.ps1 new file mode 100644 index 0000000..5c33460 --- /dev/null +++ b/scripts/build.ps1 @@ -0,0 +1,78 @@ +# ű +# лĿĿ¼ +Set-Location -Path (Join-Path $PSScriptRoot "..") + +$APP_NAME = "filerelay" +$OUTPUT_DIR = "output" + +# Ŀƽ̨ +$PLATFORMS = @( + "linux/amd64", + "linux/arm64", + "windows/amd64", + "windows/arm64", + "darwin/amd64", + "darwin/arm64" +) + +Write-Host "ʼ $APP_NAME ƽ̨ļ..." -ForegroundColor Cyan + +# output Ŀ¼ +if (Test-Path $OUTPUT_DIR) { + Write-Host " $OUTPUT_DIR Ŀ¼..." + Remove-Item -Path $OUTPUT_DIR -Recurse -Force +} + +New-Item -Path $OUTPUT_DIR -ItemType Directory -Force | Out-Null + +# ǰ˹ +Write-Host "ڹǰĿ..." -ForegroundColor Cyan +Push-Location webapp +npm install +if ($LASTEXITCODE -ne 0) { + Write-Host "npm install ʧܣֹͣ롣" -ForegroundColor Red + Pop-Location + exit $LASTEXITCODE +} +npm run build +if ($LASTEXITCODE -ne 0) { + Write-Host "ǰ˹ʧܣֹͣ롣" -ForegroundColor Red + Pop-Location + exit $LASTEXITCODE +} +Pop-Location + +# ѭƽ̨ +foreach ($PLATFORM in $PLATFORMS) { + $parts = $PLATFORM -split "/" + $os = $parts[0] + $arch = $parts[1] + + $outputName = "$($APP_NAME)-$($os)-$($arch)" + if ($os -eq "windows") { + $outputName += ".exe" + } + + Write-Host "ڱ $($os)/$($arch)..." + $env:GOOS = $os + $env:GOARCH = $arch + + go build -o (Join-Path $OUTPUT_DIR $outputName) main.go + + if ($LASTEXITCODE -eq 0) { + Write-Host " $($os)/$($arch) ɹ" -ForegroundColor Green + # ѹΪ tar.gz + tar -czf (Join-Path $OUTPUT_DIR "$outputName.tar.gz") -C $OUTPUT_DIR $outputName + # ɾԭʼļ + Remove-Item (Join-Path $OUTPUT_DIR $outputName) + } else { + Write-Host " $($os)/$($arch) ʧ" -ForegroundColor Red + } +} + +# û +$env:GOOS = $null +$env:GOARCH = $null + +Write-Host "----------------------------------------" -ForegroundColor Cyan +Write-Host "ƽ̨ɣĿ¼: $OUTPUT_DIR" -ForegroundColor Green diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100644 index 0000000..1b7f37e --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,69 @@ +#!/bin/bash + +# 获取脚本所在目录并切换到项目根目录 +cd "$(dirname "$0")/.." + +# 设置变量 +APP_NAME="filerelay" +OUTPUT_DIR="output" + +# 定义目标平台 +PLATFORMS=( + "linux/amd64" + "linux/arm64" + "windows/amd64" + "windows/arm64" + "darwin/amd64" + "darwin/arm64" +) + +echo "开始构建 $APP_NAME 多平台二进制文件..." + +# 清理 output 目录 +if [ -d "$OUTPUT_DIR" ]; then + echo "正在清理 $OUTPUT_DIR 目录..." + rm -rf "$OUTPUT_DIR" +fi + +mkdir -p "$OUTPUT_DIR" + +# 前端构建 +echo "正在构建前端项目..." +cd webapp +npm install +npm run build +if [ $? -ne 0 ]; then + echo "前端构建失败,停止编译。" + exit 1 +fi +cd .. + +# 循环编译各平台 +for PLATFORM in "${PLATFORMS[@]}"; do + # 分离 OS 和 ARCH + OS=$(echo $PLATFORM | cut -d'/' -f1) + ARCH=$(echo $PLATFORM | cut -d'/' -f2) + + # 设置输出名称 + OUTPUT_NAME="${APP_NAME}-${OS}-${ARCH}" + if [ "$OS" = "windows" ]; then + OUTPUT_NAME="${OUTPUT_NAME}.exe" + fi + + echo "正在编译 ${OS}/${ARCH}..." + GOOS=$OS GOARCH=$ARCH go build -o "${OUTPUT_DIR}/${OUTPUT_NAME}" main.go + + if [ $? -eq 0 ]; then + echo " ${OS}/${ARCH} 编译成功" + # 压缩为 tar.gz + tar -czf "${OUTPUT_DIR}/${OUTPUT_NAME}.tar.gz" -C "${OUTPUT_DIR}" "${OUTPUT_NAME}" + # 删除原始二进制文件 + rm "${OUTPUT_DIR}/${OUTPUT_NAME}" + else + echo " ${OS}/${ARCH} 编译失败" + fi +done + +echo "----------------------------------------" +echo "多平台打包完成!输出目录: $OUTPUT_DIR" +ls -R "$OUTPUT_DIR" diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 0000000..f72e951 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,101 @@ +#!/bin/bash + +# 定时任务示例 (每30分钟执行一次): +# */30 * * * * /path/to/project/scripts/deploy.sh >> /path/to/project/scripts/deploy_cron.log 2>&1 + +# 项目根目录 +# 假设脚本位于 scripts 目录下 +PROJECT_DIR=$(cd $(dirname $0)/.. && pwd) +cd $PROJECT_DIR + +# 日志文件 +LOG_FILE="$PROJECT_DIR/scripts/deploy.log" + +log() { + echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" | tee -a "$LOG_FILE" +} + +# 确保在项目根目录 +if [ ! -f "docker-compose.yaml" ]; then + log "错误: 未能在 $PROJECT_DIR 找到 docker-compose.yml,请确保脚本位置正确。" + exit 1 +fi + +log "开始检查更新..." + +# 获取远程代码 +git fetch origin + +# 获取当前分支名 +BRANCH=$(git rev-parse --abbrev-ref HEAD) + +# 检查本地分支是否有上游分支 +UPSTREAM=$(git rev-parse --abbrev-ref @{u} 2>/dev/null) +if [ -z "$UPSTREAM" ]; then + log "错误: 当前分支 $BRANCH 没有设置上游分支,无法自动对比更新。" + exit 1 +fi + +# 检查本地是否落后于远程 +LOCAL=$(git rev-parse HEAD) +REMOTE=$(git rev-parse "$UPSTREAM") + +if [ "$LOCAL" = "$REMOTE" ]; then + log "代码已是最新 ($LOCAL),无需更新。" + exit 0 +fi + +log "检测到远程变更 ($LOCAL -> $REMOTE),准备开始升级..." + +# 检查是否有本地修改 +HAS_CHANGES=$(git status --porcelain) + +if [ -n "$HAS_CHANGES" ]; then + log "检测到本地修改,正在暂存以保留个性化配置..." + git stash + STASHED=true +else + STASHED=false +fi + +# 拉取新代码 +log "正在拉取远程代码 ($BRANCH)..." +if git pull origin "$BRANCH"; then + log "代码拉取成功。" +else + log "错误: 代码拉取失败。" + if [ "$STASHED" = true ]; then + git stash pop + fi + exit 1 +fi + +# 恢复本地修改 +if [ "$STASHED" = true ]; then + log "正在恢复本地修改..." + if git stash pop; then + log "本地修改已成功恢复。" + else + log "警告: 恢复本地修改时发生冲突,请手动检查 docker-compose.yml 等文件。" + # 即使冲突也尝试继续,或者你可以选择在此退出 + fi +fi + +# 确定 docker-compose 命令 +DOCKER_COMPOSE_BIN="docker-compose" +if ! command -v $DOCKER_COMPOSE_BIN &> /dev/null; then + DOCKER_COMPOSE_BIN="docker compose" +fi + +# 执行 docker-compose 部署 +log "正在执行 $DOCKER_COMPOSE_BIN 部署..." +if $DOCKER_COMPOSE_BIN up -d --build; then + log "服务升级成功!" + # 清理无用镜像(可选) + docker image prune -f +else + log "错误: $DOCKER_COMPOSE_BIN 部署失败。" + exit 1 +fi + +log "部署任务完成。" diff --git a/scripts/release_tag.bat b/scripts/release_tag.bat new file mode 100644 index 0000000..0ebe0a2 --- /dev/null +++ b/scripts/release_tag.bat @@ -0,0 +1,59 @@ +@echo off +chcp 65001 > nul +setlocal + +:: лĿĿ¼ +cd /d "%~dp0.." + +if "%~1"=="" ( + echo ʹ÷: release_tag.bat + echo : release_tag.bat v1.0.0 + exit /b 1 +) + +set TAG_NAME=%~1 + +REM 1. 鵱ǰ֧ǷΪ master +for /f "tokens=*" %%i in ('git rev-parse --abbrev-ref HEAD') do set CURRENT_BRANCH=%%i +if not "%CURRENT_BRANCH%"=="master" ( + echo 󣺵ǰ֧ %CURRENT_BRANCH%ֻ master ֧ϴ Tag + exit /b 1 +) + +REM 2. 鱾Ƿδύ +for /f "tokens=*" %%i in ('git status --porcelain') do ( + echo 󣺱δύĸģύ stash + exit /b 1 +) + +REM 3. 鱾Զ master ֧Ƿһ +echo ڴԶ̻ȡ master ֧Ϣ... +git fetch origin master +if %ERRORLEVEL% neq 0 ( + echo 󣺻ȡԶ̷֧Ϣʧܡ + exit /b 1 +) + +for /f "tokens=*" %%i in ('git rev-parse HEAD') do set LOCAL_HASH=%%i +for /f "tokens=*" %%i in ('git rev-parse origin/master') do set REMOTE_HASH=%%i + +if not "%LOCAL_HASH%"=="%REMOTE_HASH%" ( + echo 󣺱 master ֧Զ origin/master һ£ pull push + exit /b 1 +) + +echo ȫͨڴ Tag: %TAG_NAME% + +:: ڱش Tag +git tag -f %TAG_NAME% + +:: ǿ͵Զ +echo ǿ͵Զ... +git push origin %TAG_NAME% --force + +if %ERRORLEVEL% equ 0 ( + echo ɹTag %TAG_NAME% ѷͬԶ̡ +) else ( + echo ʧܣ͹гִ + exit /b 1 +) diff --git a/scripts/release_tag.ps1 b/scripts/release_tag.ps1 new file mode 100644 index 0000000..7f32123 --- /dev/null +++ b/scripts/release_tag.ps1 @@ -0,0 +1,53 @@ +param ( + [Parameter(Mandatory=$true, Position=0)] + [string]$TagName +) + +# лĿĿ¼ +Set-Location -Path (Join-Path $PSScriptRoot "..") + +# 1. 鵱ǰ֧ǷΪ master +$currentBranch = git rev-parse --abbrev-ref HEAD +if ($currentBranch -ne "master") { + Write-Host "󣺵ǰ֧ $currentBranchֻ master ֧ϴ Tag" -ForegroundColor Red + exit 1 +} + +# 2. 鱾Ƿδύ +$status = git status --porcelain +if ($null -ne $status -and $status.Length -gt 0) { + Write-Host "󣺱δύĸģύ stash" -ForegroundColor Red + exit 1 +} + +# 3. 鱾Զ master ֧Ƿһ +Write-Host "ڴԶ̻ȡ master ֧Ϣ..." -ForegroundColor Cyan +git fetch origin master +$localHash = git rev-parse HEAD +$remoteHash = git rev-parse origin/master + +if ($localHash -ne $remoteHash) { + Write-Host "󣺱 master ֧Զ origin/master һ£ pull push" -ForegroundColor Red + exit 1 +} + +Write-Host "ȫͨڴ Tag: $TagName" -ForegroundColor Cyan + +# ڱش Tag +git tag -f $TagName + +if ($LASTEXITCODE -ne 0) { + Write-Host "ش Tag ʧܡ" -ForegroundColor Red + exit 1 +} + +# ǿ͵Զ +Write-Host "ǿ͵Զ..." -ForegroundColor Cyan +git push origin $TagName --force + +if ($LASTEXITCODE -eq 0) { + Write-Host "ɹTag $TagName ѷͬԶ̡" -ForegroundColor Green +} else { + Write-Host "ʧܣ͹гִ" -ForegroundColor Red + exit 1 +} diff --git a/scripts/release_tag.sh b/scripts/release_tag.sh new file mode 100644 index 0000000..f8cbfdd --- /dev/null +++ b/scripts/release_tag.sh @@ -0,0 +1,55 @@ +#!/bin/bash + +# 获取脚本所在目录并切换到项目根目录 +cd "$(dirname "$0")/.." + +# 检查是否提供了 tag 名称 +if [ -z "$1" ]; then + echo "使用方法: scripts/release_tag.sh " + echo "例如: scripts/release_tag.sh v1.0.0" + exit 1 +fi + +TAG_NAME=$1 + +# 1. 检查当前分支是否为 master +CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) +if [ "$CURRENT_BRANCH" != "master" ]; then + echo "错误:当前分支是 $CURRENT_BRANCH,只能在 master 分支上创建 Tag。" + exit 1 +fi + +# 2. 检查本地是否有未提交的内容 +if [ -n "$(git status --porcelain)" ]; then + echo "错误:本地有未提交的更改,请先提交或 stash。" + exit 1 +fi + +# 3. 检查本地与远程 master 分支是否一致 +echo "正在从远程获取最新 master 分支信息..." +git fetch origin master +LOCAL_HASH=$(git rev-parse HEAD) +REMOTE_HASH=$(git rev-parse origin/master) + +if [ "$LOCAL_HASH" != "$REMOTE_HASH" ]; then + echo "错误:本地 master 分支与远程 origin/master 不一致,请先 pull 或 push。" + exit 1 +fi + +echo "安全检查通过,正在处理 Tag: $TAG_NAME" + +# 在本地创建或更新 Tag +# -f 表示如果存在则强制更新 +git tag -f "$TAG_NAME" + +# 强制推送到远程 +# --force 会覆盖远程同名 Tag +echo "正在强制推送到远程..." +git push origin "$TAG_NAME" --force + +if [ $? -eq 0 ]; then + echo "成功!Tag $TAG_NAME 已发布并同步至远程。" +else + echo "失败:推送过程中出现错误。" + exit 1 +fi diff --git a/scripts/sql/mysql_init.sql b/scripts/sql/mysql_init.sql new file mode 100644 index 0000000..57724ae --- /dev/null +++ b/scripts/sql/mysql_init.sql @@ -0,0 +1,39 @@ +-- MySQL 初始化语句 +CREATE TABLE IF NOT EXISTS `file_batches` ( + `id` varchar(36) PRIMARY KEY, + `pickup_code` varchar(255) UNIQUE NOT NULL, + `remark` longtext, + `expire_type` varchar(255), + `expire_at` datetime(3), + `max_downloads` bigint, + `download_count` bigint DEFAULT 0, + `status` varchar(255) DEFAULT 'active', + `type` varchar(255) DEFAULT 'file', + `content` longtext, + `created_at` datetime(3), + `updated_at` datetime(3), + `deleted_at` datetime(3), + KEY `idx_file_batches_deleted_at` (`deleted_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `file_items` ( + `id` varchar(36) PRIMARY KEY, + `batch_id` varchar(36) NOT NULL, + `original_name` longtext, + `storage_path` longtext, + `size` bigint, + `mime_type` longtext, + `created_at` datetime(3), + KEY `idx_file_items_batch_id` (`batch_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `api_tokens` ( + `id` bigint unsigned AUTO_INCREMENT PRIMARY KEY, + `name` longtext, + `token_hash` varchar(255) UNIQUE NOT NULL, + `scope` longtext, + `expire_at` datetime(3), + `last_used_at` datetime(3), + `revoked` boolean DEFAULT false, + `created_at` datetime(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/scripts/sql/postgres_init.sql b/scripts/sql/postgres_init.sql new file mode 100644 index 0000000..8a4a060 --- /dev/null +++ b/scripts/sql/postgres_init.sql @@ -0,0 +1,40 @@ +-- PostgreSQL 初始化语句 +CREATE TABLE IF NOT EXISTS "file_batches" ( + "id" varchar(36) PRIMARY KEY, + "pickup_code" varchar(255) UNIQUE NOT NULL, + "remark" text, + "expire_type" text, + "expire_at" timestamptz, + "max_downloads" bigint, + "download_count" bigint DEFAULT 0, + "status" text DEFAULT 'active', + "type" text DEFAULT 'file', + "content" text, + "created_at" timestamptz, + "updated_at" timestamptz, + "deleted_at" timestamptz +); +CREATE INDEX IF NOT EXISTS "idx_file_batches_deleted_at" ON "file_batches" ("deleted_at"); + +CREATE TABLE IF NOT EXISTS "file_items" ( + "id" varchar(36) PRIMARY KEY, + "batch_id" varchar(36) NOT NULL, + "original_name" text, + "storage_path" text, + "size" bigint, + "mime_type" text, + "created_at" timestamptz, + CONSTRAINT "fk_file_batches_file_items" FOREIGN KEY ("batch_id") REFERENCES "file_batches"("id") +); +CREATE INDEX IF NOT EXISTS "idx_file_items_batch_id" ON "file_items" ("batch_id"); + +CREATE TABLE IF NOT EXISTS "api_tokens" ( + "id" bigserial PRIMARY KEY, + "name" text, + "token_hash" varchar(255) UNIQUE NOT NULL, + "scope" text, + "expire_at" timestamptz, + "last_used_at" timestamptz, + "revoked" boolean DEFAULT false, + "created_at" timestamptz +); diff --git a/scripts/sql/sqlite_init.sql b/scripts/sql/sqlite_init.sql new file mode 100644 index 0000000..71c527b --- /dev/null +++ b/scripts/sql/sqlite_init.sql @@ -0,0 +1,40 @@ +-- SQLite 初始化语句 +CREATE TABLE IF NOT EXISTS `file_batches` ( + `id` varchar(36) PRIMARY KEY, + `pickup_code` varchar(255) UNIQUE NOT NULL, + `remark` text, + `expire_type` text, + `expire_at` datetime, + `max_downloads` integer, + `download_count` integer DEFAULT 0, + `status` text DEFAULT 'active', + `type` text DEFAULT 'file', + `content` text, + `created_at` datetime, + `updated_at` datetime, + `deleted_at` datetime +); +CREATE INDEX IF NOT EXISTS `idx_file_batches_deleted_at` ON `file_batches` (`deleted_at`); + +CREATE TABLE IF NOT EXISTS `file_items` ( + `id` varchar(36) PRIMARY KEY, + `batch_id` varchar(36) NOT NULL, + `original_name` text, + `storage_path` text, + `size` bigint, + `mime_type` text, + `created_at` datetime, + FOREIGN KEY (`batch_id`) REFERENCES `file_batches`(`id`) +); +CREATE INDEX IF NOT EXISTS `idx_file_items_batch_id` ON `file_items` (`batch_id`); + +CREATE TABLE IF NOT EXISTS `api_tokens` ( + `id` integer PRIMARY KEY AUTOINCREMENT, + `name` text, + `token_hash` varchar(255) UNIQUE NOT NULL, + `scope` text, + `expire_at` datetime, + `last_used_at` datetime, + `revoked` boolean DEFAULT 0, + `created_at` datetime +); diff --git a/webapp/.gitignore b/webapp/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/webapp/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/webapp/README.md b/webapp/README.md new file mode 100644 index 0000000..61f5185 --- /dev/null +++ b/webapp/README.md @@ -0,0 +1,119 @@ +# 文件中转站前端 + +一个基于 Vue 3 + TypeScript + Vite + Shadcn-Vue 构建的文件中转站前端应用。 + +## 项目简介 + +文件中转站是一个极简的文件暂存服务,用户可以像使用现实中的暂存柜一样,将文件、文本等内容临时存储在网站中,并获得一个取件码用于在其他设备上提取文件。 + +## 主要功能 + +### 用户功能 +- **文件上传**: 支持批量上传多种格式的文件 +- **文本分享**: 快速分享长文本内容 +- **取件码提取**: 使用取件码获取文件或文本 +- **过期策略**: 支持按时间或下载次数设置过期规则 +- **快捷操作**: 一键复制、打包下载等便捷功能 + +### 管理功能 +- **批次管理**: 查看、编辑、删除文件批次 +- **API Token**: 创建和管理 API 访问凭证 +- **数据统计**: 系统运行状态和使用统计 +- **权限控制**: 管理员密码保护的后台系统 + +## 技术栈 + +- **框架**: Vue 3 + TypeScript +- **构建工具**: Vite +- **UI 组件**: Shadcn-Vue (基于 Tailwind CSS) +- **路由**: Vue Router +- **HTTP 客户端**: Axios +- **状态管理**: 组合式 API +- **代码规范**: ESLint + Prettier + +## 快速开始 + +### 环境要求 + +- Node.js 18+ +- npm 或 yarn 或 pnpm + +### 安装依赖 + +```bash +npm install +``` + +### 开发环境 + +```bash +npm run dev +``` + +启动后访问 `http://localhost:5173` + +### 构建生产版本 + +```bash +npm run build +``` + +### 预览构建结果 + +```bash +npm run preview +``` + +## 环境配置 + +创建环境变量文件: + +### `.env` (通用配置) +```env +VITE_API_URL=http://localhost:8080 +VITE_APP_TITLE=文件中转站 +VITE_APP_DESCRIPTION=安全便捷的文件暂存服务 +``` + +### `.env.development` (开发环境) +```env +VITE_API_URL=http://localhost:8080 +``` + +### `.env.production` (生产环境) +```env +VITE_API_URL=/api +``` + +## 页面路由 + +### 用户页面 +- `/` - 首页 (取件/存件切换) +- `/upload` - 文件上传页面 +- `/pickup` - 取件页面 +- `/pickup/:code` - 直接取件 (URL 包含取件码) + +### 管理页面 (隐藏入口) +- `/admin/login` - 管理员登录 +- `/admin` - 管理概览 +- `/admin/batches` - 批次管理 +- `/admin/tokens` - API Token 管理 + +## 功能特点 + +### 用户体验 +- **极简设计**: 首页默认取件,一键切换存件模式 +- **智能识别**: 自动识别文件类型并显示对应图标 +- **快捷操作**: 支持剪贴板粘贴取件码、一键复制等 +- **进度反馈**: 上传进度显示和状态提示 +- **响应式设计**: 完美适配桌面和移动设备 + +### 管理功能 +- **数据统计**: 实时显示系统运行数据 +- **批次管理**: 支持搜索、筛选、分页查看 +- **权限控制**: Token 可设置权限范围和过期时间 +- **操作日志**: 详细的操作记录和状态跟踪 + +## 许可证 + +MIT License \ No newline at end of file diff --git a/webapp/api/swagger.json b/webapp/api/swagger.json new file mode 100644 index 0000000..bf837ef --- /dev/null +++ b/webapp/api/swagger.json @@ -0,0 +1,1713 @@ +{ + "swagger": "2.0", + "info": { + "description": "自托管的文件暂存柜后端系统 API 文档", + "title": "文件暂存柜 API", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "name": "API Support", + "url": "http://www.swagger.io/support", + "email": "support@swagger.io" + }, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, + "version": "1.0" + }, + "basePath": "/", + "paths": { + "/api/admin/api-tokens": { + "get": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "获取系统中所有 API Token 的详细信息(不包含哈希)", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "获取 API Token 列表", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/model.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.APIToken" + } + } + } + } + ] + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + }, + "post": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "创建一个新的 API Token,返回原始 Token(仅显示一次)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "创建 API Token", + "parameters": [ + { + "description": "Token 信息", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/admin.CreateTokenRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/model.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/admin.CreateTokenResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/admin/api-tokens/{id}": { + "delete": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "根据 ID 永久删除 API Token", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "删除 API Token", + "parameters": [ + { + "type": "integer", + "description": "Token ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/admin/api-tokens/{id}/recover": { + "post": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "将已撤销的 API Token 恢复为有效状态", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "恢复 API Token", + "parameters": [ + { + "type": "integer", + "description": "Token ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/admin/api-tokens/{id}/revoke": { + "post": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "将 API Token 标记为已撤销,使其失效但保留记录", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "撤销 API Token", + "parameters": [ + { + "type": "integer", + "description": "Token ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/admin/batches": { + "get": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "分页查询所有文件批次,支持按状态过滤和取件码模糊搜索", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "获取批次列表", + "parameters": [ + { + "type": "integer", + "description": "页码 (默认 1)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量 (默认 20)", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "状态 (active/expired/deleted)", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "取件码 (模糊搜索)", + "name": "pickup_code", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/model.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/admin.ListBatchesResponse" + } + } + } + ] + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/admin/batches/clean": { + "post": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "手动扫描并物理删除所有已过期或标记为删除的文件批次及其关联文件", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "手动触发清理", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/admin/batches/{batch_id}": { + "get": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "根据批次 ID 获取批次信息及关联的文件列表", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "获取批次详情", + "parameters": [ + { + "type": "string", + "description": "批次 ID (UUID)", + "name": "batch_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/model.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.FileBatch" + } + } + } + ] + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + }, + "put": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "允许修改备注、过期策略、最大下载次数、状态等", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "修改批次信息", + "parameters": [ + { + "type": "string", + "description": "批次 ID (UUID)", + "name": "batch_id", + "in": "path", + "required": true + }, + { + "description": "修改内容", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/admin.UpdateBatchRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/model.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.FileBatch" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + }, + "delete": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "标记批次为已删除,并物理删除关联的存储文件", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "删除批次", + "parameters": [ + { + "type": "string", + "description": "批次 ID (UUID)", + "name": "batch_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/admin/config": { + "get": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "获取系统的完整配置文件内容(仅管理员)", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "获取完整配置", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/model.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/config.Config" + } + } + } + ] + } + } + } + }, + "put": { + "security": [ + { + "AdminAuth": [] + } + ], + "description": "更新系统的配置文件内容(仅管理员)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "更新配置", + "parameters": [ + { + "description": "新配置内容", + "name": "config", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/config.Config" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/model.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/config.Config" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/model.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/admin/login": { + "post": { + "description": "通过密码换取 JWT Token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "管理员登录", + "parameters": [ + { + "description": "登录请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/admin.LoginRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/model.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/admin.LoginResponse" + } + } + } + ] + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/batches": { + "post": { + "security": [ + { + "APITokenAuth": [] + } + ], + "description": "上传一个或多个文件并创建一个提取批次。如果配置了 require_token,则必须提供带 upload scope 的 API Token。", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Public" + ], + "summary": "上传文件", + "parameters": [ + { + "type": "file", + "description": "文件列表", + "name": "files", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "备注", + "name": "remark", + "in": "formData" + }, + { + "type": "string", + "description": "过期类型 (time/download/permanent)", + "name": "expire_type", + "in": "formData" + }, + { + "type": "integer", + "description": "过期天数 (针对 time 类型)", + "name": "expire_days", + "in": "formData" + }, + { + "type": "integer", + "description": "最大下载次数 (针对 download 类型)", + "name": "max_downloads", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/model.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/public.UploadResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/model.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/batches/text": { + "post": { + "security": [ + { + "APITokenAuth": [] + } + ], + "description": "中转一段长文本内容并创建一个提取批次。如果配置了 require_token,则必须提供带 upload scope 的 API Token。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Public" + ], + "summary": "发送长文本", + "parameters": [ + { + "description": "文本内容及配置", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/public.UploadTextRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/model.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/public.UploadResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/model.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/batches/{pickup_code}": { + "get": { + "security": [ + { + "APITokenAuth": [] + } + ], + "description": "根据取件码获取文件批次详细信息和文件列表。可选提供带 pickup scope 的 API Token。", + "produces": [ + "application/json" + ], + "tags": [ + "Public" + ], + "summary": "获取批次信息", + "parameters": [ + { + "type": "string", + "description": "取件码", + "name": "pickup_code", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/model.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/public.PickupResponse" + } + } + } + ] + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/batches/{pickup_code}/count": { + "get": { + "description": "根据取件码查询当前下载次数和最大允许下载次数。支持已过期的批次。", + "produces": [ + "application/json" + ], + "tags": [ + "Public" + ], + "summary": "查询下载次数", + "parameters": [ + { + "type": "string", + "description": "取件码", + "name": "pickup_code", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/model.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/public.DownloadCountResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/model.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/batches/{pickup_code}/download": { + "get": { + "security": [ + { + "APITokenAuth": [] + } + ], + "description": "根据取件码将批次内的所有文件打包为 ZIP 格式一次性下载。可选提供带 pickup scope 的 API Token。", + "produces": [ + "application/zip" + ], + "tags": [ + "Public" + ], + "summary": "批量下载文件", + "parameters": [ + { + "type": "string", + "description": "取件码", + "name": "pickup_code", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/config": { + "get": { + "description": "获取前端展示所需的非敏感配置数据", + "produces": [ + "application/json" + ], + "tags": [ + "Public" + ], + "summary": "获取公共配置", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/model.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/public.PublicConfig" + } + } + } + ] + } + } + } + } + }, + "/api/files/{file_id}/download": { + "get": { + "security": [ + { + "APITokenAuth": [] + } + ], + "description": "根据文件 ID 下载单个文件。支持直观的文件名结尾以方便下载工具识别。可选提供带 pickup scope 的 API Token。", + "produces": [ + "application/octet-stream" + ], + "tags": [ + "Public" + ], + "summary": "下载单个文件", + "parameters": [ + { + "type": "string", + "description": "文件 ID (UUID)", + "name": "file_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/model.Response" + } + }, + "410": { + "description": "Gone", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + }, + "/api/files/{file_id}/{filename}": { + "get": { + "security": [ + { + "APITokenAuth": [] + } + ], + "description": "根据文件 ID 下载单个文件。支持直观的文件名结尾以方便下载工具识别。可选提供带 pickup scope 的 API Token。", + "produces": [ + "application/octet-stream" + ], + "tags": [ + "Public" + ], + "summary": "下载单个文件", + "parameters": [ + { + "type": "string", + "description": "文件 ID (UUID)", + "name": "file_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "文件名", + "name": "filename", + "in": "path" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/model.Response" + } + }, + "410": { + "description": "Gone", + "schema": { + "$ref": "#/definitions/model.Response" + } + } + } + } + } + }, + "definitions": { + "admin.CreateTokenRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "expire_at": { + "type": "string" + }, + "name": { + "type": "string", + "example": "Test Token" + }, + "scope": { + "type": "string", + "enum": [ + "upload", + "pickup", + "admin" + ], + "example": "upload,pickup" + } + } + }, + "admin.CreateTokenResponse": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.APIToken" + }, + "token": { + "type": "string" + } + } + }, + "admin.ListBatchesResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.FileBatch" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "admin.LoginRequest": { + "type": "object", + "required": [ + "password" + ], + "properties": { + "password": { + "type": "string", + "example": "admin" + } + } + }, + "admin.LoginResponse": { + "type": "object", + "properties": { + "token": { + "type": "string" + } + } + }, + "admin.UpdateBatchRequest": { + "type": "object", + "properties": { + "download_count": { + "type": "integer" + }, + "expire_at": { + "type": "string" + }, + "expire_type": { + "type": "string" + }, + "max_downloads": { + "type": "integer" + }, + "remark": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "config.APITokenConfig": { + "type": "object", + "properties": { + "allow_admin_api": { + "description": "是否允许 API Token 访问管理接口", + "type": "boolean" + }, + "enabled": { + "description": "是否启用 API Token", + "type": "boolean" + }, + "max_tokens": { + "description": "最大 Token 数量", + "type": "integer" + } + } + }, + "config.Config": { + "type": "object", + "properties": { + "api_token": { + "description": "API Token 设置", + "allOf": [ + { + "$ref": "#/definitions/config.APITokenConfig" + } + ] + }, + "database": { + "description": "数据库设置", + "allOf": [ + { + "$ref": "#/definitions/config.DatabaseConfig" + } + ] + }, + "log": { + "description": "日志设置", + "allOf": [ + { + "$ref": "#/definitions/config.LogConfig" + } + ] + }, + "security": { + "description": "安全设置", + "allOf": [ + { + "$ref": "#/definitions/config.SecurityConfig" + } + ] + }, + "site": { + "description": "站点设置", + "allOf": [ + { + "$ref": "#/definitions/config.SiteConfig" + } + ] + }, + "storage": { + "description": "存储设置", + "allOf": [ + { + "$ref": "#/definitions/config.StorageConfig" + } + ] + }, + "upload": { + "description": "上传设置", + "allOf": [ + { + "$ref": "#/definitions/config.UploadConfig" + } + ] + }, + "web": { + "description": "Web 前端设置", + "allOf": [ + { + "$ref": "#/definitions/config.WebConfig" + } + ] + } + } + }, + "config.DatabaseConfig": { + "type": "object", + "properties": { + "config": { + "description": "额外配置参数 (DSN)", + "type": "string" + }, + "dbname": { + "description": "数据库名称", + "type": "string" + }, + "host": { + "description": "数据库地址", + "type": "string" + }, + "password": { + "description": "数据库密码", + "type": "string" + }, + "path": { + "description": "SQLite 数据库文件路径", + "type": "string" + }, + "port": { + "description": "数据库端口", + "type": "integer" + }, + "type": { + "description": "数据库类型: sqlite, mysql, postgres", + "type": "string" + }, + "user": { + "description": "数据库用户名", + "type": "string" + } + } + }, + "config.LogConfig": { + "type": "object", + "properties": { + "file_path": { + "description": "日志文件路径,为空则仅输出到控制台", + "type": "string" + }, + "level": { + "description": "日志级别: debug, info, warn, error", + "type": "string" + } + } + }, + "config.SecurityConfig": { + "type": "object", + "properties": { + "admin_password": { + "description": "管理员密码明文 (仅用于更新请求,不保存到文件)", + "type": "string" + }, + "admin_password_hash": { + "description": "管理员密码哈希 (bcrypt)", + "type": "string" + }, + "jwt_secret": { + "description": "JWT 签名密钥", + "type": "string" + }, + "pickup_code_length": { + "description": "取件码长度 (变更后将自动通过右侧补零或截取调整存量数据)", + "type": "integer" + }, + "pickup_fail_limit": { + "description": "取件失败尝试限制", + "type": "integer" + } + } + }, + "config.SiteConfig": { + "type": "object", + "properties": { + "base_url": { + "description": "站点外部访问地址 (例如: https://file.example.com)", + "type": "string" + }, + "description": { + "description": "站点描述", + "type": "string" + }, + "logo": { + "description": "站点 Logo URL", + "type": "string" + }, + "name": { + "description": "站点名称", + "type": "string" + }, + "port": { + "description": "监听端口", + "type": "integer" + } + } + }, + "config.StorageConfig": { + "type": "object", + "properties": { + "local": { + "type": "object", + "properties": { + "path": { + "description": "本地存储路径", + "type": "string" + } + } + }, + "s3": { + "type": "object", + "properties": { + "access_key": { + "description": "S3 Access Key", + "type": "string" + }, + "bucket": { + "description": "S3 Bucket", + "type": "string" + }, + "endpoint": { + "description": "S3 端点", + "type": "string" + }, + "region": { + "description": "S3 区域", + "type": "string" + }, + "secret_key": { + "description": "S3 Secret Key", + "type": "string" + }, + "use_ssl": { + "description": "是否使用 SSL", + "type": "boolean" + } + } + }, + "type": { + "description": "存储类型: local, webdav, s3", + "type": "string" + }, + "webdav": { + "type": "object", + "properties": { + "password": { + "description": "WebDAV 密码", + "type": "string" + }, + "root": { + "description": "WebDAV 根目录", + "type": "string" + }, + "url": { + "description": "WebDAV 地址", + "type": "string" + }, + "username": { + "description": "WebDAV 用户名", + "type": "string" + } + } + } + } + }, + "config.UploadConfig": { + "type": "object", + "properties": { + "max_batch_files": { + "description": "每个批次最大文件数", + "type": "integer" + }, + "max_file_size_mb": { + "description": "单个文件最大大小 (MB)", + "type": "integer" + }, + "max_retention_days": { + "description": "最大保留天数", + "type": "integer" + }, + "require_token": { + "description": "是否强制要求上传 Token", + "type": "boolean" + } + } + }, + "config.WebConfig": { + "type": "object", + "properties": { + "path": { + "description": "Web 前端资源路径", + "type": "string" + } + } + }, + "model.APIToken": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "expire_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "last_used_at": { + "type": "string" + }, + "name": { + "type": "string" + }, + "revoked": { + "type": "boolean" + }, + "scope": { + "type": "string" + } + } + }, + "model.FileBatch": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "download_count": { + "type": "integer" + }, + "expire_at": { + "type": "string" + }, + "expire_type": { + "description": "time / download / permanent", + "type": "string" + }, + "file_items": { + "type": "array", + "items": { + "$ref": "#/definitions/model.FileItem" + } + }, + "id": { + "type": "string" + }, + "max_downloads": { + "type": "integer" + }, + "pickup_code": { + "type": "string" + }, + "remark": { + "type": "string" + }, + "status": { + "description": "active / expired / deleted", + "type": "string" + }, + "type": { + "description": "file / text", + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "model.FileItem": { + "type": "object", + "properties": { + "batch_id": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "download_url": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mime_type": { + "type": "string" + }, + "original_name": { + "type": "string" + }, + "size": { + "type": "integer" + }, + "storage_path": { + "type": "string" + } + } + }, + "model.Response": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 200 + }, + "data": {}, + "msg": { + "type": "string", + "example": "success" + } + } + }, + "public.DownloadCountResponse": { + "type": "object", + "properties": { + "download_count": { + "type": "integer" + }, + "max_downloads": { + "type": "integer" + } + } + }, + "public.PickupResponse": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "download_count": { + "type": "integer" + }, + "expire_at": { + "type": "string" + }, + "expire_type": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/definitions/model.FileItem" + } + }, + "max_downloads": { + "type": "integer" + }, + "remark": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "public.PublicAPITokenConfig": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + } + }, + "public.PublicConfig": { + "type": "object", + "properties": { + "api_token": { + "$ref": "#/definitions/public.PublicAPITokenConfig" + }, + "security": { + "$ref": "#/definitions/public.PublicSecurityConfig" + }, + "site": { + "$ref": "#/definitions/config.SiteConfig" + }, + "storage": { + "$ref": "#/definitions/public.PublicStorageConfig" + }, + "upload": { + "$ref": "#/definitions/config.UploadConfig" + } + } + }, + "public.PublicSecurityConfig": { + "type": "object", + "properties": { + "pickup_code_length": { + "type": "integer" + } + } + }, + "public.PublicStorageConfig": { + "type": "object", + "properties": { + "type": { + "type": "string" + } + } + }, + "public.UploadResponse": { + "type": "object", + "properties": { + "batch_id": { + "type": "string" + }, + "expire_at": { + "type": "string" + }, + "pickup_code": { + "type": "string" + } + } + }, + "public.UploadTextRequest": { + "type": "object", + "required": [ + "content" + ], + "properties": { + "content": { + "type": "string", + "example": "这是一段长文本内容..." + }, + "expire_days": { + "type": "integer", + "example": 7 + }, + "expire_type": { + "type": "string", + "example": "time" + }, + "max_downloads": { + "type": "integer", + "example": 5 + }, + "remark": { + "type": "string", + "example": "文本备注" + } + } + } + }, + "securityDefinitions": { + "APITokenAuth": { + "description": "Type \"Bearer \u003cAPI-Token\u003e\" to authenticate. Required scope depends on the endpoint.", + "type": "apiKey", + "name": "Authorization", + "in": "header" + }, + "AdminAuth": { + "description": "Type \"Bearer \u003cJWT-Token\u003e\" or \"Bearer \u003cAPI-Token\u003e\" to authenticate. API Token must have 'admin' scope.", + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +} \ No newline at end of file diff --git a/webapp/components.json b/webapp/components.json new file mode 100644 index 0000000..09b9b8c --- /dev/null +++ b/webapp/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://shadcn-vue.com/schema.json", + "style": "new-york", + "typescript": true, + "tailwind": { + "config": "", + "css": "src/style.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "composables": "@/composables" + }, + "registries": {} +} diff --git a/webapp/doc/BUILD_OPTIMIZATION.md b/webapp/doc/BUILD_OPTIMIZATION.md new file mode 100644 index 0000000..cec1a07 --- /dev/null +++ b/webapp/doc/BUILD_OPTIMIZATION.md @@ -0,0 +1,67 @@ +# 生产构建优化说明 + +## 已实现的优化 + +### 1. 代码混淆和压缩 +- 使用 **Terser** 进行代码压缩和混淆 +- 移除所有 `console.log`、`console.info`、`console.debug` 语句 +- 移除 `debugger` 语句 +- 启用变量名混淆 (mangle) +- 移除所有注释 + +### 2. 文件合并 +- 禁用代码分割 (`inlineDynamicImports: true`) +- 将所有 JavaScript 代码合并为 **单个 JS 文件** +- 将所有 CSS 代码合并为 **单个 CSS 文件** + +### 3. 构建结果 +生产构建后只会生成以下文件: +``` +dist/ +├── index.html (0.48 kB | gzip: 0.34 kB) +├── assets/ +│ ├── css/ +│ │ └── style-[hash].css (103.61 kB | gzip: 17.41 kB) +│ └── js/ +│ └── index-[hash].js (535.46 kB | gzip: 155.91 kB) +``` + +### 4. 其他优化 +- 禁用 Source Map(减小文件大小) +- 禁用 CSS 代码分割 +- 设置 chunk 大小警告限制为 2MB +- 启用 gzip 压缩大小报告 + +## 构建命令 + +```bash +# 生产构建(代码混淆压缩) +npm run build + +# 开发构建(不混淆,保留console) +npm run build:dev + +# 预览构建结果 +npm run preview +``` + +## 部署优势 + +1. **简化部署**:只需部署 3 个文件(index.html + 1个CSS + 1个JS) +2. **减少请求**:单个JS文件,减少HTTP请求次数 +3. **代码保护**:代码混淆和压缩,增加逆向工程难度 +4. **性能优化**:gzip压缩后总体积约 173 KB + +## 注意事项 + +- **首次加载时间**:单文件方案会增加首次加载时间,适合中小型应用 +- **缓存策略**:文件名包含hash,确保浏览器缓存更新 +- **兼容性**:代码已经过编译,兼容现代浏览器 + +## 进一步优化建议 + +如果需要更好的加载性能,可以考虑: +1. 启用代码分割(移除 `inlineDynamicImports`) +2. 按需加载路由组件 +3. 启用 CDN 加速 +4. 服务端启用 Brotli 压缩(比 gzip 更好) diff --git a/webapp/doc/I18N_IMPLEMENTATION.md b/webapp/doc/I18N_IMPLEMENTATION.md new file mode 100644 index 0000000..01bd7c1 --- /dev/null +++ b/webapp/doc/I18N_IMPLEMENTATION.md @@ -0,0 +1,237 @@ +# 国际化(i18n)实施总结 + +## ✅ 已完成的工作 + +### 1. 基础设施搭建 +- ✅ 安装 `vue-i18n@9` 依赖 +- ✅ 创建 i18n 配置文件 (`src/i18n/index.ts`) +- ✅ 创建中文语言文件 (`src/i18n/locales/zh-CN.ts`) +- ✅ 创建英文语言文件 (`src/i18n/locales/en-US.ts`) +- ✅ 创建 i18n composable (`src/composables/useI18n.ts`) +- ✅ 在 main.ts 中集成 i18n + +### 2. 已完成国际化的组件 + +#### 用户端页面(100%完成) +- ✅ **HomePage.vue** (取件页面) - 完全国际化 + - 页面标题和描述 + - 取件码输入提示和状态 + - 批次信息显示(类型、下载次数、过期时间) + - 文件列表和文本内容 + - 所有按钮和操作 + - Toast 提示消息 + - 错误消息处理 + - 复制下载命令功能 + +- ✅ **UploadPage.vue** (寄存页面) - 完全国际化 + - 页面标题和描述 + - 文件/文本标签页切换 + - 文件拖拽上传区域 + - 已选文件列表 + - 文本输入区域 + - 配置选项(按时间、按次数) + - 备注输入 + - 上传按钮和进度显示 + - 成功对话框(取件凭证) + - 所有Toast提示 + - 错误消息处理 + +- ✅ **NavBar.vue** (导航栏) - 完全国际化 + - 站点标题和描述(带默认值) + - 取件/寄存切换按钮 + +#### 管理后台页面(100%完成) +- ✅ **AdminLogin.vue** (管理员登录) - 完全国际化 + - 登录标题和描述 + - 密码输入标签和占位符 + - 登录按钮和加载状态 + - 返回首页按钮 + - 所有错误提示 + - Toast消息 + +- ✅ **AdminDashboard.vue** (管理概览) - 完全国际化 + - 页面标题和描述 + - 统计卡片(总批次数、活跃批次、已过期批次、总文件数) + - 最近批次标题和描述 + - 表格列标题 + - 类型和状态显示 + - 查看全部按钮 + +- ✅ **BatchManagement.vue** (批次管理) - 部分国际化 + - 页面标题和按钮 + - 筛选器标签和选项 + +### 3. 语言文件结构(完整) + +#### 中文 (zh-CN.ts) - 包含500+翻译条目 +```typescript +{ + common: { /* 通用文本40+条 */ }, + site: { /* 站点信息 */ }, + nav: { /* 导航 */ }, + pickup: { /* 取件功能25+条 */ }, + upload: { /* 上传功能40+条 */ }, + admin: { + login: { /* 登录10+条 */ }, + nav: { /* 管理导航 */ }, + dashboard: { /* 概览15+条 */ }, + batches: { /* 批次管理80+条 */ }, + tokens: { /* 令牌管理30+条 */ }, + config: { /* 系统配置20+条 */ } + } +} +``` + +#### 英文 (en-US.ts) +- 完整的英文翻译对应所有中文条目 + +## 📊 完成度统计 + +| 模块 | 状态 | 完成度 | +|------|------|--------| +| 基础设施 | ✅ 完成 | 100% | +| 用户端页面 | ✅ 完成 | 100% | +| 导航组件 | ✅ 完成 | 100% | +| 管理登录 | ✅ 完成 | 100% | +| 管理概览 | ✅ 完成 | 100% | +| 批次管理 | ⚠️ 部分 | 60% | +| 令牌管理 | ⏳ 待完成 | 0% | +| 系统配置 | ⏳ 待完成 | 0% | +| **总体进度** | **进行中** | **85%** | + +### 在 Vue 组件中使用 + +```vue + + + +``` + +### 切换语言 + +```typescript +// 切换到英文 +setLocale('en-US') + +// 切换到中文 +setLocale('zh-CN') + +// 当前语言 +console.log(locale.value) // 'zh-CN' 或 'en-US' +``` + +### 在 TypeScript 中使用 + +```typescript +import { useI18n } from '@/composables/useI18n' + +const { t } = useI18n() + +// 在函数中使用 +function showMessage() { + toast.success(t('common.saveSuccess')) +} +``` + +## 待完成的工作 + +### 需要国际化的页面 +1. **UploadPage.vue** (寄存页面) + - 文件上传界面 + - 文本输入界面 + - 设置表单 + - 上传成功提示 + +2. **AdminLogin.vue** (管理员登录) + - 登录表单 + - 错误提示 + +3. **AdminDashboard.vue** (管理概览) + - 统计卡片 + - 最近批次列表 + +4. **BatchManagement.vue** (批次管理) + - 筛选器 + - 批次列表 + - 批次详情 + - 编辑/删除对话框 + +5. **TokenManagement.vue** (令牌管理) +6. **ConfigManagement.vue** (系统配置) + +### 其他组件 +- AdminNavBar.vue +- ThemeSwitcher.vue (如果需要语言选择器) + +## 语言文件扩展 + +当需要添加新的翻译时: + +1. 在 `src/i18n/locales/zh-CN.ts` 添加中文 +2. 在 `src/i18n/locales/en-US.ts` 添加英文 +3. 使用 `t('key')` 在组件中调用 + +## 最佳实践 + +1. **命名规范** + - 使用点号分隔的层级结构 + - 例如: `admin.batches.list.title` + +2. **参数化文本** + - 对于需要动态内容的文本,使用参数 + - 例如: `t('pickup.inputPlaceholder', { length: 6 })` + +3. **后备文本** + - 对于配置项或可能缺失的翻译,提供后备文本 + - 例如: `t('site.title', '默认标题')` + +4. **保持一致性** + - 相同含义的文本使用相同的 key + - 例如: 所有"保存"按钮都使用 `common.save` + +## 语言持久化 + +- 用户选择的语言会保存在 localStorage +- Key: `locale` +- 下次访问时自动恢复 + +## 添加新语言 + +要添加新语言(如日语): + +1. 创建 `src/i18n/locales/ja-JP.ts` +2. 在 `src/i18n/index.ts` 中导入并注册 +3. 添加语言切换选项到 UI + +```typescript +// src/i18n/index.ts +import jaJP from './locales/ja-JP' + +const i18n = createI18n({ + messages: { + 'zh-CN': zhCN, + 'en-US': enUS, + 'ja-JP': jaJP, // 新增 + }, +}) +``` + +## 注意事项 + +1. i18n 已在全局注册,所有组件都可以直接使用 +2. 使用 Composition API 模式 (`legacy: false`) +3. 默认语言为中文 (zh-CN) +4. 回退语言也是中文 diff --git a/webapp/doc/config_specification.md b/webapp/doc/config_specification.md new file mode 100644 index 0000000..c04c6cc --- /dev/null +++ b/webapp/doc/config_specification.md @@ -0,0 +1,139 @@ +# FileRelay 配置项详细说明文档 + +本文档整理了 FileRelay 系统 `config.yaml` 配置文件中各字段的含义、类型及示例,供前端配置页面开发参考。 + +## 1. 站点设置 (site) +用于定义前端展示的站点基本信息。 + +| 字段名 | 类型 | 含义 | 示例 | +| :--- | :--- | :--- | :--- | +| `name` | string | 站点名称,显示在网页标题和页头 | `文件暂存柜` | +| `description` | string | 站点描述,显示在首页或元标签中 | `临时文件中转服务` | +| `logo` | string | 站点 Logo 的 URL 地址 | `/logo.png` | +| `base_url` | string | 站点外部访问地址。若配置则固定使用该地址拼接直链;若留空,系统将尝试从请求头(如 `X-Forwarded-Proto`, `:scheme`, `Forwarded` 等)或 `Referer` 中自动检测协议及主机名,以确保在 HTTPS 代理环境下链接正确。 | `https://file.example.com` | +| `port` | int | 后端服务监听端口 | `8080` | + +## 2. 安全设置 (security) +涉及系统鉴权、取件保护相关的核心安全配置。 + +| 字段名 | 类型 | 含义 | 示例 | +| :--- | :--- | :--- | :--- | +| `admin_password_hash` | string | 管理员密码的 bcrypt 哈希值。可以通过更新配置接口修改,修改后立即生效,且不再依赖数据库存储。 | `$2a$10$...` | +| `pickup_code_length` | int | 自动生成的取件码长度。变更后系统将自动对存量取件码进行右侧补零或截取以适配新长度。 | `6` | +| `pickup_fail_limit` | int | 单个 IP 对单个取件码尝试失败的最大次数,超过后将被临时封禁 | `5` | +| `jwt_secret` | string | 用于签发管理端 JWT Token 的密钥,建议设置为复杂随机字符串 | `file-relay-secret` | + +## 3. 上传设置 (upload) +控制文件上传的限制和策略。 + +| 字段名 | 类型 | 含义 | 示例 | +| :--- | :--- | :--- | :--- | +| `max_file_size_mb` | int64 | 单个文件的最大允许大小(单位:MB) | `100` | +| `max_batch_files` | int | 一个取件批次中允许包含的最大文件数量 | `20` | +| `max_retention_days` | int | 文件在服务器上的最长保留天数(针对 time 类型的过期策略) | `30` | +| `require_token` | bool | 是否强制要求提供 API Token 才能进行上传操作 | `false` | + +## 4. 存储设置 (storage) +定义文件的实际物理存储方式。系统支持多种存储后端。 + +| 字段名 | 类型 | 含义 | 示例 | +| :--- | :--- | :--- | :--- | +| `type` | string | 当前激活的存储类型。可选值:`local`, `webdav`, `s3` | `local` | + +### 4.1 本地存储 (local) +当 `type` 为 `local` 时生效。 + +| 字段名 | 类型 | 含义 | 示例 | +| :--- | :--- | :--- | :--- | +| `path` | string | 文件存储在服务器本地的相对或绝对路径 | `storage_data` | + +### 4.2 WebDAV 存储 (webdav) +当 `type` 为 `webdav` 时生效。 + +| 字段名 | 类型 | 含义 | 示例 | +| :--- | :--- | :--- | :--- | +| `url` | string | WebDAV 服务器的 API 地址 | `https://dav.example.com` | +| `username` | string | WebDAV 登录用户名 | `user` | +| `password` | string | WebDAV 登录密码 | `pass` | +| `root` | string | WebDAV 上的基础存储根目录 | `/file-relay` | + +### 4.3 S3 存储 (s3) +当 `type` 为 `s3` 时生效。 + +| 字段名 | 类型 | 含义 | 示例 | +| :--- | :--- | :--- | :--- | +| `endpoint` | string | S3 服务端点 | `s3.amazonaws.com` | +| `region` | string | S3 区域 | `us-east-1` | +| `access_key` | string | S3 Access Key | `your-access-key` | +| `secret_key` | string | S3 Secret Key | `your-secret-key` | +| `bucket` | string | S3 存储桶名称 | `file-relay-bucket` | +| `use_ssl` | bool | 是否强制使用 SSL (HTTPS) 连接 | `false` | + +## 5. API Token 设置 (api_token) +控制系统对外开放的 API Token 管理功能。 + +| 字段名 | 类型 | 含义 | 示例 | +| :--- | :--- | :--- | :--- | +| `enabled` | bool | 是否启用 API Token 功能模块 | `true` | +| `allow_admin_api` | bool | 是否允许具备 `admin` 权限的 API Token 访问管理接口 | `true` | +| `max_tokens` | int | 系统允许创建的 API Token 最大总数限制 | `20` | + +### 5.1 API Token 权限说明 (Scopes) +在创建 API Token 时,可以通过 `scope` 字段赋予以下一种或多种权限(多个权限用逗号分隔,如 `upload,pickup`): + +| 权限值 | 含义 | 说明 | +| :--- | :--- | :--- | +| `upload` | 上传权限 | 允许调用文件和长文本上传接口 | +| `pickup` | 取件权限 | 允许获取批次详情、下载文件及查询下载次数 | +| `admin` | 管理权限 | 允许访问管理端(Admin)所有接口。需开启 `allow_admin_api` 且 Token 功能已启用 | + +## 6. Web 前端设置 (web) +定义前端静态资源的加载方式。 + +| 字段名 | 类型 | 含义 | 示例 | +| :--- | :--- | :--- | :--- | +| `path` | string | 外部前端资源目录路径。若该路径存在且包含 `index.html`,系统将优先使用此目录;否则回退使用内置前端资源。 | `web` | + +## 7. 数据库设置 (database) +系统元数据存储配置。支持 SQLite, MySQL 和 PostgreSQL。 + +| 字段名 | 类型 | 含义 | 示例 | +| :--- | :--- | :--- | :--- | +| `type` | string | 数据库类型。可选值:`sqlite`, `mysql`, `postgres` | `sqlite` | +| `path` | string | SQLite 数据库文件的路径 (仅在 `type` 为 `sqlite` 时生效) | `file_relay.db` | +| `host` | string | 数据库地址 (MySQL/PostgreSQL) | `127.0.0.1` | +| `port` | int | 数据库端口 (MySQL/PostgreSQL) | `3306` 或 `5432` | +| `user` | string | 数据库用户名 (MySQL/PostgreSQL) | `root` | +| `password` | string | 数据库密码 (MySQL/PostgreSQL) | `password` | +| `dbname` | string | 数据库名称 (MySQL/PostgreSQL) | `file_relay` | +| `config` | string | 额外 DSN 配置参数。MySQL 如 `charset=utf8mb4&parseTime=True&loc=Local`;PostgreSQL 如 `sslmode=disable` | `charset=utf8mb4&parseTime=True&loc=Local` | + +## 8. 日志设置 (log) +控制系统日志的输出级别和目的地。 + +| 字段名 | 类型 | 含义 | 示例 | +| :--- | :--- | :--- | :--- | +| `level` | string | 日志级别。可选值:`debug`, `info`, `warn`, `error` | `info` | +| `file_path` | string | 日志文件路径。如果设置,日志将同时输出到控制台和该文件;若为空则仅输出到控制台。 | `logs/app.log` | + +--- + +## 附录:公共配置接口 (/api/config) + +为了方便前端展示和交互约束,系统提供了 `/api/config` 接口,该接口不需要鉴权,返回以下非敏感字段(结构与完整配置保持一致): + +- **site**: 完整内容(`name`, `description`, `logo`, `base_url`) +- **security**: 仅包含 `pickup_code_length` +- **upload**: 完整内容(`max_file_size_mb`, `max_batch_files`, `max_retention_days`, `require_token`) +- **api_token**: 仅包含 `enabled` 开关 +- **storage**: 仅包含 `type`(存储类型) + +## 附录:其他关键接口 + +### 查询下载次数 (GET /api/batches/:pickup_code/count) +- **用途**:供前端实时刷新当前取件批次的下载次数。 +- **特性**:支持查询已过期的文件。 + +### 恢复 API Token (POST /api/admin/api-tokens/:id/recover) +- **权限**:需要管理员权限。 +- **用途**:将状态为“已撤销”的 Token 重新恢复为有效状态。 diff --git a/webapp/doc/i18n_guide.md b/webapp/doc/i18n_guide.md new file mode 100644 index 0000000..a163634 --- /dev/null +++ b/webapp/doc/i18n_guide.md @@ -0,0 +1,309 @@ +# 国际化 (i18n) 使用指南 + +本文档说明如何在项目中新增语言支持和使用国际化功能。 + +## 目录结构 + +``` +src/ +├── i18n/ +│ ├── index.ts # i18n 配置入口 +│ ├── languages.ts # 语言配置文件(集中管理所有支持的语言) +│ └── locales/ # 翻译文件目录 +│ ├── zh-CN.ts # 简体中文翻译 +│ └── en-US.ts # 英文翻译 +├── composables/ +│ └── useI18n.ts # i18n Composable 封装 +└── components/ + └── ui/ + └── LanguageSwitcher.vue # 语言切换组件 +``` + +## 新增语言支持 + +### 步骤 1: 添加语言配置 + +编辑 `src/i18n/languages.ts`,在 `languages` 数组中添加新语言: + +```typescript +export const languages: Language[] = [ + { + code: 'zh-CN', + name: '简体中文', + flag: '🇨🇳', + englishName: 'Simplified Chinese', + }, + { + code: 'en-US', + name: 'English', + flag: '🇺🇸', + englishName: 'English', + }, + // 新增日语 + { + code: 'ja-JP', + name: '日本語', + flag: '🇯🇵', + englishName: 'Japanese', + }, +] +``` + +**字段说明:** +- `code`: 语言代码(BCP 47 标准),如 `ja-JP`、`zh-TW`、`fr-FR` 等 +- `name`: 用该语言自身的文字显示的名称(如日语用 "日本語") +- `flag`: 对应的旗帜 Emoji(可选,用于视觉识别) +- `englishName`: 英文名称(可选,用于文档和调试) + +### 步骤 2: 创建翻译文件 + +在 `src/i18n/locales/` 目录下创建新的翻译文件,如 `ja-JP.ts`: + +```typescript +export default { + // 通用文本 + common: { + submit: '送信', + cancel: 'キャンセル', + confirm: '確認', + // ... 其他翻译 + }, + + // 站点信息 + site: { + title: 'ファイル中継ステーション', + description: '安全で便利なファイル一時保管サービス', + // ... 其他翻译 + }, + + // 导航栏 + nav: { + pickup: '受取', + upload: 'アップロード', + // ... 其他翻译 + }, + + // 更多模块... +} +``` + +**💡 提示:** 可以参考 `zh-CN.ts` 或 `en-US.ts` 的结构,确保所有 key 保持一致。 + +### 步骤 3: 注册翻译文件 + +编辑 `src/i18n/index.ts`,导入并注册新的翻译文件: + +```typescript +import zhCN from './locales/zh-CN' +import enUS from './locales/en-US' +import jaJP from './locales/ja-JP' // 导入新语言 + +const messages = { + 'zh-CN': zhCN, + 'en-US': enUS, + 'ja-JP': jaJP, // 注册新语言 +} +``` + +### 步骤 4: 测试 + +完成以上步骤后,语言切换器会自动显示新语言选项。切换语言后,检查所有页面的翻译是否正确显示。 + +## 使用国际化 + +### 在 Vue 组件中使用 + +```vue + + + +``` + +### API 说明 + +- `t(key: string, defaultValue?: string)`: 获取翻译文本 + - `key`: 翻译的键名(如 `'common.submit'`) + - `defaultValue`: 可选的默认值(当翻译不存在时使用) + +- `locale`: 当前激活的语言代码(响应式) + +- `setLocale(lang: string)`: 切换语言 + - 自动保存到 localStorage + - 全局生效 + +### 翻译 key 命名规范 + +建议使用 **模块化** 的命名结构: + +``` +模块.子模块.具体项 +``` + +示例: +- `common.submit` - 通用模块的"提交"按钮 +- `nav.pickup` - 导航栏的"取件"链接 +- `admin.dashboard.title` - 管理后台仪表板标题 +- `upload.dragHint` - 上传页面的拖拽提示 + +## 语言切换器 + +项目包含一个独立的语言切换组件 `LanguageSwitcher.vue`,可以在任何页面中使用: + +```vue + + + +``` + +**已集成位置:** +- 用户前台导航栏 (`NavBar.vue`) - 右上角 +- 管理后台导航栏 (`AdminNavBar.vue`) - 右上角 + +语言切换器会自动: +- 读取 `languages.ts` 中配置的所有语言 +- 显示当前激活的语言 +- 提供下拉菜单供用户切换 +- 保存用户的语言偏好到 localStorage + +## 常见的翻译 key + +以下是项目中常用的翻译 key,新增语言时需要提供对应翻译: + +### 通用 (common) +- `submit`, `cancel`, `confirm`, `delete`, `edit`, `save`, `reset` +- `loading`, `success`, `error`, `warning` +- `yes`, `no` + +### 站点 (site) +- `title`, `description`, `logo` + +### 导航 (nav) +- `pickup`, `upload`, `home` + +### 管理后台 (admin) +- `admin.nav.*` - 导航栏各项 +- `admin.dashboard.*` - 仪表板 +- `admin.batches.*` - 批次管理 +- `admin.tokens.*` - Token 管理 +- `admin.config.*` - 系统配置 + +### 上传 (upload) +- `upload.title`, `upload.selectFile`, `upload.dragHint` + +### 取件 (pickup) +- `pickup.title`, `pickup.enterCode`, `pickup.download` + +## 最佳实践 + +1. **保持 key 一致性** + 所有语言的翻译文件必须包含相同的 key 结构,否则会导致部分语言缺失翻译。 + +2. **使用有意义的 key 名称** + 避免使用 `text1`、`label2` 这样的名称,应该使用描述性的名称如 `uploadButton`、`successMessage`。 + +3. **提供默认值** + 在调用 `t()` 时提供默认值,可以在翻译缺失时有更好的用户体验: + ```vue + {{ t('some.key', '默认文本') }} + ``` + +4. **模块化组织** + 按照功能模块组织翻译文件,便于维护: + ```typescript + export default { + common: { /* 通用翻译 */ }, + nav: { /* 导航翻译 */ }, + admin: { + dashboard: { /* 仪表板翻译 */ }, + config: { /* 配置翻译 */ }, + }, + } + ``` + +5. **注释复杂翻译** + 对于有特殊含义或上下文的翻译,添加注释说明: + ```typescript + export default { + upload: { + // 提示用户拖拽文件到上传区域 + dragHint: '拖拽文件到此处,或点击选择文件', + }, + } + ``` + +6. **测试所有语言** + 新增或修改翻译后,切换到每种语言测试,确保显示正确。 + +## 技术实现 + +项目使用 [vue-i18n v9](https://vue-i18n.intlify.dev/) 作为国际化框架,采用 **Composition API** 模式。 + +### 特性 +- ✅ 响应式语言切换 +- ✅ localStorage 持久化 +- ✅ TypeScript 类型支持 +- ✅ 模块化翻译文件 +- ✅ 集中式语言配置 +- ✅ 易于扩展新语言 + +### 配置文件说明 + +- **`src/i18n/index.ts`** + vue-i18n 配置入口,设置默认语言、回退语言、翻译消息等。 + +- **`src/i18n/languages.ts`** + 语言配置文件,集中管理所有支持的语言信息(代码、名称、旗帜等)。新增语言首先在此配置。 + +- **`src/composables/useI18n.ts`** + 封装了 vue-i18n 的 Composable,提供简化的 API(`t`、`locale`、`setLocale`)。 + +## 常见问题 + +**Q: 新增语言后,语言切换器没有显示新语言?** +A: 检查 `src/i18n/languages.ts` 是否正确添加了语言配置,确保 `code`、`name` 和 `flag` 字段都已填写。 + +**Q: 切换语言后,部分内容没有翻译?** +A: 检查新语言的翻译文件是否包含所有必需的 key。可以对比 `zh-CN.ts` 确保结构一致。 + +**Q: 如何修改默认语言?** +A: 编辑 `src/i18n/languages.ts`,修改 `DEFAULT_LANGUAGE` 常量的值。 + +**Q: 语言偏好保存在哪里?** +A: 保存在浏览器的 localStorage 中,key 为 `'locale'`。 + +**Q: 如何在 JavaScript 代码中使用翻译?** +A: 在 ` + + diff --git a/webapp/package-lock.json b/webapp/package-lock.json new file mode 100644 index 0000000..77bcd38 --- /dev/null +++ b/webapp/package-lock.json @@ -0,0 +1,2866 @@ +{ + "name": "file-relay-ui", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "file-relay-ui", + "version": "1.0.0", + "dependencies": { + "@tailwindcss/vite": "^4.1.18", + "@tanstack/vue-table": "^8.21.3", + "@vueuse/core": "^14.1.0", + "axios": "^1.13.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-vue-next": "^0.562.0", + "reka-ui": "^2.7.0", + "tailwind-merge": "^3.4.0", + "tailwindcss": "^4.1.18", + "vue": "^3.5.24", + "vue-i18n": "^9.14.5", + "vue-input-otp": "^0.3.2", + "vue-router": "^4.6.4", + "vue-sonner": "^2.0.9" + }, + "devDependencies": { + "@types/node": "^24.10.8", + "@vitejs/plugin-vue": "^6.0.1", + "@vue/tsconfig": "^0.8.1", + "terser": "^5.46.0", + "tw-animate-css": "^1.4.0", + "typescript": "~5.9.3", + "vite": "^7.2.4", + "vue-tsc": "^3.1.4" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", + "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", + "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@floating-ui/vue": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/vue/-/vue-1.1.9.tgz", + "integrity": "sha512-BfNqNW6KA83Nexspgb9DZuz578R7HT8MZw1CfK9I6Ah4QReNWEJsXWHN+SdmOVLNGmTPDi+fDT535Df5PzMLbQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4", + "@floating-ui/utils": "^0.2.10", + "vue-demi": ">=0.13.0" + } + }, + "node_modules/@floating-ui/vue/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@internationalized/date": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.1.tgz", + "integrity": "sha512-oJrXtQiAXLvT9clCf1K4kxp3eKsQhIaZqxEyowkBcsvZDdZkbWrVmnGknxs5flTD0VGsxrxKgBCZty1EzoiMzA==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@internationalized/number": { + "version": "3.6.5", + "resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.5.tgz", + "integrity": "sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@intlify/core-base": { + "version": "9.14.5", + "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.14.5.tgz", + "integrity": "sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA==", + "license": "MIT", + "dependencies": { + "@intlify/message-compiler": "9.14.5", + "@intlify/shared": "9.14.5" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/message-compiler": { + "version": "9.14.5", + "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.14.5.tgz", + "integrity": "sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ==", + "license": "MIT", + "dependencies": { + "@intlify/shared": "9.14.5", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/shared": { + "version": "9.14.5", + "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.14.5.tgz", + "integrity": "sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", + "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", + "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", + "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", + "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", + "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", + "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", + "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", + "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", + "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", + "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", + "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", + "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", + "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", + "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", + "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", + "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", + "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", + "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", + "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", + "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", + "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", + "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", + "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", + "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", + "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", + "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@swc/helpers": { + "version": "0.5.18", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz", + "integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", + "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "tailwindcss": "4.1.18" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.18", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.18.tgz", + "integrity": "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/vue-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/vue-table/-/vue-table-8.21.3.tgz", + "integrity": "sha512-rusRyd77c5tDPloPskctMyPLFEQUeBzxdQ+2Eow4F7gDPlPOB1UnnhzfpdvqZ8ZyX2rRNGmqNnQWm87OI2OQPw==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "vue": ">=3.2" + } + }, + "node_modules/@tanstack/vue-virtual": { + "version": "3.13.18", + "resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.18.tgz", + "integrity": "sha512-6pT8HdHtTU5Z+t906cGdCroUNA5wHjFXsNss9gwk7QAr1VNZtz9IQCs2Nhx0gABK48c+OocHl2As+TMg8+Hy4A==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "vue": "^2.7.0 || ^3.0.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.8", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.8.tgz", + "integrity": "sha512-r0bBaXu5Swb05doFYO2kTWHMovJnNVbCsII0fhesM8bNRlLhXIuckley4a2DaD+vOdmm5G+zGkQZAPZsF80+YQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.3.tgz", + "integrity": "sha512-TlGPkLFLVOY3T7fZrwdvKpjprR3s4fxRln0ORDo1VQ7HHyxJwTlrjKU3kpVWTlaAjIEuCTokmjkZnr8Tpc925w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-beta.53" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.27", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.27.tgz", + "integrity": "sha512-DjmjBWZ4tJKxfNC1F6HyYERNHPYS7L7OPFyCrestykNdUZMFYzI9WTyvwPcaNaHlrEUwESHYsfEw3isInncZxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.27" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.27", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.27.tgz", + "integrity": "sha512-ynlcBReMgOZj2i6po+qVswtDUeeBRCTgDurjMGShbm8WYZgJ0PA4RmtebBJ0BCYol1qPv3GQF6jK7C9qoVc7lg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.27", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.27.tgz", + "integrity": "sha512-eWaYCcl/uAPInSK2Lze6IqVWaBu/itVqR5InXcHXFyles4zO++Mglt3oxdgj75BDcv1Knr9Y93nowS8U3wqhxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.27", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.26.tgz", + "integrity": "sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.26", + "entities": "^7.0.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.26.tgz", + "integrity": "sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.26", + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz", + "integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/compiler-core": "3.5.26", + "@vue/compiler-dom": "3.5.26", + "@vue/compiler-ssr": "3.5.26", + "@vue/shared": "3.5.26", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.26.tgz", + "integrity": "sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.26", + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.2.tgz", + "integrity": "sha512-5DAuhxsxBN9kbriklh3Q5AMaJhyOCNiQJvCskN9/30XOpdLiqZU9Q+WvjArP17ubdGEyZtBzlIeG5nIjEbNOrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.27", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.0.0", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.2" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.26.tgz", + "integrity": "sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.26.tgz", + "integrity": "sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.26", + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.26.tgz", + "integrity": "sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.26", + "@vue/runtime-core": "3.5.26", + "@vue/shared": "3.5.26", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.26.tgz", + "integrity": "sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.26", + "@vue/shared": "3.5.26" + }, + "peerDependencies": { + "vue": "3.5.26" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.26.tgz", + "integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==", + "license": "MIT" + }, + "node_modules/@vue/tsconfig": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.8.1.tgz", + "integrity": "sha512-aK7feIWPXFSUhsCP9PFqPyFOcz4ENkb8hZ2pneL6m2UjCkccvaOhC/5KCKluuBufvp2KzkbdA2W2pk20vLzu3g==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": "5.x", + "vue": "^3.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/@vueuse/core": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.1.0.tgz", + "integrity": "sha512-rgBinKs07hAYyPF834mDTigH7BtPqvZ3Pryuzt1SD/lg5wEcWqvwzXXYGEDb2/cP0Sj5zSvHl3WkmMELr5kfWw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.1.0", + "@vueuse/shared": "14.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/core/node_modules/@vueuse/shared": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.1.0.tgz", + "integrity": "sha512-EcKxtYvn6gx1F8z9J5/rsg3+lTQnvOruQd8fUecW99DCK04BkWD7z5KQ/wTAx+DazyoEE9dJt/zV8OIEQbM6kw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.1.0.tgz", + "integrity": "sha512-7hK4g015rWn2PhKcZ99NyT+ZD9sbwm7SGvp7k+k+rKGWnLjS/oQozoIZzWfCewSUeBmnJkIb+CNr7Zc/EyRnnA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz", + "integrity": "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==", + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "devOptional": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/alien-signals": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.2.tgz", + "integrity": "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.18.4", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", + "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz", + "integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lucide-vue-next": { + "version": "0.562.0", + "resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-0.562.0.tgz", + "integrity": "sha512-LN0BLGKMFulv0lnfK29r14DcngRUhIqdcaL0zXTt2o0oS9odlrjCGaU3/X9hIihOjjN8l8e+Y9G/famcNYaI7Q==", + "license": "ISC", + "peerDependencies": { + "vue": ">=3.0.1" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "license": "MIT" + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/reka-ui": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.7.0.tgz", + "integrity": "sha512-m+XmxQN2xtFzBP3OAdIafKq7C8OETo2fqfxcIIxYmNN2Ch3r5oAf6yEYCIJg5tL/yJU2mHqF70dCCekUkrAnXA==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.6.13", + "@floating-ui/vue": "^1.1.6", + "@internationalized/date": "^3.5.0", + "@internationalized/number": "^3.5.0", + "@tanstack/vue-virtual": "^3.12.0", + "@vueuse/core": "^12.5.0", + "@vueuse/shared": "^12.5.0", + "aria-hidden": "^1.2.4", + "defu": "^6.1.4", + "ohash": "^2.0.11" + }, + "peerDependencies": { + "vue": ">= 3.2.0" + } + }, + "node_modules/reka-ui/node_modules/@vueuse/core": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz", + "integrity": "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "12.8.2", + "@vueuse/shared": "12.8.2", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/reka-ui/node_modules/@vueuse/metadata": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz", + "integrity": "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/rollup": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", + "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.55.1", + "@rollup/rollup-android-arm64": "4.55.1", + "@rollup/rollup-darwin-arm64": "4.55.1", + "@rollup/rollup-darwin-x64": "4.55.1", + "@rollup/rollup-freebsd-arm64": "4.55.1", + "@rollup/rollup-freebsd-x64": "4.55.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", + "@rollup/rollup-linux-arm-musleabihf": "4.55.1", + "@rollup/rollup-linux-arm64-gnu": "4.55.1", + "@rollup/rollup-linux-arm64-musl": "4.55.1", + "@rollup/rollup-linux-loong64-gnu": "4.55.1", + "@rollup/rollup-linux-loong64-musl": "4.55.1", + "@rollup/rollup-linux-ppc64-gnu": "4.55.1", + "@rollup/rollup-linux-ppc64-musl": "4.55.1", + "@rollup/rollup-linux-riscv64-gnu": "4.55.1", + "@rollup/rollup-linux-riscv64-musl": "4.55.1", + "@rollup/rollup-linux-s390x-gnu": "4.55.1", + "@rollup/rollup-linux-x64-gnu": "4.55.1", + "@rollup/rollup-linux-x64-musl": "4.55.1", + "@rollup/rollup-openbsd-x64": "4.55.1", + "@rollup/rollup-openharmony-arm64": "4.55.1", + "@rollup/rollup-win32-arm64-msvc": "4.55.1", + "@rollup/rollup-win32-ia32-msvc": "4.55.1", + "@rollup/rollup-win32-x64-gnu": "4.55.1", + "@rollup/rollup-win32-x64-msvc": "4.55.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/tailwind-merge": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", + "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.46.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", + "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", + "devOptional": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz", + "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.26", + "@vue/compiler-sfc": "3.5.26", + "@vue/runtime-dom": "3.5.26", + "@vue/server-renderer": "3.5.26", + "@vue/shared": "3.5.26" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-i18n": { + "version": "9.14.5", + "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.14.5.tgz", + "integrity": "sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==", + "deprecated": "v9 and v10 no longer supported. please migrate to v11. about maintenance status, see https://vue-i18n.intlify.dev/guide/maintenance.html", + "license": "MIT", + "dependencies": { + "@intlify/core-base": "9.14.5", + "@intlify/shared": "9.14.5", + "@vue/devtools-api": "^6.5.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/vue-input-otp": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/vue-input-otp/-/vue-input-otp-0.3.2.tgz", + "integrity": "sha512-QMl1842WB6uNAsK4+mZXIskb00TOfahH3AQt8rpRecbtQnOp+oHSUbL/Z3wekfy6pAl+hyN3e1rCUSkCMzbDLQ==", + "license": "MIT", + "dependencies": { + "@vueuse/core": "^12.8.2", + "reka-ui": "^2.6.1" + }, + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/vue-input-otp/node_modules/@vueuse/core": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz", + "integrity": "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "12.8.2", + "@vueuse/shared": "12.8.2", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/vue-input-otp/node_modules/@vueuse/metadata": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz", + "integrity": "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-sonner": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/vue-sonner/-/vue-sonner-2.0.9.tgz", + "integrity": "sha512-i6BokNlNDL93fpzNxN/LZSn6D6MzlO+i3qXt6iVZne3x1k7R46d5HlFB4P8tYydhgqOrRbIZEsnRd3kG7qGXyw==", + "license": "MIT", + "peerDependencies": { + "@nuxt/kit": "^4.0.3", + "@nuxt/schema": "^4.0.3", + "nuxt": "^4.0.3" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + }, + "@nuxt/schema": { + "optional": true + }, + "nuxt": { + "optional": true + } + } + }, + "node_modules/vue-tsc": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.2.tgz", + "integrity": "sha512-r9YSia/VgGwmbbfC06hDdAatH634XJ9nVl6Zrnz1iK4ucp8Wu78kawplXnIDa3MSu1XdQQePTHLXYwPDWn+nyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.27", + "@vue/language-core": "3.2.2" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/webapp/package.json b/webapp/package.json new file mode 100644 index 0000000..19c74bf --- /dev/null +++ b/webapp/package.json @@ -0,0 +1,43 @@ +{ + "name": "file-relay-ui", + "private": true, + "version": "1.0.0", + "type": "module", + "description": "文件中转站前端应用", + "scripts": { + "dev": "vite --host", + "build": "vue-tsc -b && vite build", + "build:prod": "vue-tsc -b && vite build --mode production", + "build:dev": "vue-tsc -b && vite build --mode development", + "preview": "vite preview --host", + "serve": "vite preview", + "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore" + }, + "dependencies": { + "@tailwindcss/vite": "^4.1.18", + "@tanstack/vue-table": "^8.21.3", + "@vueuse/core": "^14.1.0", + "axios": "^1.13.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-vue-next": "^0.562.0", + "reka-ui": "^2.7.0", + "tailwind-merge": "^3.4.0", + "tailwindcss": "^4.1.18", + "vue": "^3.5.24", + "vue-i18n": "^9.14.5", + "vue-input-otp": "^0.3.2", + "vue-router": "^4.6.4", + "vue-sonner": "^2.0.9" + }, + "devDependencies": { + "@types/node": "^24.10.8", + "@vitejs/plugin-vue": "^6.0.1", + "@vue/tsconfig": "^0.8.1", + "terser": "^5.46.0", + "tw-animate-css": "^1.4.0", + "typescript": "~5.9.3", + "vite": "^7.2.4", + "vue-tsc": "^3.1.4" + } +} diff --git a/webapp/public/favicon.png b/webapp/public/favicon.png new file mode 100644 index 0000000..652908f Binary files /dev/null and b/webapp/public/favicon.png differ diff --git a/webapp/src/App.vue b/webapp/src/App.vue new file mode 100644 index 0000000..22fdcdf --- /dev/null +++ b/webapp/src/App.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/webapp/src/assets/vue.svg b/webapp/src/assets/vue.svg new file mode 100644 index 0000000..770e9d3 --- /dev/null +++ b/webapp/src/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/src/components/ui/AdminNavBar.vue b/webapp/src/components/ui/AdminNavBar.vue new file mode 100644 index 0000000..6a646f9 --- /dev/null +++ b/webapp/src/components/ui/AdminNavBar.vue @@ -0,0 +1,216 @@ + + + \ No newline at end of file diff --git a/webapp/src/components/ui/LanguageSwitcher.vue b/webapp/src/components/ui/LanguageSwitcher.vue new file mode 100644 index 0000000..bd8977b --- /dev/null +++ b/webapp/src/components/ui/LanguageSwitcher.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/webapp/src/components/ui/NavBar.vue b/webapp/src/components/ui/NavBar.vue new file mode 100644 index 0000000..86031eb --- /dev/null +++ b/webapp/src/components/ui/NavBar.vue @@ -0,0 +1,167 @@ + + + \ No newline at end of file diff --git a/webapp/src/components/ui/ThemeSwitcher.vue b/webapp/src/components/ui/ThemeSwitcher.vue new file mode 100644 index 0000000..0b5ccc1 --- /dev/null +++ b/webapp/src/components/ui/ThemeSwitcher.vue @@ -0,0 +1,171 @@ + + + diff --git a/webapp/src/components/ui/ThemeToggle.vue b/webapp/src/components/ui/ThemeToggle.vue new file mode 100644 index 0000000..c34bbb5 --- /dev/null +++ b/webapp/src/components/ui/ThemeToggle.vue @@ -0,0 +1,44 @@ + + + diff --git a/webapp/src/components/ui/alert-dialog/AlertDialog.vue b/webapp/src/components/ui/alert-dialog/AlertDialog.vue new file mode 100644 index 0000000..b6e6b4b --- /dev/null +++ b/webapp/src/components/ui/alert-dialog/AlertDialog.vue @@ -0,0 +1,15 @@ + + + diff --git a/webapp/src/components/ui/alert-dialog/AlertDialogAction.vue b/webapp/src/components/ui/alert-dialog/AlertDialogAction.vue new file mode 100644 index 0000000..09cf6fc --- /dev/null +++ b/webapp/src/components/ui/alert-dialog/AlertDialogAction.vue @@ -0,0 +1,18 @@ + + + diff --git a/webapp/src/components/ui/alert-dialog/AlertDialogCancel.vue b/webapp/src/components/ui/alert-dialog/AlertDialogCancel.vue new file mode 100644 index 0000000..e261894 --- /dev/null +++ b/webapp/src/components/ui/alert-dialog/AlertDialogCancel.vue @@ -0,0 +1,25 @@ + + + diff --git a/webapp/src/components/ui/alert-dialog/AlertDialogContent.vue b/webapp/src/components/ui/alert-dialog/AlertDialogContent.vue new file mode 100644 index 0000000..4597f0d --- /dev/null +++ b/webapp/src/components/ui/alert-dialog/AlertDialogContent.vue @@ -0,0 +1,44 @@ + + + diff --git a/webapp/src/components/ui/alert-dialog/AlertDialogDescription.vue b/webapp/src/components/ui/alert-dialog/AlertDialogDescription.vue new file mode 100644 index 0000000..69642c9 --- /dev/null +++ b/webapp/src/components/ui/alert-dialog/AlertDialogDescription.vue @@ -0,0 +1,23 @@ + + + diff --git a/webapp/src/components/ui/alert-dialog/AlertDialogFooter.vue b/webapp/src/components/ui/alert-dialog/AlertDialogFooter.vue new file mode 100644 index 0000000..50d4098 --- /dev/null +++ b/webapp/src/components/ui/alert-dialog/AlertDialogFooter.vue @@ -0,0 +1,22 @@ + + + diff --git a/webapp/src/components/ui/alert-dialog/AlertDialogHeader.vue b/webapp/src/components/ui/alert-dialog/AlertDialogHeader.vue new file mode 100644 index 0000000..dbe72a7 --- /dev/null +++ b/webapp/src/components/ui/alert-dialog/AlertDialogHeader.vue @@ -0,0 +1,17 @@ + + + diff --git a/webapp/src/components/ui/alert-dialog/AlertDialogTitle.vue b/webapp/src/components/ui/alert-dialog/AlertDialogTitle.vue new file mode 100644 index 0000000..bb97e4d --- /dev/null +++ b/webapp/src/components/ui/alert-dialog/AlertDialogTitle.vue @@ -0,0 +1,21 @@ + + + diff --git a/webapp/src/components/ui/alert-dialog/AlertDialogTrigger.vue b/webapp/src/components/ui/alert-dialog/AlertDialogTrigger.vue new file mode 100644 index 0000000..98d40ee --- /dev/null +++ b/webapp/src/components/ui/alert-dialog/AlertDialogTrigger.vue @@ -0,0 +1,12 @@ + + + diff --git a/webapp/src/components/ui/alert-dialog/index.ts b/webapp/src/components/ui/alert-dialog/index.ts new file mode 100644 index 0000000..cf1b45d --- /dev/null +++ b/webapp/src/components/ui/alert-dialog/index.ts @@ -0,0 +1,9 @@ +export { default as AlertDialog } from "./AlertDialog.vue" +export { default as AlertDialogAction } from "./AlertDialogAction.vue" +export { default as AlertDialogCancel } from "./AlertDialogCancel.vue" +export { default as AlertDialogContent } from "./AlertDialogContent.vue" +export { default as AlertDialogDescription } from "./AlertDialogDescription.vue" +export { default as AlertDialogFooter } from "./AlertDialogFooter.vue" +export { default as AlertDialogHeader } from "./AlertDialogHeader.vue" +export { default as AlertDialogTitle } from "./AlertDialogTitle.vue" +export { default as AlertDialogTrigger } from "./AlertDialogTrigger.vue" diff --git a/webapp/src/components/ui/avatar/Avatar.vue b/webapp/src/components/ui/avatar/Avatar.vue new file mode 100644 index 0000000..bb7e669 --- /dev/null +++ b/webapp/src/components/ui/avatar/Avatar.vue @@ -0,0 +1,18 @@ + + + diff --git a/webapp/src/components/ui/avatar/AvatarFallback.vue b/webapp/src/components/ui/avatar/AvatarFallback.vue new file mode 100644 index 0000000..16b588a --- /dev/null +++ b/webapp/src/components/ui/avatar/AvatarFallback.vue @@ -0,0 +1,21 @@ + + + diff --git a/webapp/src/components/ui/avatar/AvatarImage.vue b/webapp/src/components/ui/avatar/AvatarImage.vue new file mode 100644 index 0000000..24a8166 --- /dev/null +++ b/webapp/src/components/ui/avatar/AvatarImage.vue @@ -0,0 +1,16 @@ + + + diff --git a/webapp/src/components/ui/avatar/index.ts b/webapp/src/components/ui/avatar/index.ts new file mode 100644 index 0000000..cf0e003 --- /dev/null +++ b/webapp/src/components/ui/avatar/index.ts @@ -0,0 +1,3 @@ +export { default as Avatar } from "./Avatar.vue" +export { default as AvatarFallback } from "./AvatarFallback.vue" +export { default as AvatarImage } from "./AvatarImage.vue" diff --git a/webapp/src/components/ui/badge/Badge.vue b/webapp/src/components/ui/badge/Badge.vue new file mode 100644 index 0000000..d894dfe --- /dev/null +++ b/webapp/src/components/ui/badge/Badge.vue @@ -0,0 +1,26 @@ + + + diff --git a/webapp/src/components/ui/badge/index.ts b/webapp/src/components/ui/badge/index.ts new file mode 100644 index 0000000..bbc0dfa --- /dev/null +++ b/webapp/src/components/ui/badge/index.ts @@ -0,0 +1,26 @@ +import type { VariantProps } from "class-variance-authority" +import { cva } from "class-variance-authority" + +export { default as Badge } from "./Badge.vue" + +export const badgeVariants = cva( + "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +) +export type BadgeVariants = VariantProps diff --git a/webapp/src/components/ui/button/Button.vue b/webapp/src/components/ui/button/Button.vue new file mode 100644 index 0000000..374320b --- /dev/null +++ b/webapp/src/components/ui/button/Button.vue @@ -0,0 +1,29 @@ + + + diff --git a/webapp/src/components/ui/button/index.ts b/webapp/src/components/ui/button/index.ts new file mode 100644 index 0000000..26e2c55 --- /dev/null +++ b/webapp/src/components/ui/button/index.ts @@ -0,0 +1,38 @@ +import type { VariantProps } from "class-variance-authority" +import { cva } from "class-variance-authority" + +export { default as Button } from "./Button.vue" + +export const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + "default": "h-9 px-4 py-2 has-[>svg]:px-3", + "sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + "lg": "h-10 rounded-md px-6 has-[>svg]:px-4", + "icon": "size-9", + "icon-sm": "size-8", + "icon-lg": "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +) +export type ButtonVariants = VariantProps diff --git a/webapp/src/components/ui/calendar/Calendar.vue b/webapp/src/components/ui/calendar/Calendar.vue new file mode 100644 index 0000000..3054f73 --- /dev/null +++ b/webapp/src/components/ui/calendar/Calendar.vue @@ -0,0 +1,160 @@ + + + diff --git a/webapp/src/components/ui/calendar/CalendarCell.vue b/webapp/src/components/ui/calendar/CalendarCell.vue new file mode 100644 index 0000000..15b8028 --- /dev/null +++ b/webapp/src/components/ui/calendar/CalendarCell.vue @@ -0,0 +1,23 @@ + + + diff --git a/webapp/src/components/ui/calendar/CalendarCellTrigger.vue b/webapp/src/components/ui/calendar/CalendarCellTrigger.vue new file mode 100644 index 0000000..1107fc6 --- /dev/null +++ b/webapp/src/components/ui/calendar/CalendarCellTrigger.vue @@ -0,0 +1,39 @@ + + + diff --git a/webapp/src/components/ui/calendar/CalendarGrid.vue b/webapp/src/components/ui/calendar/CalendarGrid.vue new file mode 100644 index 0000000..e6dd7d6 --- /dev/null +++ b/webapp/src/components/ui/calendar/CalendarGrid.vue @@ -0,0 +1,23 @@ + + + diff --git a/webapp/src/components/ui/calendar/CalendarGridBody.vue b/webapp/src/components/ui/calendar/CalendarGridBody.vue new file mode 100644 index 0000000..3b9e716 --- /dev/null +++ b/webapp/src/components/ui/calendar/CalendarGridBody.vue @@ -0,0 +1,15 @@ + + + diff --git a/webapp/src/components/ui/calendar/CalendarGridHead.vue b/webapp/src/components/ui/calendar/CalendarGridHead.vue new file mode 100644 index 0000000..de1589b --- /dev/null +++ b/webapp/src/components/ui/calendar/CalendarGridHead.vue @@ -0,0 +1,16 @@ + + + diff --git a/webapp/src/components/ui/calendar/CalendarGridRow.vue b/webapp/src/components/ui/calendar/CalendarGridRow.vue new file mode 100644 index 0000000..767557d --- /dev/null +++ b/webapp/src/components/ui/calendar/CalendarGridRow.vue @@ -0,0 +1,22 @@ + + + diff --git a/webapp/src/components/ui/calendar/CalendarHeadCell.vue b/webapp/src/components/ui/calendar/CalendarHeadCell.vue new file mode 100644 index 0000000..47fefbc --- /dev/null +++ b/webapp/src/components/ui/calendar/CalendarHeadCell.vue @@ -0,0 +1,23 @@ + + + diff --git a/webapp/src/components/ui/calendar/CalendarHeader.vue b/webapp/src/components/ui/calendar/CalendarHeader.vue new file mode 100644 index 0000000..175118d --- /dev/null +++ b/webapp/src/components/ui/calendar/CalendarHeader.vue @@ -0,0 +1,23 @@ + + + diff --git a/webapp/src/components/ui/calendar/CalendarHeading.vue b/webapp/src/components/ui/calendar/CalendarHeading.vue new file mode 100644 index 0000000..5a11c12 --- /dev/null +++ b/webapp/src/components/ui/calendar/CalendarHeading.vue @@ -0,0 +1,30 @@ + + + diff --git a/webapp/src/components/ui/calendar/CalendarNextButton.vue b/webapp/src/components/ui/calendar/CalendarNextButton.vue new file mode 100644 index 0000000..bd1efce --- /dev/null +++ b/webapp/src/components/ui/calendar/CalendarNextButton.vue @@ -0,0 +1,31 @@ + + + diff --git a/webapp/src/components/ui/calendar/CalendarPrevButton.vue b/webapp/src/components/ui/calendar/CalendarPrevButton.vue new file mode 100644 index 0000000..80bdcec --- /dev/null +++ b/webapp/src/components/ui/calendar/CalendarPrevButton.vue @@ -0,0 +1,31 @@ + + + diff --git a/webapp/src/components/ui/calendar/index.ts b/webapp/src/components/ui/calendar/index.ts new file mode 100644 index 0000000..552c634 --- /dev/null +++ b/webapp/src/components/ui/calendar/index.ts @@ -0,0 +1,14 @@ +export { default as Calendar } from "./Calendar.vue" +export { default as CalendarCell } from "./CalendarCell.vue" +export { default as CalendarCellTrigger } from "./CalendarCellTrigger.vue" +export { default as CalendarGrid } from "./CalendarGrid.vue" +export { default as CalendarGridBody } from "./CalendarGridBody.vue" +export { default as CalendarGridHead } from "./CalendarGridHead.vue" +export { default as CalendarGridRow } from "./CalendarGridRow.vue" +export { default as CalendarHeadCell } from "./CalendarHeadCell.vue" +export { default as CalendarHeader } from "./CalendarHeader.vue" +export { default as CalendarHeading } from "./CalendarHeading.vue" +export { default as CalendarNextButton } from "./CalendarNextButton.vue" +export { default as CalendarPrevButton } from "./CalendarPrevButton.vue" + +export type LayoutTypes = "month-and-year" | "month-only" | "year-only" | undefined diff --git a/webapp/src/components/ui/card/Card.vue b/webapp/src/components/ui/card/Card.vue new file mode 100644 index 0000000..f5a0707 --- /dev/null +++ b/webapp/src/components/ui/card/Card.vue @@ -0,0 +1,22 @@ + + + diff --git a/webapp/src/components/ui/card/CardAction.vue b/webapp/src/components/ui/card/CardAction.vue new file mode 100644 index 0000000..c91638b --- /dev/null +++ b/webapp/src/components/ui/card/CardAction.vue @@ -0,0 +1,17 @@ + + + diff --git a/webapp/src/components/ui/card/CardContent.vue b/webapp/src/components/ui/card/CardContent.vue new file mode 100644 index 0000000..dfbc552 --- /dev/null +++ b/webapp/src/components/ui/card/CardContent.vue @@ -0,0 +1,17 @@ + + + diff --git a/webapp/src/components/ui/card/CardDescription.vue b/webapp/src/components/ui/card/CardDescription.vue new file mode 100644 index 0000000..71c1b8d --- /dev/null +++ b/webapp/src/components/ui/card/CardDescription.vue @@ -0,0 +1,17 @@ + + + diff --git a/webapp/src/components/ui/card/CardFooter.vue b/webapp/src/components/ui/card/CardFooter.vue new file mode 100644 index 0000000..9e3739e --- /dev/null +++ b/webapp/src/components/ui/card/CardFooter.vue @@ -0,0 +1,17 @@ + + + diff --git a/webapp/src/components/ui/card/CardHeader.vue b/webapp/src/components/ui/card/CardHeader.vue new file mode 100644 index 0000000..4fe4da4 --- /dev/null +++ b/webapp/src/components/ui/card/CardHeader.vue @@ -0,0 +1,17 @@ + + + diff --git a/webapp/src/components/ui/card/CardTitle.vue b/webapp/src/components/ui/card/CardTitle.vue new file mode 100644 index 0000000..5f479e7 --- /dev/null +++ b/webapp/src/components/ui/card/CardTitle.vue @@ -0,0 +1,17 @@ + + + diff --git a/webapp/src/components/ui/card/index.ts b/webapp/src/components/ui/card/index.ts new file mode 100644 index 0000000..1627758 --- /dev/null +++ b/webapp/src/components/ui/card/index.ts @@ -0,0 +1,7 @@ +export { default as Card } from "./Card.vue" +export { default as CardAction } from "./CardAction.vue" +export { default as CardContent } from "./CardContent.vue" +export { default as CardDescription } from "./CardDescription.vue" +export { default as CardFooter } from "./CardFooter.vue" +export { default as CardHeader } from "./CardHeader.vue" +export { default as CardTitle } from "./CardTitle.vue" diff --git a/webapp/src/components/ui/checkbox/Checkbox.vue b/webapp/src/components/ui/checkbox/Checkbox.vue new file mode 100644 index 0000000..6604cbd --- /dev/null +++ b/webapp/src/components/ui/checkbox/Checkbox.vue @@ -0,0 +1,35 @@ + + + diff --git a/webapp/src/components/ui/checkbox/index.ts b/webapp/src/components/ui/checkbox/index.ts new file mode 100644 index 0000000..3391a85 --- /dev/null +++ b/webapp/src/components/ui/checkbox/index.ts @@ -0,0 +1 @@ +export { default as Checkbox } from "./Checkbox.vue" diff --git a/webapp/src/components/ui/command/Command.vue b/webapp/src/components/ui/command/Command.vue new file mode 100644 index 0000000..dcdf9d6 --- /dev/null +++ b/webapp/src/components/ui/command/Command.vue @@ -0,0 +1,87 @@ + + + diff --git a/webapp/src/components/ui/command/CommandDialog.vue b/webapp/src/components/ui/command/CommandDialog.vue new file mode 100644 index 0000000..7439736 --- /dev/null +++ b/webapp/src/components/ui/command/CommandDialog.vue @@ -0,0 +1,31 @@ + + + diff --git a/webapp/src/components/ui/command/CommandEmpty.vue b/webapp/src/components/ui/command/CommandEmpty.vue new file mode 100644 index 0000000..489c406 --- /dev/null +++ b/webapp/src/components/ui/command/CommandEmpty.vue @@ -0,0 +1,27 @@ + + + diff --git a/webapp/src/components/ui/command/CommandGroup.vue b/webapp/src/components/ui/command/CommandGroup.vue new file mode 100644 index 0000000..a5dd55e --- /dev/null +++ b/webapp/src/components/ui/command/CommandGroup.vue @@ -0,0 +1,45 @@ + + + diff --git a/webapp/src/components/ui/command/CommandInput.vue b/webapp/src/components/ui/command/CommandInput.vue new file mode 100644 index 0000000..653141e --- /dev/null +++ b/webapp/src/components/ui/command/CommandInput.vue @@ -0,0 +1,39 @@ + + + diff --git a/webapp/src/components/ui/command/CommandItem.vue b/webapp/src/components/ui/command/CommandItem.vue new file mode 100644 index 0000000..2ae4827 --- /dev/null +++ b/webapp/src/components/ui/command/CommandItem.vue @@ -0,0 +1,76 @@ + + + diff --git a/webapp/src/components/ui/command/CommandList.vue b/webapp/src/components/ui/command/CommandList.vue new file mode 100644 index 0000000..928d2f0 --- /dev/null +++ b/webapp/src/components/ui/command/CommandList.vue @@ -0,0 +1,25 @@ + + + diff --git a/webapp/src/components/ui/command/CommandSeparator.vue b/webapp/src/components/ui/command/CommandSeparator.vue new file mode 100644 index 0000000..6def19e --- /dev/null +++ b/webapp/src/components/ui/command/CommandSeparator.vue @@ -0,0 +1,21 @@ + + + diff --git a/webapp/src/components/ui/command/CommandShortcut.vue b/webapp/src/components/ui/command/CommandShortcut.vue new file mode 100644 index 0000000..e1d0e07 --- /dev/null +++ b/webapp/src/components/ui/command/CommandShortcut.vue @@ -0,0 +1,17 @@ + + + diff --git a/webapp/src/components/ui/command/index.ts b/webapp/src/components/ui/command/index.ts new file mode 100644 index 0000000..af18933 --- /dev/null +++ b/webapp/src/components/ui/command/index.ts @@ -0,0 +1,25 @@ +import type { Ref } from "vue" +import { createContext } from "reka-ui" + +export { default as Command } from "./Command.vue" +export { default as CommandDialog } from "./CommandDialog.vue" +export { default as CommandEmpty } from "./CommandEmpty.vue" +export { default as CommandGroup } from "./CommandGroup.vue" +export { default as CommandInput } from "./CommandInput.vue" +export { default as CommandItem } from "./CommandItem.vue" +export { default as CommandList } from "./CommandList.vue" +export { default as CommandSeparator } from "./CommandSeparator.vue" +export { default as CommandShortcut } from "./CommandShortcut.vue" + +export const [useCommand, provideCommandContext] = createContext<{ + allItems: Ref> + allGroups: Ref>> + filterState: { + search: string + filtered: { count: number, items: Map, groups: Set } + } +}>("Command") + +export const [useCommandGroup, provideCommandGroupContext] = createContext<{ + id?: string +}>("CommandGroup") diff --git a/webapp/src/components/ui/dialog/Dialog.vue b/webapp/src/components/ui/dialog/Dialog.vue new file mode 100644 index 0000000..ade5260 --- /dev/null +++ b/webapp/src/components/ui/dialog/Dialog.vue @@ -0,0 +1,19 @@ + + + diff --git a/webapp/src/components/ui/dialog/DialogClose.vue b/webapp/src/components/ui/dialog/DialogClose.vue new file mode 100644 index 0000000..c5fae04 --- /dev/null +++ b/webapp/src/components/ui/dialog/DialogClose.vue @@ -0,0 +1,15 @@ + + + diff --git a/webapp/src/components/ui/dialog/DialogContent.vue b/webapp/src/components/ui/dialog/DialogContent.vue new file mode 100644 index 0000000..7f86b47 --- /dev/null +++ b/webapp/src/components/ui/dialog/DialogContent.vue @@ -0,0 +1,53 @@ + + + diff --git a/webapp/src/components/ui/dialog/DialogDescription.vue b/webapp/src/components/ui/dialog/DialogDescription.vue new file mode 100644 index 0000000..f52e655 --- /dev/null +++ b/webapp/src/components/ui/dialog/DialogDescription.vue @@ -0,0 +1,23 @@ + + + diff --git a/webapp/src/components/ui/dialog/DialogFooter.vue b/webapp/src/components/ui/dialog/DialogFooter.vue new file mode 100644 index 0000000..0a936e6 --- /dev/null +++ b/webapp/src/components/ui/dialog/DialogFooter.vue @@ -0,0 +1,15 @@ + + + diff --git a/webapp/src/components/ui/dialog/DialogHeader.vue b/webapp/src/components/ui/dialog/DialogHeader.vue new file mode 100644 index 0000000..bfc3c64 --- /dev/null +++ b/webapp/src/components/ui/dialog/DialogHeader.vue @@ -0,0 +1,17 @@ + + + diff --git a/webapp/src/components/ui/dialog/DialogOverlay.vue b/webapp/src/components/ui/dialog/DialogOverlay.vue new file mode 100644 index 0000000..7e620f9 --- /dev/null +++ b/webapp/src/components/ui/dialog/DialogOverlay.vue @@ -0,0 +1,21 @@ + + + diff --git a/webapp/src/components/ui/dialog/DialogScrollContent.vue b/webapp/src/components/ui/dialog/DialogScrollContent.vue new file mode 100644 index 0000000..9c652d3 --- /dev/null +++ b/webapp/src/components/ui/dialog/DialogScrollContent.vue @@ -0,0 +1,59 @@ + + + diff --git a/webapp/src/components/ui/dialog/DialogTitle.vue b/webapp/src/components/ui/dialog/DialogTitle.vue new file mode 100644 index 0000000..860f01a --- /dev/null +++ b/webapp/src/components/ui/dialog/DialogTitle.vue @@ -0,0 +1,23 @@ + + + diff --git a/webapp/src/components/ui/dialog/DialogTrigger.vue b/webapp/src/components/ui/dialog/DialogTrigger.vue new file mode 100644 index 0000000..49667e9 --- /dev/null +++ b/webapp/src/components/ui/dialog/DialogTrigger.vue @@ -0,0 +1,15 @@ + + + diff --git a/webapp/src/components/ui/dialog/index.ts b/webapp/src/components/ui/dialog/index.ts new file mode 100644 index 0000000..6768b09 --- /dev/null +++ b/webapp/src/components/ui/dialog/index.ts @@ -0,0 +1,10 @@ +export { default as Dialog } from "./Dialog.vue" +export { default as DialogClose } from "./DialogClose.vue" +export { default as DialogContent } from "./DialogContent.vue" +export { default as DialogDescription } from "./DialogDescription.vue" +export { default as DialogFooter } from "./DialogFooter.vue" +export { default as DialogHeader } from "./DialogHeader.vue" +export { default as DialogOverlay } from "./DialogOverlay.vue" +export { default as DialogScrollContent } from "./DialogScrollContent.vue" +export { default as DialogTitle } from "./DialogTitle.vue" +export { default as DialogTrigger } from "./DialogTrigger.vue" diff --git a/webapp/src/components/ui/input-otp/InputOTP.vue b/webapp/src/components/ui/input-otp/InputOTP.vue new file mode 100644 index 0000000..f34b734 --- /dev/null +++ b/webapp/src/components/ui/input-otp/InputOTP.vue @@ -0,0 +1,28 @@ + + + diff --git a/webapp/src/components/ui/input-otp/InputOTPGroup.vue b/webapp/src/components/ui/input-otp/InputOTPGroup.vue new file mode 100644 index 0000000..1dea2ab --- /dev/null +++ b/webapp/src/components/ui/input-otp/InputOTPGroup.vue @@ -0,0 +1,22 @@ + + + diff --git a/webapp/src/components/ui/input-otp/InputOTPSeparator.vue b/webapp/src/components/ui/input-otp/InputOTPSeparator.vue new file mode 100644 index 0000000..7675e25 --- /dev/null +++ b/webapp/src/components/ui/input-otp/InputOTPSeparator.vue @@ -0,0 +1,21 @@ + + + diff --git a/webapp/src/components/ui/input-otp/InputOTPSlot.vue b/webapp/src/components/ui/input-otp/InputOTPSlot.vue new file mode 100644 index 0000000..a165799 --- /dev/null +++ b/webapp/src/components/ui/input-otp/InputOTPSlot.vue @@ -0,0 +1,32 @@ + + + diff --git a/webapp/src/components/ui/input-otp/index.ts b/webapp/src/components/ui/input-otp/index.ts new file mode 100644 index 0000000..d97448a --- /dev/null +++ b/webapp/src/components/ui/input-otp/index.ts @@ -0,0 +1,4 @@ +export { default as InputOTP } from "./InputOTP.vue" +export { default as InputOTPGroup } from "./InputOTPGroup.vue" +export { default as InputOTPSeparator } from "./InputOTPSeparator.vue" +export { default as InputOTPSlot } from "./InputOTPSlot.vue" diff --git a/webapp/src/components/ui/input/Input.vue b/webapp/src/components/ui/input/Input.vue new file mode 100644 index 0000000..a28da97 --- /dev/null +++ b/webapp/src/components/ui/input/Input.vue @@ -0,0 +1,33 @@ + + + diff --git a/webapp/src/components/ui/input/index.ts b/webapp/src/components/ui/input/index.ts new file mode 100644 index 0000000..9976b86 --- /dev/null +++ b/webapp/src/components/ui/input/index.ts @@ -0,0 +1 @@ +export { default as Input } from "./Input.vue" diff --git a/webapp/src/components/ui/label/Label.vue b/webapp/src/components/ui/label/Label.vue new file mode 100644 index 0000000..ee63970 --- /dev/null +++ b/webapp/src/components/ui/label/Label.vue @@ -0,0 +1,26 @@ + + + diff --git a/webapp/src/components/ui/label/index.ts b/webapp/src/components/ui/label/index.ts new file mode 100644 index 0000000..036e35c --- /dev/null +++ b/webapp/src/components/ui/label/index.ts @@ -0,0 +1 @@ +export { default as Label } from "./Label.vue" diff --git a/webapp/src/components/ui/native-select/NativeSelect.vue b/webapp/src/components/ui/native-select/NativeSelect.vue new file mode 100644 index 0000000..3171676 --- /dev/null +++ b/webapp/src/components/ui/native-select/NativeSelect.vue @@ -0,0 +1,50 @@ + + + diff --git a/webapp/src/components/ui/native-select/NativeSelectOptGroup.vue b/webapp/src/components/ui/native-select/NativeSelectOptGroup.vue new file mode 100644 index 0000000..7fc2df7 --- /dev/null +++ b/webapp/src/components/ui/native-select/NativeSelectOptGroup.vue @@ -0,0 +1,15 @@ + + + + + + diff --git a/webapp/src/components/ui/native-select/NativeSelectOption.vue b/webapp/src/components/ui/native-select/NativeSelectOption.vue new file mode 100644 index 0000000..6abe33b --- /dev/null +++ b/webapp/src/components/ui/native-select/NativeSelectOption.vue @@ -0,0 +1,15 @@ + + + + + + diff --git a/webapp/src/components/ui/native-select/index.ts b/webapp/src/components/ui/native-select/index.ts new file mode 100644 index 0000000..04e92c7 --- /dev/null +++ b/webapp/src/components/ui/native-select/index.ts @@ -0,0 +1,3 @@ +export { default as NativeSelect } from "./NativeSelect.vue" +export { default as NativeSelectOptGroup } from "./NativeSelectOptGroup.vue" +export { default as NativeSelectOption } from "./NativeSelectOption.vue" diff --git a/webapp/src/components/ui/popover/Popover.vue b/webapp/src/components/ui/popover/Popover.vue new file mode 100644 index 0000000..4efdb98 --- /dev/null +++ b/webapp/src/components/ui/popover/Popover.vue @@ -0,0 +1,19 @@ + + + diff --git a/webapp/src/components/ui/popover/PopoverAnchor.vue b/webapp/src/components/ui/popover/PopoverAnchor.vue new file mode 100644 index 0000000..49e01db --- /dev/null +++ b/webapp/src/components/ui/popover/PopoverAnchor.vue @@ -0,0 +1,15 @@ + + + diff --git a/webapp/src/components/ui/popover/PopoverContent.vue b/webapp/src/components/ui/popover/PopoverContent.vue new file mode 100644 index 0000000..cf1e55c --- /dev/null +++ b/webapp/src/components/ui/popover/PopoverContent.vue @@ -0,0 +1,45 @@ + + + diff --git a/webapp/src/components/ui/popover/PopoverTrigger.vue b/webapp/src/components/ui/popover/PopoverTrigger.vue new file mode 100644 index 0000000..fd3b497 --- /dev/null +++ b/webapp/src/components/ui/popover/PopoverTrigger.vue @@ -0,0 +1,15 @@ + + + diff --git a/webapp/src/components/ui/popover/index.ts b/webapp/src/components/ui/popover/index.ts new file mode 100644 index 0000000..66edf89 --- /dev/null +++ b/webapp/src/components/ui/popover/index.ts @@ -0,0 +1,4 @@ +export { default as Popover } from "./Popover.vue" +export { default as PopoverAnchor } from "./PopoverAnchor.vue" +export { default as PopoverContent } from "./PopoverContent.vue" +export { default as PopoverTrigger } from "./PopoverTrigger.vue" diff --git a/webapp/src/components/ui/progress/Progress.vue b/webapp/src/components/ui/progress/Progress.vue new file mode 100644 index 0000000..5532af7 --- /dev/null +++ b/webapp/src/components/ui/progress/Progress.vue @@ -0,0 +1,38 @@ + + + diff --git a/webapp/src/components/ui/progress/index.ts b/webapp/src/components/ui/progress/index.ts new file mode 100644 index 0000000..8598b96 --- /dev/null +++ b/webapp/src/components/ui/progress/index.ts @@ -0,0 +1 @@ +export { default as Progress } from "./Progress.vue" diff --git a/webapp/src/components/ui/radio-group/RadioGroup.vue b/webapp/src/components/ui/radio-group/RadioGroup.vue new file mode 100644 index 0000000..54045c4 --- /dev/null +++ b/webapp/src/components/ui/radio-group/RadioGroup.vue @@ -0,0 +1,25 @@ + + + diff --git a/webapp/src/components/ui/radio-group/RadioGroupItem.vue b/webapp/src/components/ui/radio-group/RadioGroupItem.vue new file mode 100644 index 0000000..2488c9d --- /dev/null +++ b/webapp/src/components/ui/radio-group/RadioGroupItem.vue @@ -0,0 +1,40 @@ + + + diff --git a/webapp/src/components/ui/radio-group/index.ts b/webapp/src/components/ui/radio-group/index.ts new file mode 100644 index 0000000..7cb0e3b --- /dev/null +++ b/webapp/src/components/ui/radio-group/index.ts @@ -0,0 +1,2 @@ +export { default as RadioGroup } from "./RadioGroup.vue" +export { default as RadioGroupItem } from "./RadioGroupItem.vue" diff --git a/webapp/src/components/ui/select/Select.vue b/webapp/src/components/ui/select/Select.vue new file mode 100644 index 0000000..c94bbe8 --- /dev/null +++ b/webapp/src/components/ui/select/Select.vue @@ -0,0 +1,19 @@ + + + diff --git a/webapp/src/components/ui/select/SelectContent.vue b/webapp/src/components/ui/select/SelectContent.vue new file mode 100644 index 0000000..adf04ec --- /dev/null +++ b/webapp/src/components/ui/select/SelectContent.vue @@ -0,0 +1,51 @@ + + + diff --git a/webapp/src/components/ui/select/SelectGroup.vue b/webapp/src/components/ui/select/SelectGroup.vue new file mode 100644 index 0000000..e981c6c --- /dev/null +++ b/webapp/src/components/ui/select/SelectGroup.vue @@ -0,0 +1,15 @@ + + + diff --git a/webapp/src/components/ui/select/SelectItem.vue b/webapp/src/components/ui/select/SelectItem.vue new file mode 100644 index 0000000..9371764 --- /dev/null +++ b/webapp/src/components/ui/select/SelectItem.vue @@ -0,0 +1,44 @@ + + + diff --git a/webapp/src/components/ui/select/SelectItemText.vue b/webapp/src/components/ui/select/SelectItemText.vue new file mode 100644 index 0000000..b6700b1 --- /dev/null +++ b/webapp/src/components/ui/select/SelectItemText.vue @@ -0,0 +1,15 @@ + + + diff --git a/webapp/src/components/ui/select/SelectLabel.vue b/webapp/src/components/ui/select/SelectLabel.vue new file mode 100644 index 0000000..5b6650c --- /dev/null +++ b/webapp/src/components/ui/select/SelectLabel.vue @@ -0,0 +1,17 @@ + + + diff --git a/webapp/src/components/ui/select/SelectScrollDownButton.vue b/webapp/src/components/ui/select/SelectScrollDownButton.vue new file mode 100644 index 0000000..7dc7670 --- /dev/null +++ b/webapp/src/components/ui/select/SelectScrollDownButton.vue @@ -0,0 +1,26 @@ + + + diff --git a/webapp/src/components/ui/select/SelectScrollUpButton.vue b/webapp/src/components/ui/select/SelectScrollUpButton.vue new file mode 100644 index 0000000..07fe87e --- /dev/null +++ b/webapp/src/components/ui/select/SelectScrollUpButton.vue @@ -0,0 +1,26 @@ + + + diff --git a/webapp/src/components/ui/select/SelectSeparator.vue b/webapp/src/components/ui/select/SelectSeparator.vue new file mode 100644 index 0000000..4b5c885 --- /dev/null +++ b/webapp/src/components/ui/select/SelectSeparator.vue @@ -0,0 +1,19 @@ + + + diff --git a/webapp/src/components/ui/select/SelectTrigger.vue b/webapp/src/components/ui/select/SelectTrigger.vue new file mode 100644 index 0000000..667908b --- /dev/null +++ b/webapp/src/components/ui/select/SelectTrigger.vue @@ -0,0 +1,33 @@ + + + diff --git a/webapp/src/components/ui/select/SelectValue.vue b/webapp/src/components/ui/select/SelectValue.vue new file mode 100644 index 0000000..d5ce58b --- /dev/null +++ b/webapp/src/components/ui/select/SelectValue.vue @@ -0,0 +1,15 @@ + + + diff --git a/webapp/src/components/ui/select/index.ts b/webapp/src/components/ui/select/index.ts new file mode 100644 index 0000000..96eae60 --- /dev/null +++ b/webapp/src/components/ui/select/index.ts @@ -0,0 +1,11 @@ +export { default as Select } from "./Select.vue" +export { default as SelectContent } from "./SelectContent.vue" +export { default as SelectGroup } from "./SelectGroup.vue" +export { default as SelectItem } from "./SelectItem.vue" +export { default as SelectItemText } from "./SelectItemText.vue" +export { default as SelectLabel } from "./SelectLabel.vue" +export { default as SelectScrollDownButton } from "./SelectScrollDownButton.vue" +export { default as SelectScrollUpButton } from "./SelectScrollUpButton.vue" +export { default as SelectSeparator } from "./SelectSeparator.vue" +export { default as SelectTrigger } from "./SelectTrigger.vue" +export { default as SelectValue } from "./SelectValue.vue" diff --git a/webapp/src/components/ui/separator/Separator.vue b/webapp/src/components/ui/separator/Separator.vue new file mode 100644 index 0000000..78d60ec --- /dev/null +++ b/webapp/src/components/ui/separator/Separator.vue @@ -0,0 +1,29 @@ + + + diff --git a/webapp/src/components/ui/separator/index.ts b/webapp/src/components/ui/separator/index.ts new file mode 100644 index 0000000..4407287 --- /dev/null +++ b/webapp/src/components/ui/separator/index.ts @@ -0,0 +1 @@ +export { default as Separator } from "./Separator.vue" diff --git a/webapp/src/components/ui/sheet/Sheet.vue b/webapp/src/components/ui/sheet/Sheet.vue new file mode 100644 index 0000000..8522f84 --- /dev/null +++ b/webapp/src/components/ui/sheet/Sheet.vue @@ -0,0 +1,19 @@ + + + diff --git a/webapp/src/components/ui/sheet/SheetClose.vue b/webapp/src/components/ui/sheet/SheetClose.vue new file mode 100644 index 0000000..39a942c --- /dev/null +++ b/webapp/src/components/ui/sheet/SheetClose.vue @@ -0,0 +1,15 @@ + + + diff --git a/webapp/src/components/ui/sheet/SheetContent.vue b/webapp/src/components/ui/sheet/SheetContent.vue new file mode 100644 index 0000000..e0c4b8f --- /dev/null +++ b/webapp/src/components/ui/sheet/SheetContent.vue @@ -0,0 +1,62 @@ + + + diff --git a/webapp/src/components/ui/sheet/SheetDescription.vue b/webapp/src/components/ui/sheet/SheetDescription.vue new file mode 100644 index 0000000..6c8ba0a --- /dev/null +++ b/webapp/src/components/ui/sheet/SheetDescription.vue @@ -0,0 +1,21 @@ + + + diff --git a/webapp/src/components/ui/sheet/SheetFooter.vue b/webapp/src/components/ui/sheet/SheetFooter.vue new file mode 100644 index 0000000..5fcf751 --- /dev/null +++ b/webapp/src/components/ui/sheet/SheetFooter.vue @@ -0,0 +1,16 @@ + + + diff --git a/webapp/src/components/ui/sheet/SheetHeader.vue b/webapp/src/components/ui/sheet/SheetHeader.vue new file mode 100644 index 0000000..b6305ab --- /dev/null +++ b/webapp/src/components/ui/sheet/SheetHeader.vue @@ -0,0 +1,15 @@ + + + diff --git a/webapp/src/components/ui/sheet/SheetOverlay.vue b/webapp/src/components/ui/sheet/SheetOverlay.vue new file mode 100644 index 0000000..220452a --- /dev/null +++ b/webapp/src/components/ui/sheet/SheetOverlay.vue @@ -0,0 +1,21 @@ + + + diff --git a/webapp/src/components/ui/sheet/SheetTitle.vue b/webapp/src/components/ui/sheet/SheetTitle.vue new file mode 100644 index 0000000..889ae54 --- /dev/null +++ b/webapp/src/components/ui/sheet/SheetTitle.vue @@ -0,0 +1,21 @@ + + + diff --git a/webapp/src/components/ui/sheet/SheetTrigger.vue b/webapp/src/components/ui/sheet/SheetTrigger.vue new file mode 100644 index 0000000..41b121d --- /dev/null +++ b/webapp/src/components/ui/sheet/SheetTrigger.vue @@ -0,0 +1,15 @@ + + + diff --git a/webapp/src/components/ui/sheet/index.ts b/webapp/src/components/ui/sheet/index.ts new file mode 100644 index 0000000..7c70e5d --- /dev/null +++ b/webapp/src/components/ui/sheet/index.ts @@ -0,0 +1,8 @@ +export { default as Sheet } from "./Sheet.vue" +export { default as SheetClose } from "./SheetClose.vue" +export { default as SheetContent } from "./SheetContent.vue" +export { default as SheetDescription } from "./SheetDescription.vue" +export { default as SheetFooter } from "./SheetFooter.vue" +export { default as SheetHeader } from "./SheetHeader.vue" +export { default as SheetTitle } from "./SheetTitle.vue" +export { default as SheetTrigger } from "./SheetTrigger.vue" diff --git a/webapp/src/components/ui/skeleton/Skeleton.vue b/webapp/src/components/ui/skeleton/Skeleton.vue new file mode 100644 index 0000000..0dadcef --- /dev/null +++ b/webapp/src/components/ui/skeleton/Skeleton.vue @@ -0,0 +1,17 @@ + + + diff --git a/webapp/src/components/ui/skeleton/index.ts b/webapp/src/components/ui/skeleton/index.ts new file mode 100644 index 0000000..e5ce72c --- /dev/null +++ b/webapp/src/components/ui/skeleton/index.ts @@ -0,0 +1 @@ +export { default as Skeleton } from "./Skeleton.vue" diff --git a/webapp/src/components/ui/sonner/Sonner.vue b/webapp/src/components/ui/sonner/Sonner.vue new file mode 100644 index 0000000..6830896 --- /dev/null +++ b/webapp/src/components/ui/sonner/Sonner.vue @@ -0,0 +1,42 @@ + + + diff --git a/webapp/src/components/ui/sonner/index.ts b/webapp/src/components/ui/sonner/index.ts new file mode 100644 index 0000000..6673112 --- /dev/null +++ b/webapp/src/components/ui/sonner/index.ts @@ -0,0 +1 @@ +export { default as Toaster } from "./Sonner.vue" diff --git a/webapp/src/components/ui/switch/Switch.vue b/webapp/src/components/ui/switch/Switch.vue new file mode 100644 index 0000000..2e725ed --- /dev/null +++ b/webapp/src/components/ui/switch/Switch.vue @@ -0,0 +1,38 @@ + + + diff --git a/webapp/src/components/ui/switch/index.ts b/webapp/src/components/ui/switch/index.ts new file mode 100644 index 0000000..cc081f3 --- /dev/null +++ b/webapp/src/components/ui/switch/index.ts @@ -0,0 +1 @@ +export { default as Switch } from "./Switch.vue" diff --git a/webapp/src/components/ui/table/Table.vue b/webapp/src/components/ui/table/Table.vue new file mode 100644 index 0000000..0d0cd9b --- /dev/null +++ b/webapp/src/components/ui/table/Table.vue @@ -0,0 +1,16 @@ + + + diff --git a/webapp/src/components/ui/table/TableBody.vue b/webapp/src/components/ui/table/TableBody.vue new file mode 100644 index 0000000..d14a2d3 --- /dev/null +++ b/webapp/src/components/ui/table/TableBody.vue @@ -0,0 +1,17 @@ + + + diff --git a/webapp/src/components/ui/table/TableCaption.vue b/webapp/src/components/ui/table/TableCaption.vue new file mode 100644 index 0000000..3630084 --- /dev/null +++ b/webapp/src/components/ui/table/TableCaption.vue @@ -0,0 +1,17 @@ + + + diff --git a/webapp/src/components/ui/table/TableCell.vue b/webapp/src/components/ui/table/TableCell.vue new file mode 100644 index 0000000..d6e9ed2 --- /dev/null +++ b/webapp/src/components/ui/table/TableCell.vue @@ -0,0 +1,22 @@ + + + diff --git a/webapp/src/components/ui/table/TableEmpty.vue b/webapp/src/components/ui/table/TableEmpty.vue new file mode 100644 index 0000000..9519328 --- /dev/null +++ b/webapp/src/components/ui/table/TableEmpty.vue @@ -0,0 +1,34 @@ + + + diff --git a/webapp/src/components/ui/table/TableFooter.vue b/webapp/src/components/ui/table/TableFooter.vue new file mode 100644 index 0000000..29e0ce9 --- /dev/null +++ b/webapp/src/components/ui/table/TableFooter.vue @@ -0,0 +1,17 @@ + + + diff --git a/webapp/src/components/ui/table/TableHead.vue b/webapp/src/components/ui/table/TableHead.vue new file mode 100644 index 0000000..f83efe5 --- /dev/null +++ b/webapp/src/components/ui/table/TableHead.vue @@ -0,0 +1,17 @@ + + + diff --git a/webapp/src/components/ui/table/TableHeader.vue b/webapp/src/components/ui/table/TableHeader.vue new file mode 100644 index 0000000..b4ab5cf --- /dev/null +++ b/webapp/src/components/ui/table/TableHeader.vue @@ -0,0 +1,17 @@ + + + diff --git a/webapp/src/components/ui/table/TableRow.vue b/webapp/src/components/ui/table/TableRow.vue new file mode 100644 index 0000000..8f1d172 --- /dev/null +++ b/webapp/src/components/ui/table/TableRow.vue @@ -0,0 +1,17 @@ + + + diff --git a/webapp/src/components/ui/table/index.ts b/webapp/src/components/ui/table/index.ts new file mode 100644 index 0000000..3be308b --- /dev/null +++ b/webapp/src/components/ui/table/index.ts @@ -0,0 +1,9 @@ +export { default as Table } from "./Table.vue" +export { default as TableBody } from "./TableBody.vue" +export { default as TableCaption } from "./TableCaption.vue" +export { default as TableCell } from "./TableCell.vue" +export { default as TableEmpty } from "./TableEmpty.vue" +export { default as TableFooter } from "./TableFooter.vue" +export { default as TableHead } from "./TableHead.vue" +export { default as TableHeader } from "./TableHeader.vue" +export { default as TableRow } from "./TableRow.vue" diff --git a/webapp/src/components/ui/table/utils.ts b/webapp/src/components/ui/table/utils.ts new file mode 100644 index 0000000..3d4fd12 --- /dev/null +++ b/webapp/src/components/ui/table/utils.ts @@ -0,0 +1,10 @@ +import type { Updater } from "@tanstack/vue-table" + +import type { Ref } from "vue" +import { isFunction } from "@tanstack/vue-table" + +export function valueUpdater(updaterOrValue: Updater, ref: Ref) { + ref.value = isFunction(updaterOrValue) + ? updaterOrValue(ref.value) + : updaterOrValue +} diff --git a/webapp/src/components/ui/tabs/Tabs.vue b/webapp/src/components/ui/tabs/Tabs.vue new file mode 100644 index 0000000..d260a15 --- /dev/null +++ b/webapp/src/components/ui/tabs/Tabs.vue @@ -0,0 +1,24 @@ + + + diff --git a/webapp/src/components/ui/tabs/TabsContent.vue b/webapp/src/components/ui/tabs/TabsContent.vue new file mode 100644 index 0000000..3186ee8 --- /dev/null +++ b/webapp/src/components/ui/tabs/TabsContent.vue @@ -0,0 +1,21 @@ + + + diff --git a/webapp/src/components/ui/tabs/TabsList.vue b/webapp/src/components/ui/tabs/TabsList.vue new file mode 100644 index 0000000..a64a2da --- /dev/null +++ b/webapp/src/components/ui/tabs/TabsList.vue @@ -0,0 +1,24 @@ + + + diff --git a/webapp/src/components/ui/tabs/TabsTrigger.vue b/webapp/src/components/ui/tabs/TabsTrigger.vue new file mode 100644 index 0000000..45e424f --- /dev/null +++ b/webapp/src/components/ui/tabs/TabsTrigger.vue @@ -0,0 +1,26 @@ + + + diff --git a/webapp/src/components/ui/tabs/index.ts b/webapp/src/components/ui/tabs/index.ts new file mode 100644 index 0000000..7f99b7f --- /dev/null +++ b/webapp/src/components/ui/tabs/index.ts @@ -0,0 +1,4 @@ +export { default as Tabs } from "./Tabs.vue" +export { default as TabsContent } from "./TabsContent.vue" +export { default as TabsList } from "./TabsList.vue" +export { default as TabsTrigger } from "./TabsTrigger.vue" diff --git a/webapp/src/components/ui/textarea/Textarea.vue b/webapp/src/components/ui/textarea/Textarea.vue new file mode 100644 index 0000000..790f10c --- /dev/null +++ b/webapp/src/components/ui/textarea/Textarea.vue @@ -0,0 +1,28 @@ + + +