mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-06-25 03:23:41 +08:00
Compare commits
24 Commits
feat/sapha
...
v1.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2055c08f1 | ||
|
|
f4d2271cc1 | ||
|
|
7c81810019 | ||
|
|
deb7cf9a5e | ||
|
|
ad5c25f38e | ||
|
|
7568d8a2a2 | ||
|
|
e5a4aaadb2 | ||
|
|
51f1909a73 | ||
|
|
f1c7abfcc0 | ||
|
|
4407fdf731 | ||
|
|
bf799b3bf3 | ||
|
|
7e5542cae3 | ||
|
|
01fd87f029 | ||
|
|
00b153b5e1 | ||
|
|
f201b7633a | ||
|
|
4bf89ae7e5 | ||
|
|
09698cc767 | ||
|
|
3e476c401a | ||
|
|
b01828e3b4 | ||
|
|
5cc5b067fd | ||
|
|
7a67241bc6 | ||
|
|
3008d86027 | ||
|
|
ab046be247 | ||
|
|
93121745b7 |
26
.dockerignore
Normal file
26
.dockerignore
Normal file
@@ -0,0 +1,26 @@
|
||||
# Dependencies
|
||||
web/node_modules/
|
||||
|
||||
# Build artifacts
|
||||
server/bin/
|
||||
web/dist/
|
||||
|
||||
# Data & logs
|
||||
data/
|
||||
*.db
|
||||
*.log
|
||||
|
||||
# IDE & OS
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.github/
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.21'
|
||||
go-version: '1.25'
|
||||
cache-dependency-path: server/go.sum
|
||||
|
||||
- name: Build
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: web/package-lock.json
|
||||
|
||||
|
||||
145
.github/workflows/release.yml
vendored
145
.github/workflows/release.yml
vendored
@@ -1,63 +1,154 @@
|
||||
# 自动化发版流水线
|
||||
#
|
||||
# 触发方式:
|
||||
# 1. 推送 tag:git tag v1.2.3 && git push --tags
|
||||
# 2. 手动触发:GitHub Actions 页面 → Run workflow → 输入版本号
|
||||
#
|
||||
# 产出物:
|
||||
# - GitHub Release:linux/amd64 + linux/arm64 预编译 tar.gz
|
||||
# - Docker Hub:awuqing/backupx:latest + awuqing/backupx:v1.2.3(多架构)
|
||||
#
|
||||
# 前置配置:
|
||||
# 在仓库 Settings → Secrets → Actions 添加:
|
||||
# - DOCKERHUB_USERNAME(Docker Hub 用户名)
|
||||
# - DOCKERHUB_TOKEN(Docker Hub Access Token)
|
||||
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: '版本号(如 v1.2.3)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
# 统一版本号:tag 推送取 ref_name,手动触发取 inputs.version
|
||||
env:
|
||||
VERSION: ${{ github.event.inputs.version || github.ref_name }}
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Build & Release
|
||||
# ─── Job 1: 构建前端 ───
|
||||
build-web:
|
||||
name: Build Frontend
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: web/package-lock.json
|
||||
|
||||
- name: Install & Build
|
||||
working-directory: web
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Upload frontend artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: web-dist
|
||||
path: web/dist
|
||||
retention-days: 1
|
||||
|
||||
# ─── Job 2: 预编译二进制 → GitHub Release ───
|
||||
build-release:
|
||||
name: Build ${{ matrix.goarch }}
|
||||
needs: build-web
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
goos: [linux]
|
||||
goarch: [amd64, arm64]
|
||||
include:
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.21'
|
||||
go-version: '1.25'
|
||||
cache-dependency-path: server/go.sum
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Download frontend artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: web/package-lock.json
|
||||
name: web-dist
|
||||
path: web/dist
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: web
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Build backend
|
||||
- name: Build binary
|
||||
working-directory: server
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
CGO_ENABLED: 1
|
||||
CGO_ENABLED: '0'
|
||||
run: |
|
||||
go build -ldflags "-s -w -X main.version=${{ github.ref_name }}" -o ../backupx-${{ matrix.goos }}-${{ matrix.goarch }} ./cmd/backupx
|
||||
go build \
|
||||
-trimpath \
|
||||
-ldflags "-s -w -X main.version=${{ env.VERSION }}" \
|
||||
-o ../backupx \
|
||||
./cmd/backupx
|
||||
|
||||
- name: Package release
|
||||
run: |
|
||||
mkdir -p release
|
||||
cp backupx-${{ matrix.goos }}-${{ matrix.goarch }} release/
|
||||
cp -r web/dist release/web
|
||||
cp server/config.example.yaml release/
|
||||
cp deploy/install.sh release/ 2>/dev/null || true
|
||||
cd release && tar czf ../backupx-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz .
|
||||
ARCHIVE_NAME="backupx-${{ env.VERSION }}-${{ matrix.goos }}-${{ matrix.goarch }}"
|
||||
mkdir -p "${ARCHIVE_NAME}"
|
||||
cp backupx "${ARCHIVE_NAME}/"
|
||||
cp -r web/dist "${ARCHIVE_NAME}/web"
|
||||
cp server/config.example.yaml "${ARCHIVE_NAME}/"
|
||||
cp deploy/install.sh "${ARCHIVE_NAME}/" 2>/dev/null || true
|
||||
tar czf "${ARCHIVE_NAME}.tar.gz" "${ARCHIVE_NAME}"
|
||||
|
||||
- name: Upload Release Asset
|
||||
- name: Upload to GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: backupx-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz
|
||||
tag_name: ${{ env.VERSION }}
|
||||
files: backupx-${{ env.VERSION }}-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz
|
||||
generate_release_notes: true
|
||||
|
||||
# ─── Job 3: Docker 多架构 → Docker Hub ───
|
||||
build-docker:
|
||||
name: Build & Push Docker
|
||||
needs: build-web
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- 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.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build & Push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
build-args: |
|
||||
VERSION=${{ env.VERSION }}
|
||||
tags: |
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/backupx:latest
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/backupx:${{ env.VERSION }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
92
Dockerfile
Normal file
92
Dockerfile
Normal file
@@ -0,0 +1,92 @@
|
||||
# BackupX 多阶段构建
|
||||
#
|
||||
# 用法:
|
||||
# 国际构建(默认):docker build -t backupx .
|
||||
# 国内加速构建: docker build --build-arg USE_CHINA_MIRROR=true -t backupx .
|
||||
# 注入版本号: docker build --build-arg VERSION=v1.2.3 -t backupx .
|
||||
|
||||
# 全局构建参数
|
||||
ARG USE_CHINA_MIRROR=false
|
||||
|
||||
|
||||
# ---- Stage 1: Build frontend ----
|
||||
FROM node:20-alpine AS web-builder
|
||||
ARG USE_CHINA_MIRROR
|
||||
|
||||
# 国内镜像:npm 使用淘宝源
|
||||
RUN if [ "$USE_CHINA_MIRROR" = "true" ]; then \
|
||||
npm config set registry https://registry.npmmirror.com; \
|
||||
fi
|
||||
|
||||
WORKDIR /build/web
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY web/ ./
|
||||
RUN npm run build
|
||||
|
||||
|
||||
# ---- Stage 2: Build backend ----
|
||||
FROM golang:1.25-alpine AS server-builder
|
||||
ARG USE_CHINA_MIRROR
|
||||
ARG VERSION=dev
|
||||
|
||||
# 国内镜像:Go 模块使用七牛代理
|
||||
RUN if [ "$USE_CHINA_MIRROR" = "true" ]; then \
|
||||
go env -w GOPROXY=https://goproxy.cn,direct; \
|
||||
fi
|
||||
|
||||
WORKDIR /build/server
|
||||
COPY server/go.mod server/go.sum ./
|
||||
RUN go mod download
|
||||
COPY server/ ./
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o backupx ./cmd/backupx
|
||||
|
||||
|
||||
# ---- Stage 3: Production image ----
|
||||
FROM alpine:3.21
|
||||
ARG USE_CHINA_MIRROR
|
||||
|
||||
# 国内镜像:Alpine apk 使用阿里云源
|
||||
RUN if [ "$USE_CHINA_MIRROR" = "true" ]; then \
|
||||
sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories; \
|
||||
fi
|
||||
|
||||
RUN apk add --no-cache \
|
||||
nginx \
|
||||
tzdata \
|
||||
ca-certificates \
|
||||
# Required by mysql/postgresql backup tasks
|
||||
mysql-client \
|
||||
postgresql16-client \
|
||||
&& rm -rf /var/cache/apk/*
|
||||
|
||||
# Create app user
|
||||
RUN addgroup -S backupx && adduser -S -G backupx -h /app backupx
|
||||
|
||||
# Copy backend binary
|
||||
COPY --from=server-builder /build/server/backupx /app/bin/backupx
|
||||
|
||||
# Copy frontend static files
|
||||
COPY --from=web-builder /build/web/dist /app/web
|
||||
|
||||
# Copy nginx config
|
||||
COPY deploy/docker/nginx.conf /etc/nginx/http.d/default.conf
|
||||
|
||||
# Copy entrypoint
|
||||
COPY deploy/docker/entrypoint.sh /app/entrypoint.sh
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
# Create data directories
|
||||
RUN mkdir -p /app/data /tmp/backupx && \
|
||||
chown -R backupx:backupx /app /tmp/backupx
|
||||
|
||||
# Nginx needs to write to these dirs
|
||||
RUN mkdir -p /var/lib/nginx/tmp /var/log/nginx && \
|
||||
chown -R backupx:backupx /var/lib/nginx /var/log/nginx /run/nginx
|
||||
|
||||
WORKDIR /app
|
||||
EXPOSE 8340
|
||||
|
||||
VOLUME ["/app/data"]
|
||||
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
23
Makefile
23
Makefile
@@ -1,22 +1,25 @@
|
||||
.PHONY: build dev test clean
|
||||
.PHONY: build dev test clean docker docker-cn
|
||||
|
||||
# 一次性构建前后端
|
||||
# 自动获取版本号(从 git tag 或 commit hash)
|
||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
|
||||
# ── 一键构建 ──
|
||||
build: build-server build-web
|
||||
|
||||
build-server:
|
||||
cd server && go build -o bin/backupx ./cmd/backupx
|
||||
cd server && CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=$(VERSION)" -o bin/backupx ./cmd/backupx
|
||||
|
||||
build-web:
|
||||
cd web && npm run build
|
||||
|
||||
# 开发模式(分别在两个终端运行)
|
||||
# ── 开发模式(分别在两个终端运行)──
|
||||
dev-server:
|
||||
cd server && go run ./cmd/backupx
|
||||
|
||||
dev-web:
|
||||
cd web && npm run dev
|
||||
|
||||
# 运行所有测试
|
||||
# ── 测试 ──
|
||||
test: test-server test-web
|
||||
|
||||
test-server:
|
||||
@@ -25,6 +28,14 @@ test-server:
|
||||
test-web:
|
||||
cd web && npm run test
|
||||
|
||||
# 清理构建产物
|
||||
# ── Docker 构建 ──
|
||||
docker:
|
||||
docker build --build-arg VERSION=$(VERSION) -t backupx:$(VERSION) -t backupx:latest .
|
||||
|
||||
# 国内加速构建(使用国内镜像源)
|
||||
docker-cn:
|
||||
docker build --build-arg VERSION=$(VERSION) --build-arg USE_CHINA_MIRROR=true -t backupx:$(VERSION) -t backupx:latest .
|
||||
|
||||
# ── 清理 ──
|
||||
clean:
|
||||
rm -rf server/bin web/dist
|
||||
|
||||
643
README.md
643
README.md
@@ -2,405 +2,197 @@
|
||||
<a href="README_EN.md">English</a> | <strong>中文</strong>
|
||||
</p>
|
||||
<p align="center">
|
||||
<h1 align="center">🛡️ BackupX</h1>
|
||||
<h1 align="center">BackupX</h1>
|
||||
<p align="center">
|
||||
<strong>Self-hosted Server Backup Management Platform with Web UI</strong>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="#features">Features</a> •
|
||||
<a href="#quick-start">Quick Start</a> •
|
||||
<a href="#configuration">Configuration</a> •
|
||||
<a href="#architecture">Architecture</a> •
|
||||
<a href="#cluster-mode">Cluster</a> •
|
||||
<a href="#development">Development</a> •
|
||||
<a href="#api-reference">API</a>
|
||||
<strong>自托管服务器备份管理平台</strong><br>
|
||||
一个二进制,一条命令,管好你所有服务器的备份。
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://github.com/Awuqing/BackupX/stargazers"><img src="https://img.shields.io/github/stars/Awuqing/BackupX?style=flat-square&color=f5c542" alt="Stars"></a>
|
||||
<a href="https://github.com/Awuqing/BackupX/releases"><img src="https://img.shields.io/github/v/release/Awuqing/BackupX?style=flat-square&color=brightgreen" alt="Release"></a>
|
||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat-square&logo=go" alt="Go">
|
||||
<img src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat-square&logo=go" alt="Go">
|
||||
<img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react" alt="React">
|
||||
<img src="https://img.shields.io/badge/TypeScript-5-3178C6?style=flat-square&logo=typescript" alt="TypeScript">
|
||||
<img src="https://img.shields.io/badge/SQLite-embedded-003B57?style=flat-square&logo=sqlite" alt="SQLite">
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/Awuqing/BackupX?style=flat-square" alt="License"></a>
|
||||
<a href="https://github.com/Awuqing/BackupX/issues"><img src="https://img.shields.io/github/issues/Awuqing/BackupX?style=flat-square" alt="Issues"></a>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
BackupX 是一个面向 **Linux / macOS 服务器**的自托管备份管理平台。通过企业级 Web 控制台,轻松配置目录备份、数据库备份,并将备份文件安全存储到阿里云 OSS、腾讯云 COS、七牛云 Kodo、Google Drive、S3 兼容存储、WebDAV、FTP/FTPS 或本地磁盘。
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="screenshots/dashboard.png" alt="仪表盘"></td>
|
||||
<td width="50%"><img src="screenshots/backup-tasks.png" alt="备份任务"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="screenshots/storage-targets.png" alt="存储目标"></td>
|
||||
<td><img src="screenshots/backup-records.png" alt="备份记录"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
支持 **多节点集群管理**,可统一管控分布在不同服务器上的备份任务。
|
||||
## 功能亮点
|
||||
|
||||
> **适用人群**:拥有 Linux 服务器的个人开发者 / 小团队 / 企业运维
|
||||
| 能力 | 说明 |
|
||||
|------|------|
|
||||
| **备份类型** | 文件/目录(多源路径)、MySQL、PostgreSQL、SQLite、SAP HANA |
|
||||
| **存储后端** | 阿里云 OSS、腾讯云 COS、七牛云、S3 兼容(AWS/MinIO/R2)、Google Drive、WebDAV、FTP/FTPS、本地磁盘 |
|
||||
| **自动调度** | Cron 定时 + 可视化编辑器 + 自动保留策略(按天数/份数清理) |
|
||||
| **多节点** | Master-Agent 集群,统一管理多台服务器的备份 |
|
||||
| **安全** | JWT + bcrypt + AES-256-GCM 加密配置 + 可选备份文件加密 + 审计日志 |
|
||||
| **通知** | 邮件 / Webhook / Telegram,备份成功或失败时自动推送 |
|
||||
| **部署** | 单二进制 + 内嵌 SQLite,Docker 一键启动,零外部依赖 |
|
||||
|
||||
## Screenshots
|
||||
---
|
||||
|
||||
### 登录页面
|
||||

|
||||
## 快速开始
|
||||
|
||||
### 仪表盘
|
||||

|
||||
### 1. 安装
|
||||
|
||||
### 备份任务
|
||||

|
||||
|
||||
### 备份记录
|
||||

|
||||
|
||||
### 存储目标
|
||||

|
||||
|
||||
### 节点管理
|
||||

|
||||
|
||||
### 通知配置
|
||||

|
||||
|
||||
### 系统设置
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
### 📦 多种备份类型
|
||||
- **文件/目录** — 支持自定义排除规则(如 `node_modules`、`*.log`)
|
||||
- **MySQL** — 通过 `mysqldump` 原生工具
|
||||
- **SQLite** — 安全文件拷贝
|
||||
- **PostgreSQL** — 通过 `pg_dump` 原生工具
|
||||
- **SAP HANA** — 通过 `hdbsql` 原生工具(支持多租户数据库)
|
||||
|
||||
### ☁️ 多云存储后端
|
||||
| 厂商 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| 🇨🇳 **阿里云 OSS** | `aliyun_oss` | 自动组装 Endpoint,支持内网传输 |
|
||||
| 🇨🇳 **腾讯云 COS** | `tencent_cos` | 自动组装 Endpoint |
|
||||
| 🇨🇳 **七牛云 Kodo** | `qiniu_kodo` | 6 大区域精确映射 |
|
||||
| 🌍 **S3 Compatible** | `s3` | AWS S3 / MinIO / Cloudflare R2 等 |
|
||||
| 🌍 **Google Drive** | `google_drive` | 完整 OAuth 2.0 授权流程 |
|
||||
| 🌍 **WebDAV** | `webdav` | 坚果云 / Nextcloud 等 |
|
||||
| 🌍 **FTP / FTPS** | `ftp` | 标准 FTP 协议,支持 Explicit TLS 加密 |
|
||||
| 💾 **本地磁盘** | `local_disk` | 备份到服务器本地目录 |
|
||||
|
||||
> 国内云厂商仅需填写 **Region** 和 **AccessKey**,系统自动完成 Endpoint 组装,底层复用 S3 引擎零额外依赖。
|
||||
|
||||
### 🖥️ 集群管理 (Master-Agent)
|
||||
- **节点管理** — 注册远程服务器节点,Token 认证
|
||||
- **本机节点** — 自动创建,单机用户零感知升级
|
||||
- **目录浏览** — 可视化文件树选择备份源路径,告别手动输入
|
||||
- **Agent 心跳** — 节点在线状态实时监控
|
||||
- **任务标签** — 按标签/节点分类管理备份任务
|
||||
|
||||
### ⏰ 自动化与调度
|
||||
- Cron 表达式定时调度
|
||||
- 可视化 Cron 编辑器
|
||||
- 自动保留策略(按天数 / 按份数过期清理)
|
||||
- 最大并发备份数限制
|
||||
|
||||
### 🔐 安全
|
||||
- JWT 认证 + bcrypt 密码存储
|
||||
- AES-256-GCM 加密存储敏感配置(数据库密码、OAuth Token)
|
||||
- 可选备份文件加密
|
||||
- 登录限流防暴力破解
|
||||
- 节点 Token 认证(一次性显示,安全传输)
|
||||
|
||||
### 📊 监控与通知
|
||||
- 仪表盘统计(成功率、存储用量、备份趋势图表)
|
||||
- 邮件 / Webhook / Telegram 通知
|
||||
- 实时备份执行日志 (SSE)
|
||||
|
||||
### 🌐 其他
|
||||
- 中英文国际化 (i18n)
|
||||
- 零外部依赖(内嵌 SQLite,单二进制部署)
|
||||
- systemd 服务支持
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 从源码构建
|
||||
**Docker(推荐,无需克隆仓库):**
|
||||
|
||||
```bash
|
||||
# 克隆项目
|
||||
git clone https://github.com/Awuqing/BackupX.git
|
||||
cd BackupX
|
||||
# 创建 docker-compose.yml 后一键启动
|
||||
docker compose up -d
|
||||
|
||||
# 一键构建前后端
|
||||
make build
|
||||
|
||||
# 启动后端服务(默认监听 :8340)
|
||||
cd server && ./bin/backupx
|
||||
# 或直接运行
|
||||
docker run -d --name backupx -p 8340:8340 -v backupx-data:/app/data awuqing/backupx:latest
|
||||
```
|
||||
|
||||
### 访问 Web UI
|
||||
> Docker Hub 镜像:[`awuqing/backupx`](https://hub.docker.com/r/awuqing/backupx),支持 linux/amd64 和 linux/arm64。
|
||||
|
||||
打开浏览器访问 `http://your-server:8340`,首次使用会引导您创建管理员账户。
|
||||
|
||||
## Configuration
|
||||
|
||||
配置文件路径默认为 `./config.yaml`,也可通过环境变量 `BACKUPX_` 前缀覆盖。
|
||||
<details>
|
||||
<summary>docker-compose.yml 参考</summary>
|
||||
|
||||
```yaml
|
||||
# config.yaml
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8340
|
||||
mode: "release" # debug | release
|
||||
services:
|
||||
backupx:
|
||||
image: awuqing/backupx:latest
|
||||
container_name: backupx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8340:8340"
|
||||
volumes:
|
||||
- backupx-data:/app/data
|
||||
# 挂载需要备份的宿主机目录(按需添加):
|
||||
# - /var/www:/mnt/www:ro
|
||||
# - /etc/nginx:/mnt/nginx-conf:ro
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
|
||||
database:
|
||||
path: "./data/backupx.db" # SQLite 数据库路径
|
||||
|
||||
security:
|
||||
jwt_secret: "" # 留空则自动生成
|
||||
jwt_expire: "24h"
|
||||
encryption_key: "" # AES 加密密钥,留空自动生成
|
||||
|
||||
backup:
|
||||
temp_dir: "/tmp/backupx" # 备份临时文件目录
|
||||
max_concurrent: 2 # 最大并发备份数
|
||||
|
||||
log:
|
||||
level: "info" # debug | info | warn | error
|
||||
file: "./data/backupx.log"
|
||||
max_size: 100 # 日志文件大小上限 (MB)
|
||||
max_backups: 3 # 保留旧日志文件数
|
||||
max_age: 30 # 日志保留天数
|
||||
volumes:
|
||||
backupx-data:
|
||||
```
|
||||
|
||||
> 💡 `jwt_secret` 和 `encryption_key` 首次启动时自动生成并持久化到数据库,无需手动配置。
|
||||
</details>
|
||||
|
||||
## Architecture
|
||||
**预编译包(裸机部署):**
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ Nginx (反向代理) │
|
||||
│ / → 前端静态文件 │
|
||||
│ /api → :8340 │
|
||||
└─────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ BackupX Master (Go API Server) │
|
||||
│ :8340 │
|
||||
│ │
|
||||
│ ┌──────┐ ┌────────────┐ ┌───────────────────────┐│
|
||||
│ │ Auth │ │Backup Engine│ │ Storage Registry ││
|
||||
│ └──────┘ └──────┬─────┘ │ ┌─────────────────┐ ││
|
||||
│ │ │ │ Aliyun OSS │ ││
|
||||
│ ┌──────────┐ │ │ │ Tencent COS │ ││
|
||||
│ │ Cron │◄───┘ │ │ Qiniu Kodo │ ││
|
||||
│ │Scheduler │ │ │ S3 Compatible │ ││
|
||||
│ └──────────┘ │ │ Google Drive │ ││
|
||||
│ │ │ WebDAV │ ││
|
||||
│ │ │ FTP / FTPS │ ││
|
||||
│ ┌──────────┐ │ │ Local Disk │ ││
|
||||
│ │ Notify │ │ └─────────────────┘ ││
|
||||
│ │ Module │ └───────────────────────┘│
|
||||
│ └──────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌────────────────────┐ │
|
||||
│ │ Node Manager │ │ SQLite (backupx.db)│ │
|
||||
│ └──────┬───────┘ └────────────────────┘ │
|
||||
└─────────┼────────────────────────────────────────────┘
|
||||
│ Heartbeat / Task Dispatch
|
||||
▼
|
||||
┌──────────────────┐ ┌──────────────────┐
|
||||
│ Agent Node A │ │ Agent Node B │
|
||||
│ (远程服务器) │ │ (远程服务器) │
|
||||
└──────────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
### 技术栈
|
||||
|
||||
| 组件 | 技术 |
|
||||
|------|------|
|
||||
| **后端** | Go · Gin · GORM · SQLite · robfig/cron |
|
||||
| **前端** | React 18 · TypeScript · ArcoDesign · Vite · Zustand · ECharts |
|
||||
| **存储** | AWS SDK v2 (S3/OSS/COS/Kodo) · Google Drive API v3 · gowebdav · jlaffaye/ftp |
|
||||
| **安全** | JWT · bcrypt · AES-256-GCM |
|
||||
| **日志** | zap + lumberjack (自动轮转) |
|
||||
|
||||
## Cluster Mode
|
||||
|
||||
BackupX 支持 **Master-Agent** 模式,可管理多台服务器的备份任务。
|
||||
|
||||
### 工作原理
|
||||
|
||||
1. **Master** 为运行 BackupX Web 控制台的主控服务器
|
||||
2. **Agent** 部署在需要备份的远程服务器上
|
||||
3. Agent 启动后通过 Token 向 Master 注册并定期发送心跳
|
||||
4. Master 将备份任务下发至对应 Agent 执行
|
||||
|
||||
### 添加节点
|
||||
从 [Releases](https://github.com/Awuqing/BackupX/releases) 下载对应平台的压缩包:
|
||||
|
||||
```bash
|
||||
# 在 Web 控制台 → 节点管理 → 添加节点
|
||||
# 系统将生成一个唯一的 64 位十六进制 Token
|
||||
|
||||
# 在远程服务器上配置 Agent 启动参数
|
||||
./backupx-agent --master http://master-server:8340 --token <your-token>
|
||||
tar xzf backupx-v*-linux-amd64.tar.gz && cd backupx-*
|
||||
sudo ./install.sh # 自动配置 systemd + Nginx
|
||||
```
|
||||
|
||||
### 目录探针 API
|
||||
|
||||
Master 提供 `GET /api/nodes/:id/fs/list?path=/` 接口,可远程浏览节点的文件系统目录。前端在创建备份任务的"源路径"输入时可使用树形选择器直接浏览目标机器的目录结构。
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
BackupX/
|
||||
├── server/ # Go 后端
|
||||
│ ├── cmd/backupx/ # 程序入口
|
||||
│ ├── internal/
|
||||
│ │ ├── app/ # 应用组装 (DI)
|
||||
│ │ ├── apperror/ # 统一错误类型
|
||||
│ │ ├── backup/ # 备份引擎 (file/mysql/sqlite/pgsql/saphana)
|
||||
│ │ │ └── retention/ # 保留策略
|
||||
│ │ ├── config/ # 配置加载 (viper)
|
||||
│ │ ├── database/ # 数据库初始化 + 迁移
|
||||
│ │ ├── http/ # HTTP 处理器 + 路由 + 中间件
|
||||
│ │ ├── httpapi/ # HTTP API 辅助工具
|
||||
│ │ ├── logger/ # 日志初始化 (zap + lumberjack)
|
||||
│ │ ├── model/ # GORM 数据模型
|
||||
│ │ ├── notify/ # 通知 (email/webhook/telegram)
|
||||
│ │ ├── repository/ # 数据访问层
|
||||
│ │ ├── scheduler/ # Cron 调度器
|
||||
│ │ ├── security/ # JWT + 限流
|
||||
│ │ ├── service/ # 业务逻辑层
|
||||
│ │ └── storage/ # 存储后端 (插件化接口)
|
||||
│ │ ├── aliyun/ # 阿里云 OSS
|
||||
│ │ ├── tencent/ # 腾讯云 COS
|
||||
│ │ ├── qiniu/ # 七牛云 Kodo
|
||||
│ │ ├── s3/ # S3 Compatible 核心
|
||||
│ │ ├── s3provider/ # S3 Provider 辅助
|
||||
│ │ ├── googledrive/ # Google Drive
|
||||
│ │ ├── webdav/ # WebDAV 核心
|
||||
│ │ ├── webdavprovider/ # WebDAV Provider 辅助
|
||||
│ │ ├── localdisk/ # 本地磁盘
|
||||
│ │ ├── ftp/ # FTP / FTPS
|
||||
│ │ └── codec/ # 配置编解码
|
||||
│ └── pkg/ # 工具包 (compress/crypto/response)
|
||||
├── web/ # React 前端
|
||||
│ └── src/
|
||||
│ ├── components/ # 通用组件 (CronEditor/FormDrawer/...)
|
||||
│ ├── hooks/ # 自定义 Hooks
|
||||
│ ├── layouts/ # 布局组件 (AppLayout)
|
||||
│ ├── pages/ # 页面模块
|
||||
│ │ ├── dashboard/ # 仪表盘
|
||||
│ │ ├── backup-tasks/ # 备份任务
|
||||
│ │ ├── backup-records/ # 备份记录
|
||||
│ │ ├── storage-targets/ # 存储目标
|
||||
│ │ ├── nodes/ # 节点管理
|
||||
│ │ ├── notifications/ # 通知配置
|
||||
│ │ ├── settings/ # 系统设置
|
||||
│ │ └── login/ # 登录页
|
||||
│ ├── services/ # API 请求封装
|
||||
│ ├── stores/ # Zustand 状态管理
|
||||
│ ├── styles/ # 全局样式
|
||||
│ ├── types/ # TypeScript 类型定义
|
||||
│ ├── utils/ # 工具函数
|
||||
│ ├── locales/ # i18n 语言包 (zh-CN / en-US)
|
||||
│ └── router/ # 路由配置
|
||||
├── deploy/ # 部署配置
|
||||
│ ├── nginx.conf # Nginx 参考配置
|
||||
│ ├── backupx.service # systemd 服务单元
|
||||
│ └── install.sh # 一键安装脚本
|
||||
├── .github/ # GitHub 配置
|
||||
│ ├── workflows/ci.yml # CI 工作流
|
||||
│ ├── workflows/release.yml # Release 工作流
|
||||
│ └── ISSUE_TEMPLATE/ # Issue 模板
|
||||
└── Makefile # 构建命令
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### 前置条件
|
||||
|
||||
- **Go** ≥ 1.21
|
||||
- **Node.js** ≥ 18
|
||||
- **npm**
|
||||
|
||||
### 开发模式
|
||||
**从源码构建:**
|
||||
|
||||
```bash
|
||||
# 终端 1:启动后端 (热重载需配合 air)
|
||||
make dev-server
|
||||
|
||||
# 终端 2:启动前端 (Vite HMR)
|
||||
make dev-web
|
||||
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
|
||||
make build # 构建前后端
|
||||
make docker-cn # 或用国内镜像构建 Docker(goproxy.cn / npmmirror / 阿里云 apk)
|
||||
```
|
||||
|
||||
### 运行测试
|
||||
### 2. 打开控制台
|
||||
|
||||
浏览器访问 `http://your-server:8340`,首次打开会引导创建管理员账户。
|
||||
|
||||
### 3. 添加存储目标
|
||||
|
||||
进入 **存储目标** 页面,点击 **添加**,选择存储类型并填写凭证:
|
||||
|
||||
| 存储类型 | 需要填写 |
|
||||
|---------|---------|
|
||||
| 阿里云 OSS | Region + AccessKey ID/Secret + Bucket |
|
||||
| 腾讯云 COS | Region + SecretId/SecretKey + Bucket(格式 `name-appid`) |
|
||||
| 七牛云 Kodo | Region + AccessKey/SecretKey + Bucket |
|
||||
| S3 兼容 | Endpoint + AccessKey + Bucket |
|
||||
| Google Drive | Client ID/Secret → 点击授权完成 OAuth |
|
||||
| WebDAV | 服务器地址 + 用户名/密码 |
|
||||
| FTP | 主机 + 端口 + 用户名/密码 |
|
||||
| 本地磁盘 | 目标目录路径 |
|
||||
|
||||
> 国内云厂商只需填 Region 和 AccessKey,系统自动组装 Endpoint。
|
||||
|
||||
添加后点击 **测试连接** 确认配置正确。
|
||||
|
||||
### 4. 创建备份任务
|
||||
|
||||
进入 **备份任务** 页面,点击 **新建**,三步完成:
|
||||
|
||||
1. **基础信息** — 任务名称、备份类型、Cron 表达式(留空则仅手动执行)
|
||||
2. **源配置** — 文件备份选择源路径(支持多个)、数据库备份填写连接信息
|
||||
3. **存储与策略** — 选择存储目标、压缩策略、保留天数、是否加密
|
||||
|
||||
保存后可以点击 **立即执行** 测试,在 **备份记录** 页面实时查看执行日志。
|
||||
|
||||
### 5. 配置通知(可选)
|
||||
|
||||
进入 **通知配置** 页面,支持邮件、Webhook、Telegram 三种方式,可分别配置成功/失败时是否推送。
|
||||
|
||||
---
|
||||
|
||||
## 部署指南
|
||||
|
||||
### Docker 部署
|
||||
|
||||
```bash
|
||||
# 运行全部测试
|
||||
make test
|
||||
|
||||
# 仅后端
|
||||
make test-server # go test ./...
|
||||
|
||||
# 仅前端
|
||||
make test-web # npm run test
|
||||
docker compose up -d # 使用上方的 docker-compose.yml
|
||||
```
|
||||
|
||||
### 构建
|
||||
备份宿主机目录时需要挂载路径(在 docker-compose.yml 的 `volumes` 中添加):
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- backupx-data:/app/data
|
||||
- /var/www:/mnt/www:ro # 挂载需要备份的目录
|
||||
- /etc/nginx:/mnt/nginx-conf:ro # 可以挂载多个
|
||||
```
|
||||
|
||||
通过环境变量调整配置:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
- BACKUPX_LOG_LEVEL=debug
|
||||
- BACKUPX_BACKUP_MAX_CONCURRENT=4
|
||||
```
|
||||
|
||||
### 裸机部署
|
||||
|
||||
```bash
|
||||
# 构建前后端
|
||||
# 使用预编译包
|
||||
tar xzf backupx-v*-linux-amd64.tar.gz && cd backupx-*
|
||||
sudo ./install.sh
|
||||
|
||||
# 或从源码
|
||||
make build
|
||||
|
||||
# 清理构建产物
|
||||
make clean
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### 一键安装 (推荐)
|
||||
|
||||
```bash
|
||||
# 先构建
|
||||
make build
|
||||
|
||||
# 以 root 执行安装脚本
|
||||
sudo ./deploy/install.sh
|
||||
```
|
||||
|
||||
安装脚本将自动:
|
||||
1. 创建 `backupx` 系统用户
|
||||
2. 安装二进制到 `/opt/backupx/bin/`
|
||||
3. 部署前端到 `/opt/backupx/web/`
|
||||
4. 生成配置文件 `/etc/backupx/config.yaml`
|
||||
5. 注册并启动 systemd 服务
|
||||
6. 配置 Nginx 反向代理(如已安装)
|
||||
安装脚本自动完成:创建系统用户 → 安装二进制到 `/opt/backupx/` → 配置 systemd → 配置 Nginx 反向代理。
|
||||
|
||||
### 手动部署
|
||||
|
||||
```bash
|
||||
# 1. 构建
|
||||
cd server && go build -o backupx ./cmd/backupx
|
||||
cd ../web && npm run build
|
||||
|
||||
# 2. 部署文件
|
||||
scp server/backupx your-server:/opt/backupx/bin/
|
||||
scp -r web/dist/ your-server:/opt/backupx/web/
|
||||
scp server/config.example.yaml your-server:/etc/backupx/config.yaml
|
||||
|
||||
# 3. 启动
|
||||
ssh your-server '/opt/backupx/bin/backupx -config /etc/backupx/config.yaml'
|
||||
```
|
||||
|
||||
### Nginx 配置示例
|
||||
### Nginx 反向代理(裸机部署时)
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name backup.example.com;
|
||||
|
||||
# 前端静态文件
|
||||
location / {
|
||||
root /opt/backupx/web;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API 反向代理
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8340;
|
||||
proxy_set_header Host $host;
|
||||
@@ -409,89 +201,122 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
### 配置文件
|
||||
|
||||
所有 API 均以 `/api` 为前缀,使用 JWT Bearer Token 认证(除特殊标注外)。
|
||||
配置文件路径 `./config.yaml`,也可通过 `BACKUPX_` 前缀环境变量覆盖:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
port: 8340
|
||||
database:
|
||||
path: "./data/backupx.db"
|
||||
security:
|
||||
jwt_secret: "" # 留空自动生成并持久化到数据库
|
||||
encryption_key: "" # 留空自动生成
|
||||
backup:
|
||||
temp_dir: "/tmp/backupx"
|
||||
max_concurrent: 2
|
||||
log:
|
||||
level: "info" # debug | info | warn | error
|
||||
file: "./data/backupx.log"
|
||||
```
|
||||
|
||||
### 密码重置
|
||||
|
||||
忘记管理员密码时通过 CLI 重置:
|
||||
|
||||
```bash
|
||||
# 裸机
|
||||
./backupx reset-password --username admin --password newpass123
|
||||
|
||||
# Docker
|
||||
docker exec -it backupx /app/bin/backupx reset-password --username admin --password newpass123
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 多节点集群
|
||||
|
||||
BackupX 支持 Master-Agent 模式管理多台服务器:
|
||||
|
||||
1. Web 控制台 → **节点管理** → **添加节点**,系统生成 Token
|
||||
2. 在远程服务器部署 Agent 并使用 Token 连接 Master
|
||||
3. 创建备份任务时选择对应节点,Master 自动下发任务
|
||||
|
||||
创建文件备份任务时,可通过可视化目录浏览器远程选择 Agent 节点上的目录,无需手动输入路径。
|
||||
|
||||
---
|
||||
|
||||
## 开发指南
|
||||
|
||||
**环境要求:** Go >= 1.25 · Node.js >= 20 · npm
|
||||
|
||||
```bash
|
||||
# 开发模式
|
||||
make dev-server # 终端 1:后端(默认 :8340)
|
||||
make dev-web # 终端 2:前端(Vite HMR)
|
||||
|
||||
# 测试
|
||||
make test # 运行全部测试
|
||||
|
||||
# 构建
|
||||
make build # 前后端一起构建
|
||||
make docker # Docker 构建
|
||||
make docker-cn # 国内 Docker 构建(镜像加速)
|
||||
```
|
||||
|
||||
### 发版
|
||||
|
||||
```bash
|
||||
git tag v1.2.3 && git push --tags
|
||||
# GitHub Actions 自动:编译双架构二进制 → 发布 GitHub Release → 推送 Docker Hub 镜像
|
||||
```
|
||||
|
||||
也可在 GitHub Actions 页面手动触发 Release workflow。
|
||||
|
||||
---
|
||||
|
||||
## API 参考
|
||||
|
||||
所有接口以 `/api` 为前缀,使用 JWT Bearer Token 认证。
|
||||
|
||||
| 模块 | 端点 | 说明 |
|
||||
|------|------|------|
|
||||
| **认证** | `POST /api/auth/setup` | 首次初始化管理员 |
|
||||
| | `POST /api/auth/login` | 登录获取 Token |
|
||||
| | `POST /api/auth/logout` | 登出 |
|
||||
| | `GET /api/auth/profile` | 当前用户信息 |
|
||||
| | `PUT /api/auth/password` | 修改密码 |
|
||||
| **备份任务** | `GET/POST /api/backup/tasks` | 任务列表 / 创建 |
|
||||
| | `GET/PUT/DELETE /api/backup/tasks/:id` | 详情 / 更新 / 删除 |
|
||||
| | `PUT /api/backup/tasks/:id/toggle` | 启用/禁用 |
|
||||
| | `POST /api/backup/tasks/:id/run` | 手动触发执行 |
|
||||
| **备份记录** | `GET /api/backup/records` | 记录列表 (支持筛选) |
|
||||
| | `GET /api/backup/records/:id` | 记录详情 |
|
||||
| | `GET /api/backup/records/:id/logs/stream` | 实时执行日志 (SSE) |
|
||||
| | `GET /api/backup/records/:id/download` | 下载备份文件 |
|
||||
| | `POST /api/backup/records/:id/restore` | 恢复备份 |
|
||||
| **存储目标** | `GET/POST /api/storage-targets` | 存储列表 / 添加 |
|
||||
| | `GET/PUT/DELETE /api/storage-targets/:id` | 详情 / 更新 / 删除 |
|
||||
| | `POST /api/storage-targets/test` | 测试连接 |
|
||||
| | `POST /api/storage-targets/:id/test` | 测试已保存连接 |
|
||||
| | `GET /api/storage-targets/:id/usage` | 查询用量 |
|
||||
| **节点管理** | `GET/POST /api/nodes` | 节点列表 / 添加 |
|
||||
| | `GET/DELETE /api/nodes/:id` | 详情 / 删除 |
|
||||
| | `GET /api/nodes/:id/fs/list` | 目录浏览 |
|
||||
| | `POST /api/agent/heartbeat` | Agent 心跳 ⚡ |
|
||||
| **通知** | `GET/POST /api/notifications` | 通知列表 / 添加 |
|
||||
| | `POST /api/notifications/test` | 测试通知 |
|
||||
| | `POST /api/notifications/:id/test` | 测试已保存通知 |
|
||||
| **仪表盘** | `GET /api/dashboard/stats` | 概览统计 |
|
||||
| | `GET /api/dashboard/timeline` | 备份趋势时间线 |
|
||||
| **系统** | `GET /api/system/info` | 系统信息 (版本/磁盘) |
|
||||
| | `GET/PUT /api/settings` | 系统设置读写 |
|
||||
| **认证** | `POST /auth/setup` | 初始化管理员 |
|
||||
| | `POST /auth/login` | 登录 |
|
||||
| | `PUT /auth/password` | 修改密码 |
|
||||
| **备份任务** | `GET\|POST /backup/tasks` | 列表 / 创建 |
|
||||
| | `GET\|PUT\|DELETE /backup/tasks/:id` | 详情 / 更新 / 删除 |
|
||||
| | `PUT /backup/tasks/:id/toggle` | 启用/禁用 |
|
||||
| | `POST /backup/tasks/:id/run` | 手动执行 |
|
||||
| **备份记录** | `GET /backup/records` | 列表(支持筛选) |
|
||||
| | `GET /backup/records/:id/logs/stream` | 实时日志 (SSE) |
|
||||
| | `GET /backup/records/:id/download` | 下载 |
|
||||
| | `POST /backup/records/:id/restore` | 恢复 |
|
||||
| **存储目标** | `GET\|POST /storage-targets` | 列表 / 添加 |
|
||||
| | `POST /storage-targets/test` | 测试连接 |
|
||||
| **节点** | `GET\|POST /nodes` | 列表 / 添加 |
|
||||
| | `GET /nodes/:id/fs/list` | 目录浏览 |
|
||||
| **通知** | `GET\|POST /notifications` | 列表 / 添加 |
|
||||
| **仪表盘** | `GET /dashboard/stats` | 概览统计 |
|
||||
| **审计日志** | `GET /audit-logs` | 操作审计 |
|
||||
| **系统** | `GET /system/info` | 系统信息 |
|
||||
|
||||
> ⚡ `POST /api/agent/heartbeat` 为公开端点,使用 Node Token 认证而非 JWT。
|
||||
---
|
||||
|
||||
## 云存储配置指南
|
||||
## 技术栈
|
||||
|
||||
### 阿里云 OSS
|
||||
|
||||
1. 登录[阿里云控制台](https://oss.console.aliyun.com/),创建 Bucket
|
||||
2. 前往 RAM 控制台创建 AccessKey
|
||||
3. 在 BackupX 添加存储目标时选择"阿里云 OSS"
|
||||
4. 填写 Region(如 `cn-hangzhou`)和 AccessKey,系统自动组装 Endpoint
|
||||
|
||||
### 腾讯云 COS
|
||||
|
||||
1. 登录[腾讯云控制台](https://console.cloud.tencent.com/cos),创建存储桶
|
||||
2. 前往 API 密钥管理创建 SecretId/SecretKey
|
||||
3. Bucket 名称格式为 `BucketName-APPID`(如 `backup-1250000000`)
|
||||
|
||||
### 七牛云 Kodo
|
||||
|
||||
1. 登录[七牛云控制台](https://portal.qiniu.com/),创建存储空间
|
||||
2. 支持区域:`z0`(华东) / `cn-east-2`(华东-浙江2) / `z1`(华北) / `z2`(华南) / `na0`(北美) / `as0`(东南亚)
|
||||
|
||||
### Google Drive
|
||||
|
||||
1. 前往 [Google Cloud Console](https://console.cloud.google.com/) 创建项目
|
||||
2. 启用 **Google Drive API**
|
||||
3. 创建 **OAuth 2.0 客户端 ID**(Web 应用类型)
|
||||
4. 添加重定向 URI:`http://your-server/api/storage-targets/google-drive/callback`
|
||||
5. 在 BackupX 存储管理页面填入 Client ID / Secret,点击授权
|
||||
| 组件 | 技术 |
|
||||
|------|------|
|
||||
| **后端** | Go · Gin · GORM · SQLite · robfig/cron |
|
||||
| **前端** | React 18 · TypeScript · ArcoDesign · Vite · Zustand · ECharts |
|
||||
| **存储** | AWS SDK v2 · Google Drive API v3 · gowebdav · jlaffaye/ftp |
|
||||
| **安全** | JWT · bcrypt · AES-256-GCM |
|
||||
|
||||
## Contributing
|
||||
|
||||
欢迎提交 Issue 和 Pull Request!
|
||||
|
||||
1. Fork 本项目
|
||||
2. 创建功能分支 (`git checkout -b feature/amazing-feature`)
|
||||
3. 提交更改 (`git commit -m 'Add amazing feature'`)
|
||||
4. 推送到分支 (`git push origin feature/amazing-feature`)
|
||||
5. 创建 Pull Request
|
||||
|
||||
## License
|
||||
|
||||
本项目采用 [Apache License 2.0](LICENSE) 开源协议。
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
Made with ❤️ for self-hosters
|
||||
</p>
|
||||
[Apache License 2.0](LICENSE)
|
||||
|
||||
638
README_EN.md
638
README_EN.md
@@ -2,406 +2,195 @@
|
||||
<strong>English</strong> | <a href="README.md">中文</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<h1 align="center">🛡️ BackupX</h1>
|
||||
<h1 align="center">BackupX</h1>
|
||||
<p align="center">
|
||||
<strong>Self-hosted Server Backup Management Platform with Web UI</strong>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="#features">Features</a> •
|
||||
<a href="#quick-start">Quick Start</a> •
|
||||
<a href="#configuration">Configuration</a> •
|
||||
<a href="#architecture">Architecture</a> •
|
||||
<a href="#cluster-mode">Cluster</a> •
|
||||
<a href="#development">Development</a> •
|
||||
<a href="#api-reference">API</a>
|
||||
<strong>Self-hosted Server Backup Management Platform</strong><br>
|
||||
One binary, one command — manage all your server backups.
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://github.com/Awuqing/BackupX/stargazers"><img src="https://img.shields.io/github/stars/Awuqing/BackupX?style=flat-square&color=f5c542" alt="Stars"></a>
|
||||
<a href="https://github.com/Awuqing/BackupX/releases"><img src="https://img.shields.io/github/v/release/Awuqing/BackupX?style=flat-square&color=brightgreen" alt="Release"></a>
|
||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat-square&logo=go" alt="Go">
|
||||
<img src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat-square&logo=go" alt="Go">
|
||||
<img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react" alt="React">
|
||||
<img src="https://img.shields.io/badge/TypeScript-5-3178C6?style=flat-square&logo=typescript" alt="TypeScript">
|
||||
<img src="https://img.shields.io/badge/SQLite-embedded-003B57?style=flat-square&logo=sqlite" alt="SQLite">
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/Awuqing/BackupX?style=flat-square" alt="License"></a>
|
||||
<a href="https://github.com/Awuqing/BackupX/issues"><img src="https://img.shields.io/github/issues/Awuqing/BackupX?style=flat-square" alt="Issues"></a>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
BackupX is a self-hosted backup management platform for **Linux / macOS servers**. Through an enterprise-grade Web console, you can easily configure directory backups, database backups, and securely store backup files to Alibaba Cloud OSS, Tencent Cloud COS, Qiniu Cloud Kodo, Google Drive, S3-compatible storage, WebDAV, FTP/FTPS, or local disk.
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="screenshots/dashboard.png" alt="Dashboard"></td>
|
||||
<td width="50%"><img src="screenshots/backup-tasks.png" alt="Backup Tasks"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="screenshots/storage-targets.png" alt="Storage Targets"></td>
|
||||
<td><img src="screenshots/backup-records.png" alt="Backup Records"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
Supports **multi-node cluster management** for unified control of backup tasks across different servers.
|
||||
## Highlights
|
||||
|
||||
> **For**: Individual developers / small teams / DevOps with Linux servers
|
||||
| Capability | Details |
|
||||
|-----------|---------|
|
||||
| **Backup Types** | Files/Directories (multi-source), MySQL, PostgreSQL, SQLite, SAP HANA |
|
||||
| **Storage Backends** | Alibaba Cloud OSS, Tencent COS, Qiniu Kodo, S3-compatible (AWS/MinIO/R2), Google Drive, WebDAV, FTP/FTPS, Local Disk |
|
||||
| **Scheduling** | Cron-based scheduling + visual editor + auto-retention policy (by days/count) |
|
||||
| **Multi-Node** | Master-Agent cluster for managing backups across multiple servers |
|
||||
| **Security** | JWT + bcrypt + AES-256-GCM encrypted config + optional backup encryption + audit logs |
|
||||
| **Notifications** | Email / Webhook / Telegram — push on success or failure |
|
||||
| **Deployment** | Single binary + embedded SQLite, Docker one-click, zero external dependencies |
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Login
|
||||

|
||||
|
||||
### Dashboard
|
||||

|
||||
|
||||
### Backup Tasks
|
||||

|
||||
|
||||
### Backup Records
|
||||

|
||||
|
||||
### Storage Targets
|
||||

|
||||
|
||||
### Node Management
|
||||

|
||||
|
||||
### Notification Settings
|
||||

|
||||
|
||||
### System Settings
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
### 📦 Multiple Backup Types
|
||||
- **Files / Directories** — Custom exclude rules (e.g. `node_modules`, `*.log`)
|
||||
- **MySQL** — Via native `mysqldump` tool
|
||||
- **SQLite** — Safe file copy
|
||||
- **PostgreSQL** — Via native `pg_dump` tool
|
||||
- **SAP HANA** — Via native `hdbsql` tool (multi-tenant database support)
|
||||
|
||||
### ☁️ Multi-Cloud Storage Backends
|
||||
| Provider | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| 🇨🇳 **Alibaba Cloud OSS** | `aliyun_oss` | Auto endpoint assembly, internal network support |
|
||||
| 🇨🇳 **Tencent Cloud COS** | `tencent_cos` | Auto endpoint assembly |
|
||||
| 🇨🇳 **Qiniu Cloud Kodo** | `qiniu_kodo` | 6 region precise mapping |
|
||||
| 🌍 **S3 Compatible** | `s3` | AWS S3 / MinIO / Cloudflare R2, etc. |
|
||||
| 🌍 **Google Drive** | `google_drive` | Full OAuth 2.0 flow |
|
||||
| 🌍 **WebDAV** | `webdav` | Nextcloud / Nutstore, etc. |
|
||||
| 🌍 **FTP / FTPS** | `ftp` | Standard FTP protocol with Explicit TLS support |
|
||||
| 💾 **Local Disk** | `local_disk` | Backup to local server directory |
|
||||
|
||||
> Chinese cloud providers only require **Region** and **AccessKey** — the system auto-assembles the endpoint. Powered by the S3 engine under the hood with zero extra dependencies.
|
||||
|
||||
### 🖥️ Cluster Management (Master-Agent)
|
||||
- **Node Management** — Register remote server nodes with Token authentication
|
||||
- **Local Node** — Auto-created, zero-friction upgrade for single-machine users
|
||||
- **Directory Browser** — Visual file tree selector for backup source paths
|
||||
- **Agent Heartbeat** — Real-time node online status monitoring
|
||||
- **Task Tags** — Categorize and manage backup tasks by tags/nodes
|
||||
|
||||
### ⏰ Automation & Scheduling
|
||||
- Cron expression scheduling
|
||||
- Visual Cron editor
|
||||
- Auto-retention policy (by days / by count)
|
||||
- Max concurrent backup limit
|
||||
|
||||
### 🔐 Security
|
||||
- JWT authentication + bcrypt password hashing
|
||||
- AES-256-GCM encrypted sensitive config storage (DB passwords, OAuth tokens)
|
||||
- Optional backup file encryption
|
||||
- Login rate limiting (brute force protection)
|
||||
- Node Token authentication (one-time display, secure transport)
|
||||
|
||||
### 📊 Monitoring & Notifications
|
||||
- Dashboard stats (success rate, storage usage, backup trend charts)
|
||||
- Email / Webhook / Telegram notifications
|
||||
- Real-time backup execution logs (SSE)
|
||||
|
||||
### 🌐 Other
|
||||
- Chinese & English i18n
|
||||
- Zero external dependencies (embedded SQLite, single binary deployment)
|
||||
- systemd service support
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Build from Source
|
||||
### 1. Install
|
||||
|
||||
**Docker (recommended, no clone needed):**
|
||||
|
||||
```bash
|
||||
# Clone the project
|
||||
git clone https://github.com/Awuqing/BackupX.git
|
||||
cd BackupX
|
||||
# Create a docker-compose.yml then start
|
||||
docker compose up -d
|
||||
|
||||
# Build frontend and backend
|
||||
make build
|
||||
|
||||
# Start the backend service (default port :8340)
|
||||
cd server && ./bin/backupx
|
||||
# Or run directly
|
||||
docker run -d --name backupx -p 8340:8340 -v backupx-data:/app/data awuqing/backupx:latest
|
||||
```
|
||||
|
||||
### Access Web UI
|
||||
> Docker Hub: [`awuqing/backupx`](https://hub.docker.com/r/awuqing/backupx) — supports linux/amd64 and linux/arm64.
|
||||
|
||||
Open `http://your-server:8340` in your browser. First-time use will guide you through creating an admin account.
|
||||
|
||||
## Configuration
|
||||
|
||||
The config file defaults to `./config.yaml`. Settings can also be overridden via `BACKUPX_` prefixed environment variables.
|
||||
<details>
|
||||
<summary>docker-compose.yml reference</summary>
|
||||
|
||||
```yaml
|
||||
# config.yaml
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8340
|
||||
mode: "release" # debug | release
|
||||
services:
|
||||
backupx:
|
||||
image: awuqing/backupx:latest
|
||||
container_name: backupx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8340:8340"
|
||||
volumes:
|
||||
- backupx-data:/app/data
|
||||
# Mount host directories to back up (add as needed):
|
||||
# - /var/www:/mnt/www:ro
|
||||
# - /etc/nginx:/mnt/nginx-conf:ro
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
|
||||
database:
|
||||
path: "./data/backupx.db" # SQLite database path
|
||||
|
||||
security:
|
||||
jwt_secret: "" # Leave empty to auto-generate
|
||||
jwt_expire: "24h"
|
||||
encryption_key: "" # AES encryption key, auto-generated if empty
|
||||
|
||||
backup:
|
||||
temp_dir: "/tmp/backupx" # Backup temp directory
|
||||
max_concurrent: 2 # Max concurrent backups
|
||||
|
||||
log:
|
||||
level: "info" # debug | info | warn | error
|
||||
file: "./data/backupx.log"
|
||||
max_size: 100 # Max log file size (MB)
|
||||
max_backups: 3 # Number of old log files to retain
|
||||
max_age: 30 # Log retention days
|
||||
volumes:
|
||||
backupx-data:
|
||||
```
|
||||
|
||||
> 💡 `jwt_secret` and `encryption_key` are auto-generated on first startup and persisted to the database.
|
||||
</details>
|
||||
|
||||
## Architecture
|
||||
**Pre-built binaries (bare metal):**
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ Nginx (Reverse │
|
||||
│ Proxy) │
|
||||
│ / → Static Files │
|
||||
│ /api → :8340 │
|
||||
└─────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ BackupX Master (Go API Server) │
|
||||
│ :8340 │
|
||||
│ │
|
||||
│ ┌──────┐ ┌────────────┐ ┌───────────────────────┐│
|
||||
│ │ Auth │ │Backup Engine│ │ Storage Registry ││
|
||||
│ └──────┘ └──────┬─────┘ │ ┌─────────────────┐ ││
|
||||
│ │ │ │ Alibaba Cloud │ ││
|
||||
│ ┌──────────┐ │ │ │ Tencent Cloud │ ││
|
||||
│ │ Cron │◄───┘ │ │ Qiniu Cloud │ ││
|
||||
│ │Scheduler │ │ │ S3 Compatible │ ││
|
||||
│ └──────────┘ │ │ Google Drive │ ││
|
||||
│ │ │ WebDAV │ ││
|
||||
│ │ │ FTP / FTPS │ ││
|
||||
│ ┌──────────┐ │ │ Local Disk │ ││
|
||||
│ │ Notify │ │ └─────────────────┘ ││
|
||||
│ │ Module │ └───────────────────────┘│
|
||||
│ └──────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌────────────────────┐ │
|
||||
│ │ Node Manager │ │ SQLite (backupx.db)│ │
|
||||
│ └──────┬───────┘ └────────────────────┘ │
|
||||
└─────────┼────────────────────────────────────────────┘
|
||||
│ Heartbeat / Task Dispatch
|
||||
▼
|
||||
┌──────────────────┐ ┌──────────────────┐
|
||||
│ Agent Node A │ │ Agent Node B │
|
||||
│ (Remote Server)│ │ (Remote Server)│
|
||||
└──────────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
### Tech Stack
|
||||
|
||||
| Component | Technology |
|
||||
|-----------|-----------|
|
||||
| **Backend** | Go · Gin · GORM · SQLite · robfig/cron |
|
||||
| **Frontend** | React 18 · TypeScript · ArcoDesign · Vite · Zustand · ECharts |
|
||||
| **Storage** | AWS SDK v2 (S3/OSS/COS/Kodo) · Google Drive API v3 · gowebdav · jlaffaye/ftp |
|
||||
| **Security** | JWT · bcrypt · AES-256-GCM |
|
||||
| **Logging** | zap + lumberjack (auto-rotation) |
|
||||
|
||||
## Cluster Mode
|
||||
|
||||
BackupX supports **Master-Agent** mode for managing backup tasks across multiple servers.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Master** is the server running the BackupX Web console
|
||||
2. **Agent** is deployed on remote servers that need to be backed up
|
||||
3. Agents register with the Master using a Token and send periodic heartbeats
|
||||
4. Master dispatches backup tasks to the corresponding Agent for execution
|
||||
|
||||
### Adding Nodes
|
||||
Download from [Releases](https://github.com/Awuqing/BackupX/releases):
|
||||
|
||||
```bash
|
||||
# In Web Console → Node Management → Add Node
|
||||
# The system generates a unique 64-character hex Token
|
||||
|
||||
# Configure the Agent on the remote server
|
||||
./backupx-agent --master http://master-server:8340 --token <your-token>
|
||||
tar xzf backupx-v*-linux-amd64.tar.gz && cd backupx-*
|
||||
sudo ./install.sh # Auto-configures systemd + Nginx
|
||||
```
|
||||
|
||||
### Directory Probe API
|
||||
|
||||
Master provides `GET /api/nodes/:id/fs/list?path=/` to remotely browse a node's file system. The frontend uses a tree selector to browse the target machine's directory structure when creating backup tasks.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
BackupX/
|
||||
├── server/ # Go backend
|
||||
│ ├── cmd/backupx/ # Entry point
|
||||
│ ├── internal/
|
||||
│ │ ├── app/ # App assembly (DI)
|
||||
│ │ ├── apperror/ # Unified error types
|
||||
│ │ ├── backup/ # Backup engine (file/mysql/sqlite/pgsql/saphana)
|
||||
│ │ │ └── retention/ # Retention policy
|
||||
│ │ ├── config/ # Config loading (viper)
|
||||
│ │ ├── database/ # Database init + migrations
|
||||
│ │ ├── http/ # HTTP handlers + routes + middleware
|
||||
│ │ ├── httpapi/ # HTTP API helpers
|
||||
│ │ ├── logger/ # Logger init (zap + lumberjack)
|
||||
│ │ ├── model/ # GORM data models
|
||||
│ │ ├── notify/ # Notifications (email/webhook/telegram)
|
||||
│ │ ├── repository/ # Data access layer
|
||||
│ │ ├── scheduler/ # Cron scheduler
|
||||
│ │ ├── security/ # JWT + rate limiting
|
||||
│ │ ├── service/ # Business logic
|
||||
│ │ └── storage/ # Storage backends (plugin interface)
|
||||
│ │ ├── aliyun/ # Alibaba Cloud OSS
|
||||
│ │ ├── tencent/ # Tencent Cloud COS
|
||||
│ │ ├── qiniu/ # Qiniu Cloud Kodo
|
||||
│ │ ├── s3/ # S3 Compatible core
|
||||
│ │ ├── s3provider/ # S3 Provider helper
|
||||
│ │ ├── googledrive/ # Google Drive
|
||||
│ │ ├── webdav/ # WebDAV core
|
||||
│ │ ├── webdavprovider/ # WebDAV Provider helper
|
||||
│ │ ├── localdisk/ # Local disk
|
||||
│ │ ├── ftp/ # FTP / FTPS
|
||||
│ │ └── codec/ # Config codec
|
||||
│ └── pkg/ # Utilities (compress/crypto/response)
|
||||
├── web/ # React frontend
|
||||
│ └── src/
|
||||
│ ├── components/ # Shared components (CronEditor/FormDrawer/...)
|
||||
│ ├── hooks/ # Custom Hooks
|
||||
│ ├── layouts/ # Layout components (AppLayout)
|
||||
│ ├── pages/ # Page modules
|
||||
│ │ ├── dashboard/ # Dashboard
|
||||
│ │ ├── backup-tasks/ # Backup tasks
|
||||
│ │ ├── backup-records/ # Backup records
|
||||
│ │ ├── storage-targets/ # Storage targets
|
||||
│ │ ├── nodes/ # Node management
|
||||
│ │ ├── notifications/ # Notification settings
|
||||
│ │ ├── settings/ # System settings
|
||||
│ │ └── login/ # Login page
|
||||
│ ├── services/ # API request wrappers
|
||||
│ ├── stores/ # Zustand state management
|
||||
│ ├── styles/ # Global styles
|
||||
│ ├── types/ # TypeScript type definitions
|
||||
│ ├── utils/ # Utility functions
|
||||
│ ├── locales/ # i18n language packs (zh-CN / en-US)
|
||||
│ └── router/ # Route configuration
|
||||
├── deploy/ # Deployment configs
|
||||
│ ├── nginx.conf # Nginx reference config
|
||||
│ ├── backupx.service # systemd service unit
|
||||
│ └── install.sh # One-click install script
|
||||
├── .github/ # GitHub configuration
|
||||
│ ├── workflows/ci.yml # CI workflow
|
||||
│ ├── workflows/release.yml # Release workflow
|
||||
│ └── ISSUE_TEMPLATE/ # Issue templates
|
||||
└── Makefile # Build commands
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Go** ≥ 1.21
|
||||
- **Node.js** ≥ 18
|
||||
- **npm**
|
||||
|
||||
### Dev Mode
|
||||
**Build from source:**
|
||||
|
||||
```bash
|
||||
# Terminal 1: Start backend (use air for hot-reload)
|
||||
make dev-server
|
||||
|
||||
# Terminal 2: Start frontend (Vite HMR)
|
||||
make dev-web
|
||||
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
|
||||
make build # Build frontend + backend
|
||||
make docker-cn # Or Docker build with China mirrors (goproxy.cn / npmmirror / Aliyun apk)
|
||||
```
|
||||
|
||||
### Run Tests
|
||||
### 2. Open the Console
|
||||
|
||||
Visit `http://your-server:8340` in your browser. First-time access guides you through admin account creation.
|
||||
|
||||
### 3. Add a Storage Target
|
||||
|
||||
Go to **Storage Targets** → **Add**, choose a storage type and enter credentials:
|
||||
|
||||
| Storage Type | Required Fields |
|
||||
|-------------|----------------|
|
||||
| Alibaba Cloud OSS | Region + AccessKey ID/Secret + Bucket |
|
||||
| Tencent Cloud COS | Region + SecretId/SecretKey + Bucket (`name-appid`) |
|
||||
| Qiniu Cloud Kodo | Region + AccessKey/SecretKey + Bucket |
|
||||
| S3 Compatible | Endpoint + AccessKey + Bucket |
|
||||
| Google Drive | Client ID/Secret → click Authorize for OAuth |
|
||||
| WebDAV | Server URL + Username/Password |
|
||||
| FTP | Host + Port + Username/Password |
|
||||
| Local Disk | Target directory path |
|
||||
|
||||
Click **Test Connection** to verify.
|
||||
|
||||
### 4. Create a Backup Task
|
||||
|
||||
Go to **Backup Tasks** → **Create**, complete 3 steps:
|
||||
|
||||
1. **Basic Info** — Task name, backup type, Cron expression (leave empty for manual-only)
|
||||
2. **Source Config** — File backup: select source paths (supports multiple); Database: enter connection info
|
||||
3. **Storage & Policy** — Select storage target(s), compression, retention days, encryption toggle
|
||||
|
||||
Save, then click **Run Now** to test. View real-time logs in **Backup Records**.
|
||||
|
||||
### 5. Set Up Notifications (Optional)
|
||||
|
||||
Go to **Notifications** to configure Email, Webhook, or Telegram alerts for backup success/failure.
|
||||
|
||||
---
|
||||
|
||||
## Deployment Guide
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
make test
|
||||
|
||||
# Backend only
|
||||
make test-server # go test ./...
|
||||
|
||||
# Frontend only
|
||||
make test-web # npm run test
|
||||
docker compose up -d # Using the docker-compose.yml above
|
||||
```
|
||||
|
||||
### Build
|
||||
Mount host directories for file backup (add to `volumes` in docker-compose.yml):
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- backupx-data:/app/data
|
||||
- /var/www:/mnt/www:ro
|
||||
- /etc/nginx:/mnt/nginx-conf:ro
|
||||
```
|
||||
|
||||
Override config via environment variables:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
- BACKUPX_LOG_LEVEL=debug
|
||||
- BACKUPX_BACKUP_MAX_CONCURRENT=4
|
||||
```
|
||||
|
||||
### Bare Metal
|
||||
|
||||
```bash
|
||||
# Build frontend and backend
|
||||
# From pre-built package
|
||||
tar xzf backupx-v*-linux-amd64.tar.gz && cd backupx-*
|
||||
sudo ./install.sh
|
||||
|
||||
# Or from source
|
||||
make build
|
||||
|
||||
# Clean build artifacts
|
||||
make clean
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### One-Click Install (Recommended)
|
||||
|
||||
```bash
|
||||
# Build first
|
||||
make build
|
||||
|
||||
# Run install script as root
|
||||
sudo ./deploy/install.sh
|
||||
```
|
||||
|
||||
The install script will automatically:
|
||||
1. Create a `backupx` system user
|
||||
2. Install the binary to `/opt/backupx/bin/`
|
||||
3. Deploy the frontend to `/opt/backupx/web/`
|
||||
4. Generate config at `/etc/backupx/config.yaml`
|
||||
5. Register and start the systemd service
|
||||
6. Configure Nginx reverse proxy (if installed)
|
||||
The install script creates a system user, installs to `/opt/backupx/`, configures systemd, and sets up Nginx reverse proxy.
|
||||
|
||||
### Manual Deployment
|
||||
|
||||
```bash
|
||||
# 1. Build
|
||||
cd server && go build -o backupx ./cmd/backupx
|
||||
cd ../web && npm run build
|
||||
|
||||
# 2. Deploy files
|
||||
scp server/backupx your-server:/opt/backupx/bin/
|
||||
scp -r web/dist/ your-server:/opt/backupx/web/
|
||||
scp server/config.example.yaml your-server:/etc/backupx/config.yaml
|
||||
|
||||
# 3. Start
|
||||
ssh your-server '/opt/backupx/bin/backupx -config /etc/backupx/config.yaml'
|
||||
```
|
||||
|
||||
### Nginx Config Example
|
||||
### Nginx Reverse Proxy (bare metal)
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name backup.example.com;
|
||||
|
||||
# Frontend static files
|
||||
location / {
|
||||
root /opt/backupx/web;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API reverse proxy
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8340;
|
||||
proxy_set_header Host $host;
|
||||
@@ -410,89 +199,120 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Config file: `./config.yaml` (or override with `BACKUPX_` prefixed env vars):
|
||||
|
||||
```yaml
|
||||
server:
|
||||
port: 8340
|
||||
database:
|
||||
path: "./data/backupx.db"
|
||||
security:
|
||||
jwt_secret: "" # Auto-generated and persisted to DB
|
||||
encryption_key: "" # Auto-generated
|
||||
backup:
|
||||
temp_dir: "/tmp/backupx"
|
||||
max_concurrent: 2
|
||||
log:
|
||||
level: "info" # debug | info | warn | error
|
||||
file: "./data/backupx.log"
|
||||
```
|
||||
|
||||
### Password Reset
|
||||
|
||||
```bash
|
||||
# Bare metal
|
||||
./backupx reset-password --username admin --password newpass123
|
||||
|
||||
# Docker
|
||||
docker exec -it backupx /app/bin/backupx reset-password --username admin --password newpass123
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Multi-Node Cluster
|
||||
|
||||
BackupX supports Master-Agent mode for managing multiple servers:
|
||||
|
||||
1. Web Console → **Node Management** → **Add Node** — system generates a Token
|
||||
2. Deploy Agent on remote server, connect using the Token
|
||||
3. Create backup tasks and assign to specific nodes — Master dispatches automatically
|
||||
|
||||
The visual directory browser lets you pick directories on remote Agent nodes — no manual path typing.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
**Requirements:** Go >= 1.25 · Node.js >= 20 · npm
|
||||
|
||||
```bash
|
||||
# Dev mode
|
||||
make dev-server # Terminal 1: backend (:8340)
|
||||
make dev-web # Terminal 2: frontend (Vite HMR)
|
||||
|
||||
# Test
|
||||
make test # Run all tests
|
||||
|
||||
# Build
|
||||
make build # Build frontend + backend
|
||||
make docker # Docker build
|
||||
make docker-cn # Docker build with China mirrors
|
||||
```
|
||||
|
||||
### Release
|
||||
|
||||
```bash
|
||||
git tag v1.2.3 && git push --tags
|
||||
# GitHub Actions: compile dual-arch binaries → publish GitHub Release → push Docker Hub image
|
||||
```
|
||||
|
||||
Or manually trigger the Release workflow from GitHub Actions page.
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
All APIs are prefixed with `/api` and use JWT Bearer Token authentication (unless noted otherwise).
|
||||
All endpoints prefixed with `/api`, authenticated via JWT Bearer Token.
|
||||
|
||||
| Module | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| **Auth** | `POST /api/auth/setup` | Initialize admin (first time) |
|
||||
| | `POST /api/auth/login` | Login to get Token |
|
||||
| | `POST /api/auth/logout` | Logout |
|
||||
| | `GET /api/auth/profile` | Current user info |
|
||||
| | `PUT /api/auth/password` | Change password |
|
||||
| **Backup Tasks** | `GET/POST /api/backup/tasks` | List / Create tasks |
|
||||
| | `GET/PUT/DELETE /api/backup/tasks/:id` | Detail / Update / Delete |
|
||||
| | `PUT /api/backup/tasks/:id/toggle` | Enable / Disable |
|
||||
| | `POST /api/backup/tasks/:id/run` | Trigger manual execution |
|
||||
| **Backup Records** | `GET /api/backup/records` | List records (with filter) |
|
||||
| | `GET /api/backup/records/:id` | Record detail |
|
||||
| | `GET /api/backup/records/:id/logs/stream` | Real-time execution logs (SSE) |
|
||||
| | `GET /api/backup/records/:id/download` | Download backup file |
|
||||
| | `POST /api/backup/records/:id/restore` | Restore backup |
|
||||
| **Storage Targets** | `GET/POST /api/storage-targets` | List / Add targets |
|
||||
| | `GET/PUT/DELETE /api/storage-targets/:id` | Detail / Update / Delete |
|
||||
| | `POST /api/storage-targets/test` | Test connection |
|
||||
| | `POST /api/storage-targets/:id/test` | Test saved connection |
|
||||
| | `GET /api/storage-targets/:id/usage` | Query usage |
|
||||
| **Nodes** | `GET/POST /api/nodes` | List / Add nodes |
|
||||
| | `GET/DELETE /api/nodes/:id` | Detail / Delete |
|
||||
| | `GET /api/nodes/:id/fs/list` | Directory browser |
|
||||
| | `POST /api/agent/heartbeat` | Agent heartbeat ⚡ |
|
||||
| **Notifications** | `GET/POST /api/notifications` | List / Add |
|
||||
| | `POST /api/notifications/test` | Test notification |
|
||||
| | `POST /api/notifications/:id/test` | Test saved notification |
|
||||
| **Dashboard** | `GET /api/dashboard/stats` | Overview statistics |
|
||||
| | `GET /api/dashboard/timeline` | Backup trend timeline |
|
||||
| **System** | `GET /api/system/info` | System info (version/disk) |
|
||||
| | `GET/PUT /api/settings` | System settings |
|
||||
| **Auth** | `POST /auth/setup` | Initialize admin |
|
||||
| | `POST /auth/login` | Login |
|
||||
| | `PUT /auth/password` | Change password |
|
||||
| **Backup Tasks** | `GET\|POST /backup/tasks` | List / Create |
|
||||
| | `GET\|PUT\|DELETE /backup/tasks/:id` | Detail / Update / Delete |
|
||||
| | `PUT /backup/tasks/:id/toggle` | Enable / Disable |
|
||||
| | `POST /backup/tasks/:id/run` | Manual run |
|
||||
| **Backup Records** | `GET /backup/records` | List (with filter) |
|
||||
| | `GET /backup/records/:id/logs/stream` | Real-time logs (SSE) |
|
||||
| | `GET /backup/records/:id/download` | Download |
|
||||
| | `POST /backup/records/:id/restore` | Restore |
|
||||
| **Storage Targets** | `GET\|POST /storage-targets` | List / Add |
|
||||
| | `POST /storage-targets/test` | Test connection |
|
||||
| **Nodes** | `GET\|POST /nodes` | List / Add |
|
||||
| | `GET /nodes/:id/fs/list` | Directory browser |
|
||||
| **Notifications** | `GET\|POST /notifications` | List / Add |
|
||||
| **Dashboard** | `GET /dashboard/stats` | Overview stats |
|
||||
| **Audit Logs** | `GET /audit-logs` | Operation audit |
|
||||
| **System** | `GET /system/info` | System info |
|
||||
|
||||
> ⚡ `POST /api/agent/heartbeat` is a public endpoint authenticated via Node Token instead of JWT.
|
||||
---
|
||||
|
||||
## Cloud Storage Setup Guide
|
||||
## Tech Stack
|
||||
|
||||
### Alibaba Cloud OSS
|
||||
|
||||
1. Log in to [Alibaba Cloud Console](https://oss.console.aliyun.com/), create a Bucket
|
||||
2. Go to RAM Console to create an AccessKey
|
||||
3. Select "Alibaba Cloud OSS" when adding a storage target in BackupX
|
||||
4. Enter the Region (e.g. `cn-hangzhou`) and AccessKey — the system auto-assembles the endpoint
|
||||
|
||||
### Tencent Cloud COS
|
||||
|
||||
1. Log in to [Tencent Cloud Console](https://console.cloud.tencent.com/cos), create a bucket
|
||||
2. Go to API Key Management to create SecretId/SecretKey
|
||||
3. Bucket name format is `BucketName-APPID` (e.g. `backup-1250000000`)
|
||||
|
||||
### Qiniu Cloud Kodo
|
||||
|
||||
1. Log in to [Qiniu Cloud Console](https://portal.qiniu.com/), create a storage space
|
||||
2. Supported regions: `z0` (East China) / `cn-east-2` (East China-Zhejiang 2) / `z1` (North China) / `z2` (South China) / `na0` (North America) / `as0` (Southeast Asia)
|
||||
|
||||
### Google Drive
|
||||
|
||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/) and create a project
|
||||
2. Enable the **Google Drive API**
|
||||
3. Create an **OAuth 2.0 Client ID** (Web application type)
|
||||
4. Add redirect URI: `http://your-server/api/storage-targets/google-drive/callback`
|
||||
5. Enter the Client ID / Secret in BackupX storage management and click Authorize
|
||||
| Component | Technology |
|
||||
|-----------|-----------|
|
||||
| **Backend** | Go · Gin · GORM · SQLite · robfig/cron |
|
||||
| **Frontend** | React 18 · TypeScript · ArcoDesign · Vite · Zustand · ECharts |
|
||||
| **Storage** | AWS SDK v2 · Google Drive API v3 · gowebdav · jlaffaye/ftp |
|
||||
| **Security** | JWT · bcrypt · AES-256-GCM |
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues and Pull Requests are welcome!
|
||||
|
||||
1. Fork this repository
|
||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Commit your changes (`git commit -m 'Add amazing feature'`)
|
||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the [Apache License 2.0](LICENSE).
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
Made with ❤️ for self-hosters
|
||||
</p>
|
||||
[Apache License 2.0](LICENSE)
|
||||
|
||||
23
deploy/docker/entrypoint.sh
Normal file
23
deploy/docker/entrypoint.sh
Normal file
@@ -0,0 +1,23 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Backend listens on internal port 8341, Nginx exposes 8340
|
||||
export BACKUPX_SERVER_PORT="${BACKUPX_SERVER_PORT_INTERNAL:-8341}"
|
||||
|
||||
# Start Nginx in background
|
||||
nginx -g "daemon off;" &
|
||||
NGINX_PID=$!
|
||||
|
||||
# Start BackupX backend
|
||||
/app/bin/backupx &
|
||||
APP_PID=$!
|
||||
|
||||
# Trap signals for graceful shutdown
|
||||
trap 'kill $APP_PID $NGINX_PID 2>/dev/null; wait $APP_PID $NGINX_PID 2>/dev/null' SIGTERM SIGINT
|
||||
|
||||
echo "BackupX started — Nginx :8340 -> Backend :8341"
|
||||
|
||||
# Wait for either process to exit
|
||||
wait -n $APP_PID $NGINX_PID 2>/dev/null || true
|
||||
kill $APP_PID $NGINX_PID 2>/dev/null || true
|
||||
wait $APP_PID $NGINX_PID 2>/dev/null || true
|
||||
32
deploy/docker/nginx.conf
Normal file
32
deploy/docker/nginx.conf
Normal file
@@ -0,0 +1,32 @@
|
||||
server {
|
||||
listen 8340;
|
||||
server_name _;
|
||||
|
||||
root /app/web;
|
||||
index index.html;
|
||||
|
||||
# API reverse proxy to backend
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8341/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Static assets cache
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
29
docker-compose.yml
Normal file
29
docker-compose.yml
Normal file
@@ -0,0 +1,29 @@
|
||||
# BackupX Docker Compose
|
||||
#
|
||||
# 快速启动:docker compose up -d
|
||||
# 访问地址:http://localhost:8340
|
||||
#
|
||||
# 如需从源码构建镜像(而非拉取线上镜像),取消注释 build 行并注释 image 行。
|
||||
|
||||
services:
|
||||
backupx:
|
||||
image: awuqing/backupx:latest
|
||||
# build: . # 从源码构建时取消此行注释
|
||||
container_name: backupx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8340:8340"
|
||||
volumes:
|
||||
- backupx-data:/app/data
|
||||
# 挂载需要备份的宿主机目录(按需添加,:ro 表示只读):
|
||||
# - /var/www:/mnt/www:ro
|
||||
# - /etc/nginx:/mnt/nginx-conf:ro
|
||||
# - /home/user/data:/mnt/data:ro
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
# 通过 BACKUPX_ 前缀环境变量覆盖配置:
|
||||
# - BACKUPX_LOG_LEVEL=debug
|
||||
# - BACKUPX_BACKUP_MAX_CONCURRENT=4
|
||||
|
||||
volumes:
|
||||
backupx-data:
|
||||
@@ -10,11 +10,21 @@ import (
|
||||
|
||||
"backupx/server/internal/app"
|
||||
"backupx/server/internal/config"
|
||||
"backupx/server/internal/security"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
gormlogger "gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
// 子命令分发:reset-password
|
||||
if len(os.Args) > 1 && os.Args[1] == "reset-password" {
|
||||
runResetPassword(os.Args[2:])
|
||||
return
|
||||
}
|
||||
|
||||
var configPath string
|
||||
var showVersion bool
|
||||
|
||||
@@ -48,3 +58,58 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// runResetPassword 通过 CLI 直接操作 SQLite 重置用户密码,无需完整 app 初始化。
|
||||
// 用法:backupx reset-password --username admin --password newpass123 [--config path]
|
||||
func runResetPassword(args []string) {
|
||||
fs := flag.NewFlagSet("reset-password", flag.ExitOnError)
|
||||
username := fs.String("username", "admin", "要重置密码的用户名")
|
||||
password := fs.String("password", "", "新密码(至少 8 个字符)")
|
||||
configPath := fs.String("config", "", "配置文件路径")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if *password == "" {
|
||||
fmt.Fprintln(os.Stderr, "错误:--password 参数为必填项")
|
||||
fs.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
if len(*password) < 8 {
|
||||
fmt.Fprintln(os.Stderr, "错误:密码长度至少 8 个字符")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
cfg, err := config.Load(*configPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "加载配置失败:%v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(cfg.Database.Path), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "打开数据库失败:%v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var count int64
|
||||
db.Table("users").Where("username = ?", *username).Count(&count)
|
||||
if count == 0 {
|
||||
fmt.Fprintf(os.Stderr, "错误:用户 %q 不存在\n", *username)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
hash, err := security.HashPassword(*password)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "密码哈希失败:%v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
result := db.Table("users").Where("username = ?", *username).Update("password_hash", hash)
|
||||
if result.Error != nil {
|
||||
fmt.Fprintf(os.Stderr, "密码更新失败:%v\n", result.Error)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("用户 %q 密码已重置成功\n", *username)
|
||||
}
|
||||
|
||||
146
server/go.mod
146
server/go.mod
@@ -3,93 +3,153 @@ module backupx/server
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.3
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.11
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/natefinch/lumberjack v2.0.0+incompatible
|
||||
github.com/rclone/rclone v1.73.3
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/spf13/viper v1.20.0
|
||||
github.com/studio-b12/gowebdav v0.12.0
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.45.0
|
||||
golang.org/x/oauth2 v0.25.0
|
||||
google.golang.org/api v0.215.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
google.golang.org/api v0.255.0
|
||||
gorm.io/gorm v1.25.12
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/auth v0.13.0 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.6.0 // indirect
|
||||
cloud.google.com/go/auth v0.17.0 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.0.2-0.20251110135918-10b7b7e7cd26 // indirect
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/IBM/go-sdk-core/v5 v5.18.5 // indirect
|
||||
github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd // indirect
|
||||
github.com/abbot/go-http-auth v0.4.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 // 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.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 // indirect
|
||||
github.com/aws/smithy-go v1.24.2 // indirect
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/buger/jsonparser v1.1.2 // indirect
|
||||
github.com/bytedance/sonic v1.13.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/coreos/go-semver v0.3.1 // indirect
|
||||
github.com/coreos/go-systemd/v22 v22.6.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/ebitengine/purego v0.9.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.8.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
|
||||
github.com/gin-contrib/sse v1.0.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-chi/chi/v5 v5.2.5 // indirect
|
||||
github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/go-openapi/errors v0.22.4 // indirect
|
||||
github.com/go-openapi/strfmt v0.25.0 // 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.20.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/s2a-go v0.1.8 // indirect
|
||||
github.com/go-playground/validator/v10 v10.28.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/google/pprof v0.0.0-20240509144519-723abb6459b7 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.0.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/jlaffaye/ftp v0.2.0 // indirect
|
||||
github.com/jlaffaye/ftp v0.2.1-0.20240918233326-1b970516f5d3 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/jzelinskie/whirlpool v0.0.0-20201016144138-0675e54bb004 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/lanrat/extsort v1.4.2 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
|
||||
github.com/mailru/easyjson v0.9.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // 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.3 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/ncw/swift/v2 v2.0.5 // indirect
|
||||
github.com/oklog/ulid v1.3.1 // indirect
|
||||
github.com/onsi/gomega v1.34.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/peterh/liner v1.2.2 // indirect
|
||||
github.com/pkg/xattr v0.4.12 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.2 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rfjakob/eme v1.1.2 // indirect
|
||||
github.com/sagikazarmark/locafero v0.7.0 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.25.10 // indirect
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.12.0 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.7.1 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
||||
github.com/tklauser/numcpus v0.10.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
|
||||
go.opentelemetry.io/otel v1.29.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.29.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.29.0 // indirect
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
github.com/zeebo/assert v1.3.1 // indirect
|
||||
github.com/zeebo/blake3 v0.2.4 // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
go.mongodb.org/mongo-driver v1.17.6 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
|
||||
go.opentelemetry.io/otel v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 // indirect
|
||||
google.golang.org/grpc v1.67.3 // indirect
|
||||
google.golang.org/protobuf v1.36.5 // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/arch v0.14.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/term v0.40.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
google.golang.org/grpc v1.79.3 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
|
||||
537
server/go.sum
537
server/go.sum
@@ -1,21 +1,79 @@
|
||||
cloud.google.com/go/auth v0.13.0 h1:8Fu8TZy167JkW8Tj3q7dIkr2v4cndv41ouecJx0PAHs=
|
||||
cloud.google.com/go/auth v0.13.0/go.mod h1:COOjD9gwfKNKz+IIduatIhYJQIc0mG3H102r/EMxX6Q=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.6 h1:V6a6XDu2lTwPZWOawrAa9HUK+DB2zfJyTuciBG5hFkU=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.6/go.mod h1:AlmsELtlEBnaNTL7jCj8VQFLy6mbZv0s4Q7NGBeQ5E8=
|
||||
cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
|
||||
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
|
||||
cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4=
|
||||
cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.0 h1:KpMC6LFL7mqpExyMC9jVOYRiVhLmamjeZfRsUpB7l4s=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.0/go.mod h1:J7MUC/wtRpfGVbQ5sIItY5/FuVWmvzlY21WAOfQnq/I=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3 h1:ZJJNFaQ86GVKQ9ehwqyAFE6pIfyicpuJ8IkVaPBc6/4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3/go.mod h1:URuDvhmATVKqHBH9/0nOiNKk0+YcwfQ3WkK5PqHKxc8=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.3 h1:sxgSqOB9CDToiaVFpxuvb5wGgGqWa3lCShcm5o0n3bE=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.3/go.mod h1:XdED8i399lEVblYHTZM8eXaP07gv4Z58IL6ueMlVlrg=
|
||||
github.com/Azure/go-ntlmssp v0.0.2-0.20251110135918-10b7b7e7cd26 h1:gy/jrlpp8EfSyA73a51fofoSfhp5rPNQAUvDr4Dm91c=
|
||||
github.com/Azure/go-ntlmssp v0.0.2-0.20251110135918-10b7b7e7cd26/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/FilenCloudDienste/filen-sdk-go v0.0.37 h1:W8S9TrAyZ4//3PXsU6+Bi+fe/6uIL986GyS7PVzIDL4=
|
||||
github.com/FilenCloudDienste/filen-sdk-go v0.0.37/go.mod h1:0cBhKXQg49XbKZZfk5TCDa3sVLP+xMxZTWL+7KY0XR0=
|
||||
github.com/Files-com/files-sdk-go/v3 v3.2.264 h1:lMHTplAYI9FtmCo/QOcpRxmPA5REVAct1r2riQmDQKw=
|
||||
github.com/Files-com/files-sdk-go/v3 v3.2.264/go.mod h1:wGqkOzRu/ClJibvDgcfuJNAqI2nLhe8g91tPlDKRCdE=
|
||||
github.com/IBM/go-sdk-core/v5 v5.18.5 h1:g0JRl3sYXJczB/yuDlrN6x22LJ6jIxhp0Sa4ARNW60c=
|
||||
github.com/IBM/go-sdk-core/v5 v5.18.5/go.mod h1:KonTFRR+8ZSgw5cxBSYo6E4WZoY1+7n1kfHM82VcjFU=
|
||||
github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd h1:nzE1YQBdx1bq9IlZinHa+HVffy+NmVRoKr+wHN8fpLE=
|
||||
github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd/go.mod h1:C8yoIfvESpM3GD07OCHU7fqI7lhwyZ2Td1rbNbTAhnc=
|
||||
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
|
||||
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
|
||||
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf h1:yc9daCCYUefEs69zUkSzubzjBbL+cmOXgnmt9Fyd9ug=
|
||||
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf/go.mod h1:o0ESU9p83twszAU8LBeJKFAAMX14tISa0yk4Oo5TOqo=
|
||||
github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e h1:lCsqUUACrcMC83lg5rTo9Y0PnPItE61JSfvMyIcANwk=
|
||||
github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e/go.mod h1:Og5/Dz1MiGpCJn51XujZwxiLG7WzvvjE5PRpZBQmAHo=
|
||||
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
|
||||
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
|
||||
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ekTTXpdwKYF8eBlsYsDVoggDAuAjoK66k=
|
||||
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw=
|
||||
github.com/ProtonMail/go-srp v0.0.7 h1:Sos3Qk+th4tQR64vsxGIxYpN3rdnG9Wf9K4ZloC1JrI=
|
||||
github.com/ProtonMail/go-srp v0.0.7/go.mod h1:giCp+7qRnMIcCvI6V6U3S1lDDXDQYx2ewJ6F/9wdlJk=
|
||||
github.com/ProtonMail/gopenpgp/v2 v2.9.0 h1:ruLzBmwe4dR1hdnrsEJ/S7psSBmV15gFttFUPP/+/kE=
|
||||
github.com/ProtonMail/gopenpgp/v2 v2.9.0/go.mod h1:IldDyh9Hv1ZCCYatTuuEt1XZJ0OPjxLpTarDfglih7s=
|
||||
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
|
||||
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
|
||||
github.com/a1ex3/zstd-seekable-format-go/pkg v0.10.0 h1:iLDOF0rdGTrol/q8OfPIIs5kLD8XvA2q75o6Uq/tgak=
|
||||
github.com/a1ex3/zstd-seekable-format-go/pkg v0.10.0/go.mod h1:DrEWcQJjz7t5iF2duaiyhg4jyoF0kxOD6LtECNGkZ/Q=
|
||||
github.com/aalpar/deheap v0.0.0-20210914013432-0cc84d79dec3 h1:hhdWprfSpFbN7lz3W1gM40vOgvSh1WCSMxYD6gGB4Hs=
|
||||
github.com/aalpar/deheap v0.0.0-20210914013432-0cc84d79dec3/go.mod h1:XaUnRxSCYgL3kkgX0QHIV0D+znljPIDImxlv2kbGv0Y=
|
||||
github.com/abbot/go-http-auth v0.4.0 h1:QjmvZ5gSC7jm3Zg54DqWE/T5m1t2AfDu6QlXJT0EVT0=
|
||||
github.com/abbot/go-http-auth v0.4.0/go.mod h1:Cz6ARTIzApMJDzh5bRMSUou6UMSp0IEXg9km/ci7TJM=
|
||||
github.com/anchore/go-lzo v0.1.0 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs=
|
||||
github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/appscode/go-querystring v0.0.0-20170504095604-0126cfb3f1dc h1:LoL75er+LKDHDUfU5tRvFwxH0LjPpZN8OoG8Ll+liGU=
|
||||
github.com/appscode/go-querystring v0.0.0-20170504095604-0126cfb3f1dc/go.mod h1:w648aMHEgFYS6xb0KVMMtZ2uMeemhiKCuD2vj6gY52A=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 h1:N4lRUXZpZ1KVEUn6hxtco/1d2lgYhNn1fHkkl8WhlyQ=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.17 h1:QFl8lL6RgakNK86vusim14P2k8BFSxjvUkcWLDjgz9Y=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.17/go.mod h1:V8P7ILjp/Uef/aX8TjGk6OHZN6IKPM5YW6S78QnRD5c=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.11 h1:NdV8cwCcAXrCWyxArt58BrvZJ9pZ9Fhf9w6Uh5W3Uyc=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.11/go.mod h1:30yY2zqkMPdrvxBqzI9xQCM+WrlrZKSOpSJEsylVU+8=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 h1:INUvJxmhdEbVulJYHI061k4TVuS3jzzthNvjqvVvTKM=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19/go.mod h1:FpZN2QISLdEBWkayloda+sZjVJL+e9Gl0k1SyTgcswU=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.4 h1:2fjfz3/G9BRvIKuNZ655GwzpklC2kEH0cowZQGO7uBg=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.4/go.mod h1:Ymws824lvMypLFPwyyUXM52SXuGgxpu0+DISLfKvB+c=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 h1:/sECfyq2JTifMI2JPyZ4bdRN77zJmr6SrS1eL3augIA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19/go.mod h1:dMf8A5oAqr9/oxOfLkC/c2LU/uMcALP0Rgn2BD5LWn0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 h1:AWeJMk33GTBf6J20XJe6qZoRSJo0WfUhsMdUKhoODXE=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19/go.mod h1:+GWrYoaAsV7/4pNHpwh1kiNLXkKaSoppxQq9lbH8Ejw=
|
||||
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.20 h1:qi3e/dmpdONhj1RyIZdi6DKKpDXS5Lb8ftr3p7cyHJc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.20/go.mod h1:V1K+TeJVD5JOk3D9e5tsX2KUdL7BlB+FV6cBhdobN8c=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 h1:XAq62tBTJP/85lFD5oqOOe7YYgWxY9LvWq8plyDvDVg=
|
||||
@@ -28,188 +86,474 @@ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19 h1:JnQeStZvPHFHe
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19/go.mod h1:HGyasyHvYdFQeJhvDHfH7HXkHh57htcJGKDZ+7z+I24=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4 h1:4ExZyubQ6LQQVuF2Qp9OsfEvsTdAWh5Gfwf6PgIdLdk=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4/go.mod h1:NF3JcMGOiARAss1ld3WGORCw71+4ExDD2cbbdKS5PpA=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 h1:iSsvB9EtQ09YrsmIc44Heqlx5ByGErqhPK1ZQLppias=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12/go.mod h1:fEWYKTRGoZNl8tZ77i61/ccwOMJdGxwOhWCkp6TXAr0=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 h1:EnUdUqRP1CNzt2DkV67tJx6XDN4xlfBFm+bzeNOQVb0=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16/go.mod h1:Jic/xv0Rq/pFNCh3WwpH4BEqdbSAl+IyHro8LbibHD8=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 h1:XQTQTF75vnug2TXS8m7CVJfC2nniYPZnO1D4Np761Oo=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8/go.mod h1:Xgx+PR1NUOjNmQY+tRMnouRp83JRM8pRMw/vCaVhPkI=
|
||||
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
||||
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
|
||||
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/bradenaw/juniper v0.15.3 h1:RHIAMEDTpvmzV1wg1jMAHGOoI2oJUSPx3lxRldXnFGo=
|
||||
github.com/bradenaw/juniper v0.15.3/go.mod h1:UX4FX57kVSaDp4TPqvSjkAAewmRFAfXf27BOs5z9dq8=
|
||||
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 h1:GKTyiRCL6zVf5wWaqKnf+7Qs6GbEPfd4iMOitWzXJx8=
|
||||
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8/go.mod h1:spo1JLcs67NmW1aVLEgtA8Yy1elc+X8y5SRW1sFW4Og=
|
||||
github.com/buengese/sgzip v0.1.1 h1:ry+T8l1mlmiWEsDrH/YHZnCVWD2S3im1KLsyO+8ZmTU=
|
||||
github.com/buengese/sgzip v0.1.1/go.mod h1:i5ZiXGF3fhV7gL1xaRRL1nDnmpNj0X061FQzOS8VMas=
|
||||
github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
|
||||
github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
|
||||
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
|
||||
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/calebcase/tmpfile v1.0.3 h1:BZrOWZ79gJqQ3XbAQlihYZf/YCV0H4KPIdM5K5oMpJo=
|
||||
github.com/calebcase/tmpfile v1.0.3/go.mod h1:UAUc01aHeC+pudPagY/lWvt2qS9ZO5Zzof6/tIUzqeI=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chilts/sid v0.0.0-20190607042430-660e94789ec9 h1:z0uK8UQqjMVYzvk4tiiu3obv2B44+XBsvgEJREQfnO8=
|
||||
github.com/chilts/sid v0.0.0-20190607042430-660e94789ec9/go.mod h1:Jl2neWsQaDanWORdqZ4emBl50J4/aRBBS4FyyG9/PFo=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/cloudinary/cloudinary-go/v2 v2.13.0 h1:ugiQwb7DwpWQnete2AZkTh94MonZKmxD7hDGy1qTzDs=
|
||||
github.com/cloudinary/cloudinary-go/v2 v2.13.0/go.mod h1:ireC4gqVetsjVhYlwjUJwKTbZuWjEIynbR9zQTlqsvo=
|
||||
github.com/cloudsoda/go-smb2 v0.0.0-20250228001242-d4c70e6251cc h1:t8YjNUCt1DimB4HCIXBztwWMhgxr5yG5/YaRl9Afdfg=
|
||||
github.com/cloudsoda/go-smb2 v0.0.0-20250228001242-d4c70e6251cc/go.mod h1:CgWpFCFWzzEA5hVkhAc6DZZzGd3czx+BblvOzjmg6KA=
|
||||
github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc h1:0xCWmFKBmarCqqqLeM7jFBSw/Or81UEElFqO8MY+GDs=
|
||||
github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc/go.mod h1:uvR42Hb/t52HQd7x5/ZLzZEK8oihrFpgnodIJ1vte2E=
|
||||
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/colinmarc/hdfs/v2 v2.4.0 h1:v6R8oBx/Wu9fHpdPoJJjpGSUxo8NhHIwrwsfhFvU9W0=
|
||||
github.com/colinmarc/hdfs/v2 v2.4.0/go.mod h1:0NAO+/3knbMx6+5pCv+Hcbaz4xn/Zzbn9+WIib2rKVI=
|
||||
github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4=
|
||||
github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec=
|
||||
github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo=
|
||||
github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU=
|
||||
github.com/creasty/defaults v1.8.0 h1:z27FJxCAa0JKt3utc0sCImAEb+spPucmKoOdLHvHYKk=
|
||||
github.com/creasty/defaults v1.8.0/go.mod h1:iGzKe6pbEHnpMPtfDXZEr0NVxWnPTjb1bbDy08fPzYM=
|
||||
github.com/cronokirby/saferith v0.33.0 h1:TgoQlfsD4LIwx71+ChfRcIpjkw+RPOapDEVxa+LhwLo=
|
||||
github.com/cronokirby/saferith v0.33.0/go.mod h1:QKJhjoqUtBsXCAVEjw38mFqoi7DebT7kthcD7UzbnoA=
|
||||
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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/diskfs/go-diskfs v1.7.0 h1:vonWmt5CMowXwUc79jWyGrf2DIMeoOjkLlMnQYGVOs8=
|
||||
github.com/diskfs/go-diskfs v1.7.0/go.mod h1:LhQyXqOugWFRahYUSw47NyZJPezFzB9UELwhpszLP/k=
|
||||
github.com/dromara/dongle v1.0.1 h1:si/7UP/EXxnFVZok1cNos70GiMGxInAYMilHQFP5dJs=
|
||||
github.com/dromara/dongle v1.0.1/go.mod h1:ebFhTaDgxaDIKppycENTWlBsxz8mWCPWOLnsEgDpMv4=
|
||||
github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.0.5 h1:FT+t0UEDykcor4y3dMVKXIiWJETBpRgERYTGlmMd7HU=
|
||||
github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.0.5/go.mod h1:rSS3kM9XMzSQ6pw91Qgd6yB5jdt70N4OdtrAf74As5M=
|
||||
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/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
|
||||
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
|
||||
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
|
||||
github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff h1:4N8wnS3f1hNHSmFD5zgFkWCyA4L1kCDkImPAtK7D6tg=
|
||||
github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
|
||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/flynn/noise v1.1.0 h1:KjPQoQCEFdZDiP03phOvGi11+SVVhBG2wOWAorLsstg=
|
||||
github.com/flynn/noise v1.1.0/go.mod h1:xbMo+0i6+IGbYdJhF31t2eR1BIU0CYc12+BNAKwUTag=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
||||
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
|
||||
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/geoffgarside/ber v1.2.0 h1:/loowoRcs/MWLYmGX9QtIAbA+V/FrnVLsMMPhwiRm64=
|
||||
github.com/geoffgarside/ber v1.2.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc=
|
||||
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
|
||||
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
|
||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
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-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348 h1:JnrjqG5iR07/8k7NqrLNilRsl3s1EPRQEGvbPyOce68=
|
||||
github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348/go.mod h1:Czxo/d1g948LtrALAZdL04TL/HnkopquAjxYUuI02bo=
|
||||
github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
|
||||
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/go-openapi/errors v0.22.4 h1:oi2K9mHTOb5DPW2Zjdzs/NIvwi2N3fARKaTJLdNabaM=
|
||||
github.com/go-openapi/errors v0.22.4/go.mod h1:z9S8ASTUqx7+CP1Q8dD8ewGH/1JWFFLX/2PmAYNQLgk=
|
||||
github.com/go-openapi/strfmt v0.25.0 h1:7R0RX7mbKLa9EYCTHRcCuIPcaqlyQiWNPTXwClK0saQ=
|
||||
github.com/go-openapi/strfmt v0.25.0/go.mod h1:nNXct7OzbwrMY9+5tLX4I21pzcmE6ccMGXl3jFdPfn8=
|
||||
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
|
||||
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||
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.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
|
||||
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
|
||||
github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM=
|
||||
github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
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/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
|
||||
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
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/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
|
||||
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
||||
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/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
|
||||
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
|
||||
github.com/google/pprof v0.0.0-20240509144519-723abb6459b7 h1:velgFPYr1X9TDwLIfkV7fWqsFlf7TeP11M/7kPd/dVI=
|
||||
github.com/google/pprof v0.0.0-20240509144519-723abb6459b7/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
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/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
|
||||
github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=
|
||||
github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
|
||||
github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
|
||||
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
|
||||
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
|
||||
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
||||
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/internxt/rclone-adapter v0.0.0-20260220172730-613f4cc8b8fd h1:dSIuz2mpJAPQfhHYtG57D0qwSkgC/vQ69gHfeyQ4kxA=
|
||||
github.com/internxt/rclone-adapter v0.0.0-20260220172730-613f4cc8b8fd/go.mod h1:vdPya4AIcDjvng4ViaAzqjegJf0VHYpYHQguFx5xBp0=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
|
||||
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
|
||||
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||
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/jlaffaye/ftp v0.2.0 h1:lXNvW7cBu7R/68bknOX3MrRIIqZ61zELs1P2RAiA3lg=
|
||||
github.com/jlaffaye/ftp v0.2.0/go.mod h1:is2Ds5qkhceAPy2xD6RLI6hmp/qysSoymZ+Z2uTnspI=
|
||||
github.com/jlaffaye/ftp v0.2.1-0.20240918233326-1b970516f5d3 h1:ZxO6Qr2GOXPdcW80Mcn3nemvilMPvpWqxrNfK2ZnNNs=
|
||||
github.com/jlaffaye/ftp v0.2.1-0.20240918233326-1b970516f5d3/go.mod h1:dvLUr/8Fs9a2OBrEnCC5duphbkz/k/mSy5OkXg3PAgI=
|
||||
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/jtolio/noiseconn v0.0.0-20231127013910-f6d9ecbf1de7 h1:JcltaO1HXM5S2KYOYcKgAV7slU0xPy1OcvrVgn98sRQ=
|
||||
github.com/jtolio/noiseconn v0.0.0-20231127013910-f6d9ecbf1de7/go.mod h1:MEkhEPFwP3yudWO0lj6vfYpLIB+3eIcuIW+e0AZzUQk=
|
||||
github.com/jzelinskie/whirlpool v0.0.0-20201016144138-0675e54bb004 h1:G+9t9cEtnC9jFiTxyptEKuNIAbiN5ZCQzX2a74lj3xg=
|
||||
github.com/jzelinskie/whirlpool v0.0.0-20201016144138-0675e54bb004/go.mod h1:KmHnJWQrgEvbuy0vcvj00gtMqbvNn1L+3YUZLK/B92c=
|
||||
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
||||
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
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/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/koofr/go-httpclient v0.0.0-20240520111329-e20f8f203988 h1:CjEMN21Xkr9+zwPmZPaJJw+apzVbjGL5uK/6g9Q2jGU=
|
||||
github.com/koofr/go-httpclient v0.0.0-20240520111329-e20f8f203988/go.mod h1:/agobYum3uo/8V6yPVnq+R82pyVGCeuWW5arT4Txn8A=
|
||||
github.com/koofr/go-koofrclient v0.0.0-20221207135200-cbd7fc9ad6a6 h1:FHVoZMOVRA+6/y4yRlbiR3WvsrOcKBd/f64H7YiWR2U=
|
||||
github.com/koofr/go-koofrclient v0.0.0-20221207135200-cbd7fc9ad6a6/go.mod h1:MRAz4Gsxd+OzrZ0owwrUHc0zLESL+1Y5syqK/sJxK2A=
|
||||
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lanrat/extsort v1.4.2 h1:akbLIdo4PhNZtvjpaWnbXtGMmLtnGzXplkzfgl+XTTY=
|
||||
github.com/lanrat/extsort v1.4.2/go.mod h1:hceP6kxKPKebjN1RVrDBXMXXECbaI41Y94tt6MDazc4=
|
||||
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/lpar/date v1.0.0 h1:bq/zVqFTUmsxvd/CylidY4Udqpr9BOFrParoP6p0x/I=
|
||||
github.com/lpar/date v1.0.0/go.mod h1:KjYe0dDyMQTgpqcUz4LEIeM5VZwhggjVx/V2dtc8NSo=
|
||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
|
||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
|
||||
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
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/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/natefinch/lumberjack v2.0.0+incompatible h1:4QJd3OLAMgj7ph+yZTuX13Ld4UpgHp07nNdFX7mqFfM=
|
||||
github.com/natefinch/lumberjack v2.0.0+incompatible/go.mod h1:Wi9p2TTF5DG5oU+6YfsmYQpsTIOm0B1VNzQg9Mw6nPk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/ncw/swift/v2 v2.0.5 h1:9o5Gsd7bInAFEqsGPcaUdsboMbqf8lnNtxqWKFT9iz8=
|
||||
github.com/ncw/swift/v2 v2.0.5/go.mod h1:cbAO76/ZwcFrFlHdXPjaqWZ9R7Hdar7HpjRXBfbjigk=
|
||||
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=
|
||||
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
|
||||
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
|
||||
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
|
||||
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
|
||||
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
|
||||
github.com/oracle/oci-go-sdk/v65 v65.104.0 h1:l9awEvzWvxmYhy/97A0hZ87pa7BncYXmcO/S8+rvgK0=
|
||||
github.com/oracle/oci-go-sdk/v65 v65.104.0/go.mod h1:oB8jFGVc/7/zJ+DbleE8MzGHjhs2ioCz5stRTdZdIcY=
|
||||
github.com/panjf2000/ants/v2 v2.11.3 h1:AfI0ngBoXJmYOpDh9m516vjqoUu2sLrIVgppI9TZVpg=
|
||||
github.com/panjf2000/ants/v2 v2.11.3/go.mod h1:8u92CYMUc6gyvTIw8Ru7Mt7+/ESnJahz5EVtqfrilek=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||
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/pengsrc/go-shared v0.2.1-0.20190131101655-1999055a4a14 h1:XeOYlK9W1uCmhjJSsY78Mcuh7MVkNjTzmHx1yBzizSU=
|
||||
github.com/pengsrc/go-shared v0.2.1-0.20190131101655-1999055a4a14/go.mod h1:jVblp62SafmidSkvWrXyxAme3gaTfEtWwRPGz5cpvHg=
|
||||
github.com/peterh/liner v1.2.2 h1:aJ4AOodmL+JxOZZEL2u9iJf8omNRpqHc/EbrK+3mAXw=
|
||||
github.com/peterh/liner v1.2.2/go.mod h1:xFwJyiKIXJZUKItq5dGHZSTBRAuG/CpeNpWLyiNRNwI=
|
||||
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
|
||||
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
|
||||
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
|
||||
github.com/pkg/xattr v0.4.12 h1:rRTkSyFNTRElv6pkA3zpjHpQ90p/OdHQC1GmGh1aTjM=
|
||||
github.com/pkg/xattr v0.4.12/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8=
|
||||
github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/putdotio/go-putio/putio v0.0.0-20200123120452-16d982cac2b8 h1:Y258uzXU/potCYnQd1r6wlAnoMB68BiCkCcCnKx1SH8=
|
||||
github.com/putdotio/go-putio/putio v0.0.0-20200123120452-16d982cac2b8/go.mod h1:bSJjRokAHHOhA+XFxplld8w2R/dXLH7Z3BZ532vhFwU=
|
||||
github.com/rclone/Proton-API-Bridge v1.0.1-0.20260127174007-77f974840d11 h1:4MI2alxM/Ye2gIRBlYf28JGWTipZ4Zz7yAziPKrttjs=
|
||||
github.com/rclone/Proton-API-Bridge v1.0.1-0.20260127174007-77f974840d11/go.mod h1:3HLX7dwZgvB7nt+Yl/xdzVPcargQ1yBmJEUg3n+jMKM=
|
||||
github.com/rclone/go-proton-api v1.0.1-0.20260127173028-eb465cac3b18 h1:Lc+d3ISfQaMJKWZOE7z4ZSY4RVmdzbn1B0IM8xN18qM=
|
||||
github.com/rclone/go-proton-api v1.0.1-0.20260127173028-eb465cac3b18/go.mod h1:LB2kCEaZMzNn3ocdz+qYfxXmuLxxN0ka62KJd2x53Bc=
|
||||
github.com/rclone/rclone v1.73.3 h1:XKlobcnXxxzxnB6UBSVtRB+UeZmYDV9B4QExVSSGoAY=
|
||||
github.com/rclone/rclone v1.73.3/go.mod h1:QJDWatpAY9sKGXfpKZUXbThvtHoeo78DcFP2+/cbkvc=
|
||||
github.com/relvacode/iso8601 v1.7.0 h1:BXy+V60stMP6cpswc+a93Mq3e65PfXCgDFfhvNNGrdo=
|
||||
github.com/relvacode/iso8601 v1.7.0/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH2h3sZCn0I=
|
||||
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/rfjakob/eme v1.1.2 h1:SxziR8msSOElPayZNFfQw4Tjx/Sbaeeh3eRvrHVMUs4=
|
||||
github.com/rfjakob/eme v1.1.2/go.mod h1:cVvpasglm/G3ngEfcfT/Wt0GwhkuO32pf/poW6Nyk1k=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
|
||||
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
|
||||
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
|
||||
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
|
||||
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
||||
github.com/shirou/gopsutil/v4 v4.25.10 h1:at8lk/5T1OgtuCp+AwrDofFRjnvosn0nkN2OLQ6g8tA=
|
||||
github.com/shirou/gopsutil/v4 v4.25.10/go.mod h1:+kSwyC8DRUD9XXEHCAFjK+0nuArFJM0lva+StQAcskM=
|
||||
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af h1:Sp5TG9f7K39yfB+If0vjp97vuT74F72r8hfRpP8jLU0=
|
||||
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
|
||||
github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ=
|
||||
github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
|
||||
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
|
||||
github.com/spacemonkeygo/monkit/v3 v3.0.25-0.20251022131615-eb24eb109368 h1:GyYC5Ntqk/yy9lEIGE7chdIvt4zP44taycwd9YDSGdc=
|
||||
github.com/spacemonkeygo/monkit/v3 v3.0.25-0.20251022131615-eb24eb109368/go.mod h1:XkZYGzknZwkD0AKUnZaSXhRiVTLCkq7CWVa3IsE72gA=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.20.0 h1:zrxIyR3RQIOsarIrgL8+sAvALXul9jeEPa06Y0Ph6vY=
|
||||
github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
|
||||
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/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/studio-b12/gowebdav v0.12.0 h1:kFRtQECt8jmVAvA6RHBz3geXUGJHUZA6/IKpOVUs5kM=
|
||||
github.com/studio-b12/gowebdav v0.12.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
|
||||
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/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/t3rm1n4l/go-mega v0.0.0-20251031123324-a804aaa87491 h1:rrGZv6xYk37hx0tW2sYfgbO0PqStbHqz6Bq6oc9Hurg=
|
||||
github.com/t3rm1n4l/go-mega v0.0.0-20251031123324-a804aaa87491/go.mod h1:ykucQyiE9Q2qx1wLlEtZkkNn1IURib/2O+Mvd25i1Fo=
|
||||
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
||||
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
||||
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
||||
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/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8=
|
||||
github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
||||
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
|
||||
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
|
||||
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
|
||||
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
|
||||
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
|
||||
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
|
||||
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
||||
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/unknwon/goconfig v1.0.0 h1:rS7O+CmUdli1T+oDm7fYj1MwqNWtEJfNj+FqcUHML8U=
|
||||
github.com/unknwon/goconfig v1.0.0/go.mod h1:qu2ZQ/wcC/if2u32263HTVC39PeOQRSmidQk3DuDFQ8=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||
github.com/yunify/qingstor-sdk-go/v3 v3.2.0 h1:9sB2WZMgjwSUNZhrgvaNGazVltoFUUfuS9f0uCWtTr8=
|
||||
github.com/yunify/qingstor-sdk-go/v3 v3.2.0/go.mod h1:KciFNuMu6F4WLk9nGwwK69sCGKLCdd9f97ac/wfumS4=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
github.com/zeebo/assert v1.3.1 h1:vukIABvugfNMZMQO1ABsyQDJDTVQbn+LWSMy1ol1h6A=
|
||||
github.com/zeebo/assert v1.3.1/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
|
||||
github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE=
|
||||
github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
|
||||
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
|
||||
github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
|
||||
github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||
go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss=
|
||||
go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
|
||||
golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
golang.org/x/arch v0.14.0 h1:z9JUEZWr8x4rR0OU6c4/4t6E6jOZ8/QBS2bBYBm4tx4=
|
||||
golang.org/x/arch v0.14.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
|
||||
golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
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-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
google.golang.org/api v0.215.0 h1:jdYF4qnyczlEz2ReWIsosNLDuzXyvFHJtI5gcr0J7t0=
|
||||
google.golang.org/api v0.215.0/go.mod h1:fta3CVtuJYOEdugLNWm6WodzOS8KdFckABwN4I40hzY=
|
||||
google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 h1:ToEetK57OidYuqD4Q5w+vfEnPvPpuTwedCNVohYJfNk=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 h1:TqExAhdPaB60Ux47Cn0oLV07rGnxZzIsaRhQaqS666A=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8/go.mod h1:lcTa1sDdWEIHMWlITnIczmw5w60CF9ffkb8Z+DVmmjA=
|
||||
google.golang.org/grpc v1.67.3 h1:OgPcDAFKHnH8X3O4WcO4XUc8GRDeKsKReqbQtiCj7N8=
|
||||
google.golang.org/grpc v1.67.3/go.mod h1:YGaHCc6Oap+FzBJTZLBzkGSYt/cvGPFTPxkn7QfSU8s=
|
||||
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
|
||||
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/api v0.255.0 h1:OaF+IbRwOottVCYV2wZan7KUq7UeNUQn1BcPc4K7lE4=
|
||||
google.golang.org/api v0.255.0/go.mod h1:d1/EtvCLdtiWEV4rAEHDHGh2bCnqsWhw+M8y2ECN4a8=
|
||||
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
|
||||
google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/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/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/validator.v2 v2.0.1 h1:xF0KWyGWXm/LM2G1TrEjqOu4pa6coO9AlWSf3msVfDY=
|
||||
gopkg.in/validator.v2 v2.0.1/go.mod h1:lIUZBlB3Im4s/eYp39Ry/wkR02yOPhZ9IwIRBjuPuG8=
|
||||
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=
|
||||
@@ -225,5 +569,18 @@ 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=
|
||||
moul.io/http2curl/v2 v2.3.0 h1:9r3JfDzWPcbIklMOs2TnIFzDYvfAZvjeavG6EzP7jYs=
|
||||
moul.io/http2curl/v2 v2.3.0/go.mod h1:RW4hyBjTWSYDOxapodpNEtX0g5Eb16sxklBqmd2RHcE=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
storj.io/common v0.0.0-20251107171817-6221ae45072c h1:UDXSrdeLJe3QFouavSW10fYdpclK0YNu3KvQHzqq2+k=
|
||||
storj.io/common v0.0.0-20251107171817-6221ae45072c/go.mod h1:XNX7uykja6aco92y2y8RuqaXIDRPpt1YA2OQDKlKEUk=
|
||||
storj.io/drpc v0.0.35-0.20250513201419-f7819ea69b55 h1:8OE12DvUnB9lfZcHe7IDGsuhjrY9GBAr964PVHmhsro=
|
||||
storj.io/drpc v0.0.35-0.20250513201419-f7819ea69b55/go.mod h1:Y9LZaa8esL1PW2IDMqJE7CFSNq7d5bQ3RI7mGPtmKMg=
|
||||
storj.io/eventkit v0.0.0-20250410172343-61f26d3de156 h1:5MZ0CyMbG6Pi0rRzUWVG6dvpXjbBYEX2oyXuj+tT+sk=
|
||||
storj.io/eventkit v0.0.0-20250410172343-61f26d3de156/go.mod h1:CpnM6kfZV58dcq3lpbo/IQ4/KoutarnTSHY0GYVwnYw=
|
||||
storj.io/infectious v0.0.2 h1:rGIdDC/6gNYAStsxsZU79D/MqFjNyJc1tsyyj9sTl7Q=
|
||||
storj.io/infectious v0.0.2/go.mod h1:QEjKKww28Sjl1x8iDsjBpOM4r1Yp8RsowNcItsZJ1Vs=
|
||||
storj.io/picobuf v0.0.4 h1:qswHDla+YZ2TovGtMnU4astjvrADSIz84FXRn0qgP6o=
|
||||
storj.io/picobuf v0.0.4/go.mod h1:hSMxmZc58MS/2qSLy1I0idovlO7+6K47wIGUyRZa6mg=
|
||||
storj.io/uplink v1.13.1 h1:C8RdW/upALoCyuF16Lod9XGCXEdbJAS+ABQy9JO/0pA=
|
||||
storj.io/uplink v1.13.1/go.mod h1:x0MQr4UfFsQBwgVWZAtEsLpuwAn6dg7G0Mpne1r516E=
|
||||
|
||||
@@ -20,14 +20,7 @@ import (
|
||||
"backupx/server/internal/service"
|
||||
"backupx/server/internal/storage"
|
||||
"backupx/server/internal/storage/codec"
|
||||
"backupx/server/internal/storage/googledrive"
|
||||
"backupx/server/internal/storage/localdisk"
|
||||
storageAliyun "backupx/server/internal/storage/aliyun"
|
||||
storageFTP "backupx/server/internal/storage/ftp"
|
||||
storageTencent "backupx/server/internal/storage/tencent"
|
||||
storageQiniu "backupx/server/internal/storage/qiniu"
|
||||
storageS3 "backupx/server/internal/storage/s3"
|
||||
storageWebDAV "backupx/server/internal/storage/webdav"
|
||||
storageRclone "backupx/server/internal/storage/rclone"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -70,14 +63,14 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
||||
systemService := service.NewSystemService(cfg, version, time.Now().UTC())
|
||||
configCipher := codec.NewConfigCipher(resolvedSecurity.EncryptionKey)
|
||||
storageRegistry := storage.NewRegistry(
|
||||
localdisk.NewFactory(),
|
||||
storageS3.NewFactory(),
|
||||
storageWebDAV.NewFactory(),
|
||||
googledrive.NewFactory(),
|
||||
storageAliyun.NewFactory(),
|
||||
storageTencent.NewFactory(),
|
||||
storageQiniu.NewFactory(),
|
||||
storageFTP.NewFactory(),
|
||||
storageRclone.NewLocalDiskFactory(),
|
||||
storageRclone.NewS3Factory(),
|
||||
storageRclone.NewWebDAVFactory(),
|
||||
storageRclone.NewGoogleDriveFactory(),
|
||||
storageRclone.NewAliyunOSSFactory(),
|
||||
storageRclone.NewTencentCOSFactory(),
|
||||
storageRclone.NewQiniuKodoFactory(),
|
||||
storageRclone.NewFTPFactory(),
|
||||
)
|
||||
storageTargetService := service.NewStorageTargetService(storageTargetRepo, oauthSessionRepo, storageRegistry, configCipher)
|
||||
storageTargetService.SetBackupTaskRepository(backupTaskRepo)
|
||||
@@ -95,6 +88,14 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
||||
dashboardService := service.NewDashboardService(backupTaskRepo, backupRecordRepo, storageTargetRepo)
|
||||
settingsService := service.NewSettingsService(systemConfigRepo)
|
||||
|
||||
// Audit
|
||||
auditLogRepo := repository.NewAuditLogRepository(db)
|
||||
auditService := service.NewAuditService(auditLogRepo)
|
||||
authService.SetAuditService(auditService)
|
||||
|
||||
// Database discovery
|
||||
databaseDiscoveryService := service.NewDatabaseDiscoveryService(backup.NewOSCommandExecutor())
|
||||
|
||||
// Cluster: Node management
|
||||
nodeRepo := repository.NewNodeRepository(db)
|
||||
nodeService := service.NewNodeService(nodeRepo)
|
||||
@@ -115,8 +116,10 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
||||
NotificationService: notificationService,
|
||||
DashboardService: dashboardService,
|
||||
SettingsService: settingsService,
|
||||
NodeService: nodeService,
|
||||
JWTManager: jwtManager,
|
||||
NodeService: nodeService,
|
||||
DatabaseDiscoveryService: databaseDiscoveryService,
|
||||
AuditService: auditService,
|
||||
JWTManager: jwtManager,
|
||||
UserRepository: userRepo,
|
||||
SystemConfigRepo: systemConfigRepo,
|
||||
})
|
||||
|
||||
@@ -22,14 +22,23 @@ func (r *FileRunner) Type() string {
|
||||
}
|
||||
|
||||
func (r *FileRunner) Run(_ context.Context, task TaskSpec, writer LogWriter) (*RunResult, error) {
|
||||
sourcePath := filepath.Clean(strings.TrimSpace(task.SourcePath))
|
||||
if sourcePath == "" {
|
||||
// 解析源路径列表:优先 SourcePaths,回退 SourcePath
|
||||
sourcePaths := task.SourcePaths
|
||||
if len(sourcePaths) == 0 && strings.TrimSpace(task.SourcePath) != "" {
|
||||
sourcePaths = []string{task.SourcePath}
|
||||
}
|
||||
if len(sourcePaths) == 0 {
|
||||
return nil, fmt.Errorf("source path is required")
|
||||
}
|
||||
info, err := os.Stat(sourcePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat source path: %w", err)
|
||||
|
||||
// 验证所有路径存在
|
||||
for _, sp := range sourcePaths {
|
||||
cleaned := filepath.Clean(strings.TrimSpace(sp))
|
||||
if _, err := os.Stat(cleaned); err != nil {
|
||||
return nil, fmt.Errorf("stat source path %s: %w", cleaned, err)
|
||||
}
|
||||
}
|
||||
|
||||
tempDir, artifactPath, err := createTempArtifact(task.TempDir, task.Name, "tar")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -41,69 +50,88 @@ func (r *FileRunner) Run(_ context.Context, task TaskSpec, writer LogWriter) (*R
|
||||
defer artifactFile.Close()
|
||||
tw := tar.NewWriter(artifactFile)
|
||||
defer tw.Close()
|
||||
baseParent := filepath.Dir(sourcePath)
|
||||
|
||||
excludes := normalizeExcludePatterns(task.ExcludePatterns)
|
||||
writer.WriteLine(fmt.Sprintf("开始打包文件备份:%s", sourcePath))
|
||||
fileCount := 0
|
||||
dirCount := 0
|
||||
walkErr := filepath.Walk(sourcePath, func(currentPath string, currentInfo os.FileInfo, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
writer.WriteLine(fmt.Sprintf("⚠ 无法访问 %s: %v", currentPath, walkErr))
|
||||
return nil
|
||||
}
|
||||
relPath, err := filepath.Rel(baseParent, currentPath)
|
||||
totalFileCount := 0
|
||||
totalDirCount := 0
|
||||
|
||||
for i, sp := range sourcePaths {
|
||||
sourcePath := filepath.Clean(strings.TrimSpace(sp))
|
||||
info, err := os.Stat(sourcePath)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, fmt.Errorf("stat source path: %w", err)
|
||||
}
|
||||
archiveName := filepath.ToSlash(relPath)
|
||||
if shouldExcludeEntry(archiveName, currentInfo.IsDir(), excludes) {
|
||||
if currentInfo.IsDir() {
|
||||
writer.WriteLine(fmt.Sprintf("跳过排除目录 %s", archiveName))
|
||||
return filepath.SkipDir
|
||||
|
||||
baseParent := filepath.Dir(sourcePath)
|
||||
writer.WriteLine(fmt.Sprintf("开始打包源路径 [%d/%d]: %s", i+1, len(sourcePaths), sourcePath))
|
||||
fileCount := 0
|
||||
dirCount := 0
|
||||
|
||||
walkErr := filepath.Walk(sourcePath, func(currentPath string, currentInfo os.FileInfo, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
writer.WriteLine(fmt.Sprintf("⚠ 无法访问 %s: %v", currentPath, walkErr))
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if currentPath == sourcePath && currentInfo.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if currentInfo.IsDir() {
|
||||
dirCount++
|
||||
writer.WriteLine(fmt.Sprintf("📁 进入目录 %s", archiveName))
|
||||
}
|
||||
|
||||
header, err := tar.FileInfoHeader(currentInfo, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header.Name = archiveName
|
||||
if err := tw.WriteHeader(header); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if currentInfo.Mode().IsRegular() {
|
||||
file, err := os.Open(currentPath)
|
||||
relPath, err := filepath.Rel(baseParent, currentPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
if _, err := io.CopyN(tw, file, currentInfo.Size()); err != nil && err != io.EOF {
|
||||
archiveName := filepath.ToSlash(relPath)
|
||||
if shouldExcludeEntry(archiveName, currentInfo.IsDir(), excludes) {
|
||||
if currentInfo.IsDir() {
|
||||
writer.WriteLine(fmt.Sprintf("跳过排除目录 %s", archiveName))
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if currentPath == sourcePath && currentInfo.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if currentInfo.IsDir() {
|
||||
dirCount++
|
||||
writer.WriteLine(fmt.Sprintf("📁 进入目录 %s", archiveName))
|
||||
}
|
||||
|
||||
header, err := tar.FileInfoHeader(currentInfo, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileCount++
|
||||
if fileCount%100 == 0 {
|
||||
writer.WriteLine(fmt.Sprintf("已打包 %d 个文件...", fileCount))
|
||||
header.Name = archiveName
|
||||
if err := tw.WriteHeader(header); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if currentInfo.Mode().IsRegular() {
|
||||
file, err := os.Open(currentPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
if _, err := io.CopyN(tw, file, currentInfo.Size()); err != nil && err != io.EOF {
|
||||
return err
|
||||
}
|
||||
fileCount++
|
||||
if fileCount%100 == 0 {
|
||||
writer.WriteLine(fmt.Sprintf("已打包 %d 个文件...", fileCount))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil {
|
||||
return nil, fmt.Errorf("walk source path %s: %w", sourcePath, walkErr)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil {
|
||||
return nil, fmt.Errorf("walk source path: %w", walkErr)
|
||||
if info.IsDir() {
|
||||
writer.WriteLine(fmt.Sprintf("源路径 [%d/%d] 打包完成(%d 个目录,%d 个文件)", i+1, len(sourcePaths), dirCount, fileCount))
|
||||
} else {
|
||||
writer.WriteLine(fmt.Sprintf("源路径 [%d/%d] 文件打包完成", i+1, len(sourcePaths)))
|
||||
}
|
||||
totalFileCount += fileCount
|
||||
totalDirCount += dirCount
|
||||
}
|
||||
if info.IsDir() {
|
||||
writer.WriteLine(fmt.Sprintf("目录打包完成(%d 个目录,%d 个文件)", dirCount, fileCount))
|
||||
} else {
|
||||
writer.WriteLine("文件打包完成")
|
||||
|
||||
if len(sourcePaths) > 1 {
|
||||
writer.WriteLine(fmt.Sprintf("全部源路径打包完成(共 %d 个目录,%d 个文件)", totalDirCount, totalFileCount))
|
||||
}
|
||||
return &RunResult{ArtifactPath: artifactPath, FileName: filepath.Base(artifactPath), TempDir: tempDir}, nil
|
||||
}
|
||||
@@ -114,7 +142,12 @@ func (r *FileRunner) Restore(_ context.Context, task TaskSpec, artifactPath stri
|
||||
return fmt.Errorf("open tar artifact: %w", err)
|
||||
}
|
||||
defer artifactFile.Close()
|
||||
targetParent := filepath.Dir(filepath.Clean(strings.TrimSpace(task.SourcePath)))
|
||||
// 恢复目标:优先取 SourcePaths 的第一个路径的父目录,回退 SourcePath
|
||||
restoreSource := task.SourcePath
|
||||
if len(task.SourcePaths) > 0 {
|
||||
restoreSource = task.SourcePaths[0]
|
||||
}
|
||||
targetParent := filepath.Dir(filepath.Clean(strings.TrimSpace(restoreSource)))
|
||||
if err := os.MkdirAll(targetParent, 0o755); err != nil {
|
||||
return fmt.Errorf("create restore parent: %w", err)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ type TaskSpec struct {
|
||||
Name string
|
||||
Type string
|
||||
SourcePath string
|
||||
SourcePaths []string
|
||||
ExcludePatterns []string
|
||||
Database DatabaseSpec
|
||||
StorageTargetID uint
|
||||
|
||||
@@ -23,10 +23,17 @@ func Open(cfg config.DatabaseConfig, logger *zap.Logger) (*gorm.DB, error) {
|
||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(&model.User{}, &model.SystemConfig{}, &model.StorageTarget{}, &model.OAuthSession{}, &model.BackupTask{}, &model.BackupRecord{}, &model.Notification{}, &model.Node{}); err != nil {
|
||||
if err := db.AutoMigrate(&model.User{}, &model.SystemConfig{}, &model.StorageTarget{}, &model.OAuthSession{}, &model.BackupTask{}, &model.BackupRecord{}, &model.Notification{}, &model.Node{}, &model.BackupTaskStorageTarget{}, &model.AuditLog{}); err != nil {
|
||||
return nil, fmt.Errorf("migrate schema: %w", err)
|
||||
}
|
||||
|
||||
// 一次性数据迁移:从 backup_tasks.storage_target_id 回填到多对多中间表
|
||||
var count int64
|
||||
db.Model(&model.BackupTaskStorageTarget{}).Count(&count)
|
||||
if count == 0 {
|
||||
db.Exec("INSERT INTO backup_task_storage_targets (backup_task_id, storage_target_id) SELECT id, storage_target_id FROM backup_tasks WHERE storage_target_id > 0")
|
||||
}
|
||||
|
||||
logger.Info("database initialized", zap.String("path", cfg.Path))
|
||||
return db, nil
|
||||
}
|
||||
|
||||
40
server/internal/http/audit_handler.go
Normal file
40
server/internal/http/audit_handler.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"backupx/server/internal/service"
|
||||
"backupx/server/pkg/response"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AuditHandler struct {
|
||||
auditService *service.AuditService
|
||||
}
|
||||
|
||||
func NewAuditHandler(auditService *service.AuditService) *AuditHandler {
|
||||
return &AuditHandler{auditService: auditService}
|
||||
}
|
||||
|
||||
func (h *AuditHandler) List(c *gin.Context) {
|
||||
category := strings.TrimSpace(c.Query("category"))
|
||||
limit := 50
|
||||
offset := 0
|
||||
if v := strings.TrimSpace(c.Query("limit")); v != "" {
|
||||
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("offset")); v != "" {
|
||||
if parsed, err := strconv.Atoi(v); err == nil && parsed >= 0 {
|
||||
offset = parsed
|
||||
}
|
||||
}
|
||||
result, err := h.auditService.List(c.Request.Context(), category, limit, offset)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
29
server/internal/http/audit_helpers.go
Normal file
29
server/internal/http/audit_helpers.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"backupx/server/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// recordAudit 从 gin context 中提取用户信息并记录审计日志(nil 安全)
|
||||
func recordAudit(c *gin.Context, auditService *service.AuditService, category, action, targetType, targetID, targetName, detail string) {
|
||||
if auditService == nil {
|
||||
return
|
||||
}
|
||||
username := ""
|
||||
if subject, exists := c.Get(contextUserSubjectKey); exists {
|
||||
username = fmt.Sprintf("%v", subject)
|
||||
}
|
||||
auditService.Record(service.AuditEntry{
|
||||
Username: username,
|
||||
Category: category,
|
||||
Action: action,
|
||||
TargetType: targetType,
|
||||
TargetID: targetID,
|
||||
TargetName: targetName,
|
||||
Detail: detail,
|
||||
ClientIP: c.ClientIP(),
|
||||
})
|
||||
}
|
||||
@@ -16,11 +16,12 @@ import (
|
||||
)
|
||||
|
||||
type BackupRecordHandler struct {
|
||||
service *service.BackupRecordService
|
||||
service *service.BackupRecordService
|
||||
auditService *service.AuditService
|
||||
}
|
||||
|
||||
func NewBackupRecordHandler(recordService *service.BackupRecordService) *BackupRecordHandler {
|
||||
return &BackupRecordHandler{service: recordService}
|
||||
func NewBackupRecordHandler(recordService *service.BackupRecordService, auditService *service.AuditService) *BackupRecordHandler {
|
||||
return &BackupRecordHandler{service: recordService, auditService: auditService}
|
||||
}
|
||||
|
||||
func (h *BackupRecordHandler) List(c *gin.Context) {
|
||||
@@ -129,6 +130,7 @@ func (h *BackupRecordHandler) Restore(c *gin.Context) {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
recordAudit(c, h.auditService, "backup_record", "restore", "backup_record", fmt.Sprintf("%d", id), "", "")
|
||||
response.Success(c, gin.H{"restored": true})
|
||||
}
|
||||
|
||||
@@ -141,6 +143,7 @@ func (h *BackupRecordHandler) Delete(c *gin.Context) {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
recordAudit(c, h.auditService, "backup_record", "delete", "backup_record", fmt.Sprintf("%d", id), "", "")
|
||||
response.Success(c, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"backupx/server/internal/service"
|
||||
"backupx/server/pkg/response"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type BackupRunHandler struct {
|
||||
service *service.BackupExecutionService
|
||||
service *service.BackupExecutionService
|
||||
auditService *service.AuditService
|
||||
}
|
||||
|
||||
func NewBackupRunHandler(executionService *service.BackupExecutionService) *BackupRunHandler {
|
||||
return &BackupRunHandler{service: executionService}
|
||||
func NewBackupRunHandler(executionService *service.BackupExecutionService, auditService *service.AuditService) *BackupRunHandler {
|
||||
return &BackupRunHandler{service: executionService, auditService: auditService}
|
||||
}
|
||||
|
||||
func (h *BackupRunHandler) Run(c *gin.Context) {
|
||||
@@ -24,5 +27,6 @@ func (h *BackupRunHandler) Run(c *gin.Context) {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
recordAudit(c, h.auditService, "backup_task", "run", "backup_task", fmt.Sprintf("%d", id), "", "手动触发备份")
|
||||
response.Success(c, record)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"backupx/server/internal/apperror"
|
||||
"backupx/server/internal/service"
|
||||
"backupx/server/pkg/response"
|
||||
@@ -8,11 +10,12 @@ import (
|
||||
)
|
||||
|
||||
type BackupTaskHandler struct {
|
||||
service *service.BackupTaskService
|
||||
service *service.BackupTaskService
|
||||
auditService *service.AuditService
|
||||
}
|
||||
|
||||
func NewBackupTaskHandler(taskService *service.BackupTaskService) *BackupTaskHandler {
|
||||
return &BackupTaskHandler{service: taskService}
|
||||
func NewBackupTaskHandler(taskService *service.BackupTaskService, auditService *service.AuditService) *BackupTaskHandler {
|
||||
return &BackupTaskHandler{service: taskService, auditService: auditService}
|
||||
}
|
||||
|
||||
func (h *BackupTaskHandler) List(c *gin.Context) {
|
||||
@@ -48,6 +51,7 @@ func (h *BackupTaskHandler) Create(c *gin.Context) {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
recordAudit(c, h.auditService, "backup_task", "create", "backup_task", fmt.Sprintf("%d", item.ID), item.Name, "")
|
||||
response.Success(c, item)
|
||||
}
|
||||
|
||||
@@ -66,6 +70,7 @@ func (h *BackupTaskHandler) Update(c *gin.Context) {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
recordAudit(c, h.auditService, "backup_task", "update", "backup_task", fmt.Sprintf("%d", item.ID), item.Name, "")
|
||||
response.Success(c, item)
|
||||
}
|
||||
|
||||
@@ -78,6 +83,7 @@ func (h *BackupTaskHandler) Delete(c *gin.Context) {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
recordAudit(c, h.auditService, "backup_task", "delete", "backup_task", fmt.Sprintf("%d", id), "", "")
|
||||
response.Success(c, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
@@ -105,5 +111,10 @@ func (h *BackupTaskHandler) Toggle(c *gin.Context) {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
action := "enable"
|
||||
if !enabled {
|
||||
action = "disable"
|
||||
}
|
||||
recordAudit(c, h.auditService, "backup_task", action, "backup_task", fmt.Sprintf("%d", id), item.Name, "")
|
||||
response.Success(c, item)
|
||||
}
|
||||
|
||||
30
server/internal/http/database_handler.go
Normal file
30
server/internal/http/database_handler.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"backupx/server/internal/apperror"
|
||||
"backupx/server/internal/service"
|
||||
"backupx/server/pkg/response"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type DatabaseHandler struct {
|
||||
service *service.DatabaseDiscoveryService
|
||||
}
|
||||
|
||||
func NewDatabaseHandler(service *service.DatabaseDiscoveryService) *DatabaseHandler {
|
||||
return &DatabaseHandler{service: service}
|
||||
}
|
||||
|
||||
func (h *DatabaseHandler) Discover(c *gin.Context) {
|
||||
var input service.DatabaseDiscoverInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, apperror.BadRequest("DATABASE_DISCOVER_INVALID", "数据库发现参数不合法", err))
|
||||
return
|
||||
}
|
||||
result, err := h.service.Discover(c.Request.Context(), input)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
@@ -15,22 +15,24 @@ import (
|
||||
)
|
||||
|
||||
type RouterDependencies struct {
|
||||
Config config.Config
|
||||
Version string
|
||||
Logger *zap.Logger
|
||||
AuthService *service.AuthService
|
||||
SystemService *service.SystemService
|
||||
StorageTargetService *service.StorageTargetService
|
||||
BackupTaskService *service.BackupTaskService
|
||||
BackupExecutionService *service.BackupExecutionService
|
||||
BackupRecordService *service.BackupRecordService
|
||||
NotificationService *service.NotificationService
|
||||
DashboardService *service.DashboardService
|
||||
SettingsService *service.SettingsService
|
||||
NodeService *service.NodeService
|
||||
JWTManager *security.JWTManager
|
||||
UserRepository repository.UserRepository
|
||||
SystemConfigRepo repository.SystemConfigRepository
|
||||
Config config.Config
|
||||
Version string
|
||||
Logger *zap.Logger
|
||||
AuthService *service.AuthService
|
||||
SystemService *service.SystemService
|
||||
StorageTargetService *service.StorageTargetService
|
||||
BackupTaskService *service.BackupTaskService
|
||||
BackupExecutionService *service.BackupExecutionService
|
||||
BackupRecordService *service.BackupRecordService
|
||||
NotificationService *service.NotificationService
|
||||
DashboardService *service.DashboardService
|
||||
SettingsService *service.SettingsService
|
||||
NodeService *service.NodeService
|
||||
DatabaseDiscoveryService *service.DatabaseDiscoveryService
|
||||
AuditService *service.AuditService
|
||||
JWTManager *security.JWTManager
|
||||
UserRepository repository.UserRepository
|
||||
SystemConfigRepo repository.SystemConfigRepository
|
||||
}
|
||||
|
||||
func NewRouter(deps RouterDependencies) *gin.Engine {
|
||||
@@ -42,13 +44,14 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
|
||||
|
||||
authHandler := NewAuthHandler(deps.AuthService)
|
||||
systemHandler := NewSystemHandler(deps.SystemService)
|
||||
storageTargetHandler := NewStorageTargetHandler(deps.StorageTargetService)
|
||||
backupTaskHandler := NewBackupTaskHandler(deps.BackupTaskService)
|
||||
backupRunHandler := NewBackupRunHandler(deps.BackupExecutionService)
|
||||
backupRecordHandler := NewBackupRecordHandler(deps.BackupRecordService)
|
||||
storageTargetHandler := NewStorageTargetHandler(deps.StorageTargetService, deps.AuditService)
|
||||
backupTaskHandler := NewBackupTaskHandler(deps.BackupTaskService, deps.AuditService)
|
||||
backupRunHandler := NewBackupRunHandler(deps.BackupExecutionService, deps.AuditService)
|
||||
backupRecordHandler := NewBackupRecordHandler(deps.BackupRecordService, deps.AuditService)
|
||||
notificationHandler := NewNotificationHandler(deps.NotificationService)
|
||||
dashboardHandler := NewDashboardHandler(deps.DashboardService)
|
||||
settingsHandler := NewSettingsHandler(deps.SettingsService)
|
||||
settingsHandler := NewSettingsHandler(deps.SettingsService, deps.AuditService)
|
||||
auditHandler := NewAuditHandler(deps.AuditService)
|
||||
|
||||
api := engine.Group("/api")
|
||||
{
|
||||
@@ -73,6 +76,7 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
|
||||
storageTargets.POST("", storageTargetHandler.Create)
|
||||
storageTargets.PUT("/:id", storageTargetHandler.Update)
|
||||
storageTargets.DELETE("/:id", storageTargetHandler.Delete)
|
||||
storageTargets.PUT("/:id/star", storageTargetHandler.ToggleStar)
|
||||
storageTargets.POST("/test", storageTargetHandler.TestConnection)
|
||||
storageTargets.POST("/:id/test", storageTargetHandler.TestSavedConnection)
|
||||
storageTargets.GET("/:id/usage", storageTargetHandler.GetUsage)
|
||||
@@ -119,6 +123,17 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
|
||||
settings.GET("", settingsHandler.Get)
|
||||
settings.PUT("", settingsHandler.Update)
|
||||
|
||||
auditLogs := api.Group("/audit-logs")
|
||||
auditLogs.Use(AuthMiddleware(deps.JWTManager))
|
||||
auditLogs.GET("", auditHandler.List)
|
||||
|
||||
if deps.DatabaseDiscoveryService != nil {
|
||||
databaseHandler := NewDatabaseHandler(deps.DatabaseDiscoveryService)
|
||||
database := api.Group("/database")
|
||||
database.Use(AuthMiddleware(deps.JWTManager))
|
||||
database.POST("/discover", databaseHandler.Discover)
|
||||
}
|
||||
|
||||
nodeHandler := NewNodeHandler(deps.NodeService)
|
||||
nodes := api.Group("/nodes")
|
||||
nodes.Use(AuthMiddleware(deps.JWTManager))
|
||||
|
||||
@@ -9,10 +9,11 @@ import (
|
||||
|
||||
type SettingsHandler struct {
|
||||
settingsService *service.SettingsService
|
||||
auditService *service.AuditService
|
||||
}
|
||||
|
||||
func NewSettingsHandler(settingsService *service.SettingsService) *SettingsHandler {
|
||||
return &SettingsHandler{settingsService: settingsService}
|
||||
func NewSettingsHandler(settingsService *service.SettingsService, auditService *service.AuditService) *SettingsHandler {
|
||||
return &SettingsHandler{settingsService: settingsService, auditService: auditService}
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) Get(c *gin.Context) {
|
||||
@@ -35,5 +36,6 @@ func (h *SettingsHandler) Update(c *gin.Context) {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
recordAudit(c, h.auditService, "settings", "update", "settings", "", "", "")
|
||||
response.Success(c, settings)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ import (
|
||||
)
|
||||
|
||||
type StorageTargetHandler struct {
|
||||
service *service.StorageTargetService
|
||||
service *service.StorageTargetService
|
||||
auditService *service.AuditService
|
||||
}
|
||||
|
||||
type storageTargetGoogleDriveAuthRequest struct {
|
||||
@@ -27,8 +28,8 @@ type storageTargetGoogleDriveAuthRequest struct {
|
||||
FolderID string `json:"folderId"`
|
||||
}
|
||||
|
||||
func NewStorageTargetHandler(service *service.StorageTargetService) *StorageTargetHandler {
|
||||
return &StorageTargetHandler{service: service}
|
||||
func NewStorageTargetHandler(service *service.StorageTargetService, auditService *service.AuditService) *StorageTargetHandler {
|
||||
return &StorageTargetHandler{service: service, auditService: auditService}
|
||||
}
|
||||
|
||||
func (h *StorageTargetHandler) List(c *gin.Context) {
|
||||
@@ -64,6 +65,7 @@ func (h *StorageTargetHandler) Create(c *gin.Context) {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
recordAudit(c, h.auditService, "storage_target", "create", "storage_target", fmt.Sprintf("%d", item.ID), item.Name, "")
|
||||
response.Success(c, item)
|
||||
}
|
||||
|
||||
@@ -82,6 +84,7 @@ func (h *StorageTargetHandler) Update(c *gin.Context) {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
recordAudit(c, h.auditService, "storage_target", "update", "storage_target", fmt.Sprintf("%d", item.ID), item.Name, "")
|
||||
response.Success(c, item)
|
||||
}
|
||||
|
||||
@@ -94,6 +97,7 @@ func (h *StorageTargetHandler) Delete(c *gin.Context) {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
recordAudit(c, h.auditService, "storage_target", "delete", "storage_target", fmt.Sprintf("%d", id), "", "")
|
||||
response.Success(c, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
@@ -230,6 +234,19 @@ func firstNonEmpty(values ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (h *StorageTargetHandler) ToggleStar(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
item, err := h.service.ToggleStar(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, item)
|
||||
}
|
||||
|
||||
func (h *StorageTargetHandler) GetUsage(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
|
||||
21
server/internal/model/audit_log.go
Normal file
21
server/internal/model/audit_log.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type AuditLog struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"column:user_id;index" json:"userId"`
|
||||
Username string `gorm:"column:username;size:64;not null" json:"username"`
|
||||
Category string `gorm:"column:category;size:32;index;not null" json:"category"`
|
||||
Action string `gorm:"column:action;size:64;not null" json:"action"`
|
||||
TargetType string `gorm:"column:target_type;size:32" json:"targetType"`
|
||||
TargetID string `gorm:"column:target_id;size:64" json:"targetId"`
|
||||
TargetName string `gorm:"column:target_name;size:128" json:"targetName"`
|
||||
Detail string `gorm:"column:detail;type:text" json:"detail"`
|
||||
ClientIP string `gorm:"column:client_ip;size:45" json:"clientIp"`
|
||||
CreatedAt time.Time `gorm:"index" json:"createdAt"`
|
||||
}
|
||||
|
||||
func (AuditLog) TableName() string {
|
||||
return "audit_logs"
|
||||
}
|
||||
@@ -9,22 +9,24 @@ const (
|
||||
)
|
||||
|
||||
type BackupRecord struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
TaskID uint `gorm:"column:task_id;index;not null" json:"taskId"`
|
||||
Task BackupTask `json:"task,omitempty"`
|
||||
StorageTargetID uint `gorm:"column:storage_target_id;index;not null" json:"storageTargetId"`
|
||||
StorageTarget StorageTarget `json:"storageTarget,omitempty"`
|
||||
Status string `gorm:"size:20;index;not null" json:"status"`
|
||||
FileName string `gorm:"column:file_name;size:255" json:"fileName"`
|
||||
FileSize int64 `gorm:"column:file_size;not null;default:0" json:"fileSize"`
|
||||
StoragePath string `gorm:"column:storage_path;size:500" json:"storagePath"`
|
||||
DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"`
|
||||
ErrorMessage string `gorm:"column:error_message;size:2000" json:"errorMessage"`
|
||||
LogContent string `gorm:"column:log_content;type:text" json:"logContent"`
|
||||
StartedAt time.Time `gorm:"column:started_at;index;not null" json:"startedAt"`
|
||||
CompletedAt *time.Time `gorm:"column:completed_at;index" json:"completedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
TaskID uint `gorm:"column:task_id;index;not null" json:"taskId"`
|
||||
Task BackupTask `json:"task,omitempty"`
|
||||
StorageTargetID uint `gorm:"column:storage_target_id;index;not null" json:"storageTargetId"`
|
||||
StorageTarget StorageTarget `json:"storageTarget,omitempty"`
|
||||
Status string `gorm:"size:20;index;not null" json:"status"`
|
||||
FileName string `gorm:"column:file_name;size:255" json:"fileName"`
|
||||
FileSize int64 `gorm:"column:file_size;not null;default:0" json:"fileSize"`
|
||||
Checksum string `gorm:"column:checksum;size:64" json:"checksum"`
|
||||
StoragePath string `gorm:"column:storage_path;size:500" json:"storagePath"`
|
||||
StorageUploadResults string `gorm:"column:storage_upload_results;type:text" json:"-"`
|
||||
DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"`
|
||||
ErrorMessage string `gorm:"column:error_message;size:2000" json:"errorMessage"`
|
||||
LogContent string `gorm:"column:log_content;type:text" json:"logContent"`
|
||||
StartedAt time.Time `gorm:"column:started_at;index;not null" json:"startedAt"`
|
||||
CompletedAt *time.Time `gorm:"column:completed_at;index" json:"completedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (BackupRecord) TableName() string {
|
||||
|
||||
@@ -17,34 +17,46 @@ const (
|
||||
)
|
||||
|
||||
type BackupTask struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"size:100;uniqueIndex;not null" json:"name"`
|
||||
Type string `gorm:"size:20;index;not null" json:"type"`
|
||||
Enabled bool `gorm:"not null;default:true" json:"enabled"`
|
||||
CronExpr string `gorm:"column:cron_expr;size:64" json:"cronExpr"`
|
||||
SourcePath string `gorm:"column:source_path;size:500" json:"sourcePath"`
|
||||
ExcludePatterns string `gorm:"column:exclude_patterns;type:text" json:"excludePatterns"`
|
||||
DBHost string `gorm:"column:db_host;size:255" json:"dbHost"`
|
||||
DBPort int `gorm:"column:db_port" json:"dbPort"`
|
||||
DBUser string `gorm:"column:db_user;size:100" json:"dbUser"`
|
||||
DBPasswordCiphertext string `gorm:"column:db_password_ciphertext;type:text" json:"-"`
|
||||
DBName string `gorm:"column:db_name;size:255" json:"dbName"`
|
||||
DBPath string `gorm:"column:db_path;size:500" json:"dbPath"`
|
||||
StorageTargetID uint `gorm:"column:storage_target_id;index;not null" json:"storageTargetId"`
|
||||
StorageTarget StorageTarget `json:"storageTarget,omitempty"`
|
||||
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
|
||||
Node Node `json:"node,omitempty"`
|
||||
Tags string `gorm:"column:tags;size:500" json:"tags"`
|
||||
RetentionDays int `gorm:"column:retention_days;not null;default:30" json:"retentionDays"`
|
||||
Compression string `gorm:"size:10;not null;default:'gzip'" json:"compression"`
|
||||
Encrypt bool `gorm:"not null;default:false" json:"encrypt"`
|
||||
MaxBackups int `gorm:"column:max_backups;not null;default:10" json:"maxBackups"`
|
||||
LastRunAt *time.Time `gorm:"column:last_run_at" json:"lastRunAt,omitempty"`
|
||||
LastStatus string `gorm:"column:last_status;size:20;not null;default:'idle'" json:"lastStatus"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"size:100;uniqueIndex;not null" json:"name"`
|
||||
Type string `gorm:"size:20;index;not null" json:"type"`
|
||||
Enabled bool `gorm:"not null;default:true" json:"enabled"`
|
||||
CronExpr string `gorm:"column:cron_expr;size:64" json:"cronExpr"`
|
||||
SourcePath string `gorm:"column:source_path;size:500" json:"sourcePath"`
|
||||
SourcePaths string `gorm:"column:source_paths;type:text" json:"sourcePaths"`
|
||||
ExcludePatterns string `gorm:"column:exclude_patterns;type:text" json:"excludePatterns"`
|
||||
DBHost string `gorm:"column:db_host;size:255" json:"dbHost"`
|
||||
DBPort int `gorm:"column:db_port" json:"dbPort"`
|
||||
DBUser string `gorm:"column:db_user;size:100" json:"dbUser"`
|
||||
DBPasswordCiphertext string `gorm:"column:db_password_ciphertext;type:text" json:"-"`
|
||||
DBName string `gorm:"column:db_name;size:255" json:"dbName"`
|
||||
DBPath string `gorm:"column:db_path;size:500" json:"dbPath"`
|
||||
StorageTargetID uint `gorm:"column:storage_target_id;index;not null" json:"storageTargetId"` // deprecated: 保留兼容
|
||||
StorageTarget StorageTarget `json:"storageTarget,omitempty"` // deprecated: 保留兼容
|
||||
StorageTargets []StorageTarget `gorm:"many2many:backup_task_storage_targets" json:"storageTargets,omitempty"`
|
||||
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
|
||||
Node Node `json:"node,omitempty"`
|
||||
Tags string `gorm:"column:tags;size:500" json:"tags"`
|
||||
RetentionDays int `gorm:"column:retention_days;not null;default:30" json:"retentionDays"`
|
||||
Compression string `gorm:"size:10;not null;default:'gzip'" json:"compression"`
|
||||
Encrypt bool `gorm:"not null;default:false" json:"encrypt"`
|
||||
MaxBackups int `gorm:"column:max_backups;not null;default:10" json:"maxBackups"`
|
||||
LastRunAt *time.Time `gorm:"column:last_run_at" json:"lastRunAt,omitempty"`
|
||||
LastStatus string `gorm:"column:last_status;size:20;not null;default:'idle'" json:"lastStatus"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (BackupTask) TableName() string {
|
||||
return "backup_tasks"
|
||||
}
|
||||
|
||||
// BackupTaskStorageTarget 多对多中间表
|
||||
type BackupTaskStorageTarget struct {
|
||||
BackupTaskID uint `gorm:"primaryKey;column:backup_task_id"`
|
||||
StorageTargetID uint `gorm:"primaryKey;column:storage_target_id"`
|
||||
}
|
||||
|
||||
func (BackupTaskStorageTarget) TableName() string {
|
||||
return "backup_task_storage_targets"
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ type StorageTarget struct {
|
||||
Type string `gorm:"size:32;index;not null" json:"type"`
|
||||
Description string `gorm:"size:255" json:"description"`
|
||||
Enabled bool `gorm:"not null;default:true" json:"enabled"`
|
||||
Starred bool `gorm:"not null;default:false" json:"starred"`
|
||||
ConfigCiphertext string `gorm:"column:config_ciphertext;type:text;not null" json:"-"`
|
||||
ConfigVersion int `gorm:"not null;default:1" json:"configVersion"`
|
||||
LastTestedAt *time.Time `gorm:"column:last_tested_at" json:"lastTestedAt,omitempty"`
|
||||
|
||||
56
server/internal/repository/audit_log_repository.go
Normal file
56
server/internal/repository/audit_log_repository.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"backupx/server/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AuditLogListOptions struct {
|
||||
Category string
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
type AuditLogListResult struct {
|
||||
Items []model.AuditLog `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
type AuditLogRepository interface {
|
||||
Create(ctx context.Context, log *model.AuditLog) error
|
||||
List(ctx context.Context, opts AuditLogListOptions) (*AuditLogListResult, error)
|
||||
}
|
||||
|
||||
type gormAuditLogRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewAuditLogRepository(db *gorm.DB) AuditLogRepository {
|
||||
return &gormAuditLogRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *gormAuditLogRepository) Create(_ context.Context, log *model.AuditLog) error {
|
||||
return r.db.Create(log).Error
|
||||
}
|
||||
|
||||
func (r *gormAuditLogRepository) List(_ context.Context, opts AuditLogListOptions) (*AuditLogListResult, error) {
|
||||
query := r.db.Model(&model.AuditLog{})
|
||||
if opts.Category != "" {
|
||||
query = query.Where("category = ?", opts.Category)
|
||||
}
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
limit := opts.Limit
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
var items []model.AuditLog
|
||||
if err := query.Order("created_at DESC").Offset(opts.Offset).Limit(limit).Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &AuditLogListResult{Items: items, Total: total}, nil
|
||||
}
|
||||
@@ -35,7 +35,7 @@ func NewBackupTaskRepository(db *gorm.DB) *GormBackupTaskRepository {
|
||||
}
|
||||
|
||||
func (r *GormBackupTaskRepository) List(ctx context.Context, options BackupTaskListOptions) ([]model.BackupTask, error) {
|
||||
query := r.db.WithContext(ctx).Model(&model.BackupTask{}).Preload("StorageTarget").Order("updated_at desc")
|
||||
query := r.db.WithContext(ctx).Model(&model.BackupTask{}).Preload("StorageTarget").Preload("StorageTargets").Order("updated_at desc")
|
||||
if options.Type != "" {
|
||||
query = query.Where("type = ?", options.Type)
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func (r *GormBackupTaskRepository) List(ctx context.Context, options BackupTaskL
|
||||
|
||||
func (r *GormBackupTaskRepository) FindByID(ctx context.Context, id uint) (*model.BackupTask, error) {
|
||||
var item model.BackupTask
|
||||
if err := r.db.WithContext(ctx).Preload("StorageTarget").First(&item, id).Error; err != nil {
|
||||
if err := r.db.WithContext(ctx).Preload("StorageTarget").Preload("StorageTargets").First(&item, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -73,7 +73,7 @@ func (r *GormBackupTaskRepository) FindByName(ctx context.Context, name string)
|
||||
|
||||
func (r *GormBackupTaskRepository) ListSchedulable(ctx context.Context) ([]model.BackupTask, error) {
|
||||
var items []model.BackupTask
|
||||
if err := r.db.WithContext(ctx).Preload("StorageTarget").Where("enabled = ? AND cron_expr <> ''", true).Order("id asc").Find(&items).Error; err != nil {
|
||||
if err := r.db.WithContext(ctx).Preload("StorageTarget").Preload("StorageTargets").Where("enabled = ? AND cron_expr <> ''", true).Order("id asc").Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
@@ -97,18 +97,39 @@ func (r *GormBackupTaskRepository) CountEnabled(ctx context.Context) (int64, err
|
||||
|
||||
func (r *GormBackupTaskRepository) CountByStorageTargetID(ctx context.Context, storageTargetID uint) (int64, error) {
|
||||
var count int64
|
||||
if err := r.db.WithContext(ctx).Model(&model.BackupTask{}).Where("storage_target_id = ?", storageTargetID).Count(&count).Error; err != nil {
|
||||
if err := r.db.WithContext(ctx).Model(&model.BackupTaskStorageTarget{}).Where("storage_target_id = ?", storageTargetID).Count(&count).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (r *GormBackupTaskRepository) Create(ctx context.Context, item *model.BackupTask) error {
|
||||
return r.db.WithContext(ctx).Create(item).Error
|
||||
if err := r.db.WithContext(ctx).Create(item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return r.syncStorageTargets(ctx, item)
|
||||
}
|
||||
|
||||
func (r *GormBackupTaskRepository) Update(ctx context.Context, item *model.BackupTask) error {
|
||||
return r.db.WithContext(ctx).Save(item).Error
|
||||
if err := r.db.WithContext(ctx).Save(item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(item.StorageTargets) > 0 {
|
||||
return r.db.WithContext(ctx).Model(item).Association("StorageTargets").Replace(item.StorageTargets)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncStorageTargets 确保中间表数据一致:优先使用 StorageTargets,回退到 StorageTargetID
|
||||
func (r *GormBackupTaskRepository) syncStorageTargets(ctx context.Context, item *model.BackupTask) error {
|
||||
targets := item.StorageTargets
|
||||
if len(targets) == 0 && item.StorageTargetID > 0 {
|
||||
targets = []model.StorageTarget{{ID: item.StorageTargetID}}
|
||||
}
|
||||
if len(targets) > 0 {
|
||||
return r.db.WithContext(ctx).Model(item).Association("StorageTargets").Replace(targets)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *GormBackupTaskRepository) Delete(ctx context.Context, id uint) error {
|
||||
|
||||
@@ -27,7 +27,7 @@ func NewStorageTargetRepository(db *gorm.DB) *GormStorageTargetRepository {
|
||||
|
||||
func (r *GormStorageTargetRepository) List(ctx context.Context) ([]model.StorageTarget, error) {
|
||||
var items []model.StorageTarget
|
||||
if err := r.db.WithContext(ctx).Order("updated_at desc").Find(&items).Error; err != nil {
|
||||
if err := r.db.WithContext(ctx).Order("starred desc, updated_at desc").Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
|
||||
68
server/internal/service/audit_service.go
Normal file
68
server/internal/service/audit_service.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"backupx/server/internal/apperror"
|
||||
"backupx/server/internal/model"
|
||||
"backupx/server/internal/repository"
|
||||
)
|
||||
|
||||
// AuditEntry 是记录审计日志的输入结构
|
||||
type AuditEntry struct {
|
||||
UserID uint
|
||||
Username string
|
||||
Category string // auth / storage_target / backup_task / backup_record / settings
|
||||
Action string // create / update / delete / login_success / login_failed / ...
|
||||
TargetType string
|
||||
TargetID string
|
||||
TargetName string
|
||||
Detail string
|
||||
ClientIP string
|
||||
}
|
||||
|
||||
type AuditService struct {
|
||||
repo repository.AuditLogRepository
|
||||
}
|
||||
|
||||
func NewAuditService(repo repository.AuditLogRepository) *AuditService {
|
||||
return &AuditService{repo: repo}
|
||||
}
|
||||
|
||||
// Record 异步 fire-and-forget 写入审计日志,不阻塞业务逻辑
|
||||
func (s *AuditService) Record(entry AuditEntry) {
|
||||
if s == nil || s.repo == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
record := &model.AuditLog{
|
||||
UserID: entry.UserID,
|
||||
Username: entry.Username,
|
||||
Category: entry.Category,
|
||||
Action: entry.Action,
|
||||
TargetType: entry.TargetType,
|
||||
TargetID: entry.TargetID,
|
||||
TargetName: entry.TargetName,
|
||||
Detail: entry.Detail,
|
||||
ClientIP: entry.ClientIP,
|
||||
}
|
||||
if err := s.repo.Create(context.Background(), record); err != nil {
|
||||
log.Printf("[audit] failed to write audit log: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// List 分页查询审计日志
|
||||
func (s *AuditService) List(ctx context.Context, category string, limit, offset int) (*repository.AuditLogListResult, error) {
|
||||
result, err := s.repo.List(ctx, repository.AuditLogListOptions{
|
||||
Category: category,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, apperror.Internal("AUDIT_LOG_LIST_FAILED", fmt.Sprintf("无法获取审计日志列表: %v", err), err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -37,10 +37,11 @@ type UserOutput struct {
|
||||
}
|
||||
|
||||
type AuthService struct {
|
||||
users repository.UserRepository
|
||||
configs repository.SystemConfigRepository
|
||||
jwtManager *security.JWTManager
|
||||
rateLimiter *security.LoginRateLimiter
|
||||
users repository.UserRepository
|
||||
configs repository.SystemConfigRepository
|
||||
jwtManager *security.JWTManager
|
||||
rateLimiter *security.LoginRateLimiter
|
||||
auditService *AuditService
|
||||
}
|
||||
|
||||
func NewAuthService(
|
||||
@@ -52,6 +53,10 @@ func NewAuthService(
|
||||
return &AuthService{users: users, configs: configs, jwtManager: jwtManager, rateLimiter: rateLimiter}
|
||||
}
|
||||
|
||||
func (s *AuthService) SetAuditService(auditService *AuditService) {
|
||||
s.auditService = auditService
|
||||
}
|
||||
|
||||
func (s *AuthService) SetupStatus(ctx context.Context) (bool, error) {
|
||||
count, err := s.users.Count(ctx)
|
||||
if err != nil {
|
||||
@@ -97,6 +102,15 @@ func (s *AuthService) Setup(ctx context.Context, input SetupInput) (*AuthPayload
|
||||
return nil, apperror.Internal("AUTH_TOKEN_FAILED", "无法生成访问令牌", err)
|
||||
}
|
||||
|
||||
if s.auditService != nil {
|
||||
s.auditService.Record(AuditEntry{
|
||||
UserID: user.ID, Username: user.Username,
|
||||
Category: "auth", Action: "setup",
|
||||
TargetType: "user", TargetID: fmt.Sprintf("%d", user.ID), TargetName: user.Username,
|
||||
Detail: "系统初始化,创建管理员账户",
|
||||
})
|
||||
}
|
||||
|
||||
return &AuthPayload{Token: token, User: ToUserOutput(user)}, nil
|
||||
}
|
||||
|
||||
@@ -113,9 +127,23 @@ func (s *AuthService) Login(ctx context.Context, input LoginInput, clientKey str
|
||||
return nil, apperror.Internal("AUTH_LOOKUP_FAILED", "无法执行登录校验", err)
|
||||
}
|
||||
if user == nil {
|
||||
if s.auditService != nil {
|
||||
s.auditService.Record(AuditEntry{
|
||||
Category: "auth", Action: "login_failed",
|
||||
Detail: fmt.Sprintf("用户名不存在: %s", strings.TrimSpace(input.Username)),
|
||||
ClientIP: clientKey,
|
||||
})
|
||||
}
|
||||
return nil, apperror.Unauthorized("AUTH_INVALID_CREDENTIALS", "用户名或密码错误", nil)
|
||||
}
|
||||
if err := security.ComparePassword(user.PasswordHash, input.Password); err != nil {
|
||||
if s.auditService != nil {
|
||||
s.auditService.Record(AuditEntry{
|
||||
UserID: user.ID, Username: user.Username,
|
||||
Category: "auth", Action: "login_failed",
|
||||
Detail: "密码错误", ClientIP: clientKey,
|
||||
})
|
||||
}
|
||||
return nil, apperror.Unauthorized("AUTH_INVALID_CREDENTIALS", "用户名或密码错误", err)
|
||||
}
|
||||
|
||||
@@ -124,6 +152,15 @@ func (s *AuthService) Login(ctx context.Context, input LoginInput, clientKey str
|
||||
if err != nil {
|
||||
return nil, apperror.Internal("AUTH_TOKEN_FAILED", "无法生成访问令牌", err)
|
||||
}
|
||||
|
||||
if s.auditService != nil {
|
||||
s.auditService.Record(AuditEntry{
|
||||
UserID: user.ID, Username: user.Username,
|
||||
Category: "auth", Action: "login_success",
|
||||
Detail: "登录成功", ClientIP: clientKey,
|
||||
})
|
||||
}
|
||||
|
||||
return &AuthPayload{Token: token, User: ToUserOutput(user)}, nil
|
||||
}
|
||||
|
||||
@@ -170,6 +207,15 @@ func (s *AuthService) ChangePassword(ctx context.Context, subject string, input
|
||||
if err := s.users.Update(ctx, user); err != nil {
|
||||
return apperror.Internal("AUTH_UPDATE_FAILED", "密码修改失败", err)
|
||||
}
|
||||
|
||||
if s.auditService != nil {
|
||||
s.auditService.Record(AuditEntry{
|
||||
UserID: user.ID, Username: user.Username,
|
||||
Category: "auth", Action: "change_password",
|
||||
Detail: "密码修改成功",
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,16 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/apperror"
|
||||
@@ -37,11 +41,35 @@ func (noopBackupNotifier) NotifyBackupResult(context.Context, BackupExecutionNot
|
||||
return nil
|
||||
}
|
||||
|
||||
type StorageUploadResultItem struct {
|
||||
StorageTargetID uint `json:"storageTargetId"`
|
||||
StorageTargetName string `json:"storageTargetName"`
|
||||
Status string `json:"status"`
|
||||
StoragePath string `json:"storagePath,omitempty"`
|
||||
FileSize int64 `json:"fileSize,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type DownloadedArtifact struct {
|
||||
FileName string
|
||||
Reader io.ReadCloser
|
||||
}
|
||||
|
||||
// collectTargetIDs 获取任务关联的所有存储目标 ID
|
||||
func collectTargetIDs(task *model.BackupTask) []uint {
|
||||
if len(task.StorageTargets) > 0 {
|
||||
ids := make([]uint, len(task.StorageTargets))
|
||||
for i, t := range task.StorageTargets {
|
||||
ids[i] = t.ID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
if task.StorageTargetID > 0 {
|
||||
return []uint{task.StorageTargetID}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type BackupExecutionService struct {
|
||||
tasks repository.BackupTaskRepository
|
||||
records repository.BackupRecordRepository
|
||||
@@ -194,7 +222,12 @@ func (s *BackupExecutionService) startTask(ctx context.Context, id uint, async b
|
||||
return nil, apperror.New(404, "BACKUP_TASK_NOT_FOUND", "备份任务不存在", fmt.Errorf("backup task %d not found", id))
|
||||
}
|
||||
startedAt := s.now()
|
||||
record := &model.BackupRecord{TaskID: task.ID, StorageTargetID: task.StorageTargetID, Status: "running", StartedAt: startedAt}
|
||||
// 取第一个存储目标 ID 做兼容
|
||||
primaryTargetID := task.StorageTargetID
|
||||
if tids := collectTargetIDs(task); len(tids) > 0 {
|
||||
primaryTargetID = tids[0]
|
||||
}
|
||||
record := &model.BackupRecord{TaskID: task.ID, StorageTargetID: primaryTargetID, Status: "running", StartedAt: startedAt}
|
||||
if err := s.records.Create(ctx, record); err != nil {
|
||||
return nil, apperror.Internal("BACKUP_RECORD_CREATE_FAILED", "无法创建备份记录", err)
|
||||
}
|
||||
@@ -223,11 +256,22 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
|
||||
errMessage := ""
|
||||
var fileName string
|
||||
var fileSize int64
|
||||
var checksum string
|
||||
var storagePath string
|
||||
var uploadResults []StorageUploadResultItem
|
||||
completeRecord := func() {
|
||||
if finalizeErr := s.finalizeRecord(ctx, task, recordID, startedAt, status, errMessage, logger.String(), fileName, fileSize, storagePath); finalizeErr != nil {
|
||||
if finalizeErr := s.finalizeRecord(ctx, task, recordID, startedAt, status, errMessage, logger.String(), fileName, fileSize, checksum, storagePath); finalizeErr != nil {
|
||||
logger.Errorf("写回备份记录失败:%v", finalizeErr)
|
||||
}
|
||||
// 写入多目标上传结果
|
||||
if len(uploadResults) > 0 {
|
||||
if resultsJSON, marshalErr := json.Marshal(uploadResults); marshalErr == nil {
|
||||
if record, findErr := s.records.FindByID(ctx, recordID); findErr == nil && record != nil {
|
||||
record.StorageUploadResults = string(resultsJSON)
|
||||
_ = s.records.Update(ctx, record)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := s.notifier.NotifyBackupResult(ctx, BackupExecutionNotification{Task: task, Record: &model.BackupRecord{ID: recordID, TaskID: task.ID, Status: status, FileName: fileName, FileSize: fileSize, StoragePath: storagePath, ErrorMessage: errMessage, StartedAt: startedAt}, Error: buildOptionalError(errMessage)}); err != nil {
|
||||
logger.Warnf("发送备份通知失败:%v", err)
|
||||
}
|
||||
@@ -241,12 +285,6 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
|
||||
logger.Errorf("构建任务运行时配置失败:%v", err)
|
||||
return
|
||||
}
|
||||
provider, err := s.resolveProvider(ctx, task.StorageTargetID)
|
||||
if err != nil {
|
||||
errMessage = err.Error()
|
||||
logger.Errorf("创建存储客户端失败:%v", err)
|
||||
return
|
||||
}
|
||||
runner, err := s.runnerRegistry.Runner(spec.Type)
|
||||
if err != nil {
|
||||
errMessage = err.Error()
|
||||
@@ -290,34 +328,99 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
|
||||
fileSize = info.Size()
|
||||
fileName = filepath.Base(finalPath)
|
||||
storagePath = backup.BuildStorageKey(task.Type, startedAt, fileName)
|
||||
artifact, err := os.Open(finalPath)
|
||||
if err != nil {
|
||||
errMessage = err.Error()
|
||||
logger.Errorf("打开备份文件失败:%v", err)
|
||||
|
||||
// 收集所有存储目标
|
||||
targetIDs := collectTargetIDs(task)
|
||||
if len(targetIDs) == 0 {
|
||||
errMessage = "没有关联的存储目标"
|
||||
logger.Errorf("没有关联的存储目标")
|
||||
return
|
||||
}
|
||||
defer artifact.Close()
|
||||
logger.Infof("开始上传备份到存储目标")
|
||||
if err := provider.Upload(ctx, storagePath, artifact, fileSize, map[string]string{"taskId": fmt.Sprintf("%d", task.ID), "recordId": fmt.Sprintf("%d", recordID)}); err != nil {
|
||||
errMessage = err.Error()
|
||||
logger.Errorf("上传备份文件失败:%v", err)
|
||||
return
|
||||
}
|
||||
if s.retention != nil {
|
||||
cleanupResult, cleanupErr := s.retention.Cleanup(ctx, task, provider)
|
||||
if cleanupErr != nil {
|
||||
logger.Warnf("执行保留策略失败:%v", cleanupErr)
|
||||
} else {
|
||||
for _, warning := range cleanupResult.Warnings {
|
||||
logger.Warnf("保留策略警告:%s", warning)
|
||||
|
||||
// 并行上传到所有目标
|
||||
uploadResults = make([]StorageUploadResultItem, len(targetIDs))
|
||||
var checksumOnce sync.Once
|
||||
var wg sync.WaitGroup
|
||||
for i, tid := range targetIDs {
|
||||
wg.Add(1)
|
||||
go func(index int, targetID uint) {
|
||||
defer wg.Done()
|
||||
target, findErr := s.targets.FindByID(ctx, targetID)
|
||||
targetName := fmt.Sprintf("target-%d", targetID)
|
||||
if findErr == nil && target != nil {
|
||||
targetName = target.Name
|
||||
}
|
||||
provider, resolveErr := s.resolveProvider(ctx, targetID)
|
||||
if resolveErr != nil {
|
||||
uploadResults[index] = StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "failed", Error: resolveErr.Error()}
|
||||
logger.Warnf("存储目标 %s 创建客户端失败:%v", targetName, resolveErr)
|
||||
return
|
||||
}
|
||||
artifact, openErr := os.Open(finalPath)
|
||||
if openErr != nil {
|
||||
uploadResults[index] = StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "failed", Error: openErr.Error()}
|
||||
logger.Warnf("存储目标 %s 打开备份文件失败:%v", targetName, openErr)
|
||||
return
|
||||
}
|
||||
defer artifact.Close()
|
||||
logger.Infof("开始上传备份到存储目标:%s", targetName)
|
||||
// hashingReader: 上传过程中同步计算字节数 + SHA-256,单次读取零额外 I/O
|
||||
hr := newHashingReader(artifact)
|
||||
if uploadErr := provider.Upload(ctx, storagePath, hr, fileSize, map[string]string{"taskId": fmt.Sprintf("%d", task.ID), "recordId": fmt.Sprintf("%d", recordID)}); uploadErr != nil {
|
||||
uploadResults[index] = StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "failed", Error: uploadErr.Error()}
|
||||
logger.Warnf("存储目标 %s 上传失败:%v", targetName, uploadErr)
|
||||
return
|
||||
}
|
||||
// 完整性校验:对比实际传输字节数
|
||||
if hr.n != fileSize {
|
||||
uploadResults[index] = StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "failed", Error: fmt.Sprintf("完整性校验失败: 预期 %d bytes, 实际传输 %d bytes", fileSize, hr.n)}
|
||||
logger.Errorf("存储目标 %s 完整性校验失败:预期 %d bytes, 实际传输 %d bytes", targetName, fileSize, hr.n)
|
||||
_ = provider.Delete(ctx, storagePath)
|
||||
return
|
||||
}
|
||||
// 取第一个成功目标的哈希写入 record(所有目标读同一文件,哈希一定相同)
|
||||
targetChecksum := hr.Sum()
|
||||
checksumOnce.Do(func() { checksum = targetChecksum })
|
||||
uploadResults[index] = StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "success", StoragePath: storagePath, FileSize: fileSize}
|
||||
logger.Infof("存储目标 %s 上传成功 (%d bytes, SHA-256=%s)", targetName, fileSize, targetChecksum)
|
||||
// 每个成功目标独立执行保留策略
|
||||
if s.retention != nil {
|
||||
cleanupResult, cleanupErr := s.retention.Cleanup(ctx, task, provider)
|
||||
if cleanupErr != nil {
|
||||
logger.Warnf("存储目标 %s 执行保留策略失败:%v", targetName, cleanupErr)
|
||||
} else {
|
||||
for _, warning := range cleanupResult.Warnings {
|
||||
logger.Warnf("存储目标 %s 保留策略警告:%s", targetName, warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
}(i, tid)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// 汇总结果:任意一个 success → 整体 success
|
||||
anySuccess := false
|
||||
var failedMessages []string
|
||||
for _, r := range uploadResults {
|
||||
if r.Status == "success" {
|
||||
anySuccess = true
|
||||
} else if r.Error != "" {
|
||||
failedMessages = append(failedMessages, fmt.Sprintf("%s: %s", r.StorageTargetName, r.Error))
|
||||
}
|
||||
}
|
||||
status = "success"
|
||||
logger.Infof("备份执行完成")
|
||||
if anySuccess {
|
||||
status = "success"
|
||||
if len(failedMessages) > 0 {
|
||||
logger.Warnf("部分存储目标上传失败:%s", strings.Join(failedMessages, "; "))
|
||||
}
|
||||
logger.Infof("备份执行完成")
|
||||
} else {
|
||||
errMessage = strings.Join(failedMessages, "; ")
|
||||
logger.Errorf("所有存储目标上传均失败")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *BackupExecutionService) finalizeRecord(ctx context.Context, task *model.BackupTask, recordID uint, startedAt time.Time, status string, errorMessage string, logContent string, fileName string, fileSize int64, storagePath string) error {
|
||||
func (s *BackupExecutionService) finalizeRecord(ctx context.Context, task *model.BackupTask, recordID uint, startedAt time.Time, status string, errorMessage string, logContent string, fileName string, fileSize int64, checksum string, storagePath string) error {
|
||||
record, err := s.records.FindByID(ctx, recordID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -329,6 +432,7 @@ func (s *BackupExecutionService) finalizeRecord(ctx context.Context, task *model
|
||||
record.Status = status
|
||||
record.FileName = fileName
|
||||
record.FileSize = fileSize
|
||||
record.Checksum = checksum
|
||||
record.StoragePath = storagePath
|
||||
record.DurationSeconds = int(completedAt.Sub(startedAt).Seconds())
|
||||
record.ErrorMessage = strings.TrimSpace(errorMessage)
|
||||
@@ -376,11 +480,18 @@ func (s *BackupExecutionService) buildTaskSpec(task *model.BackupTask, startedAt
|
||||
}
|
||||
password = string(plain)
|
||||
}
|
||||
sourcePaths := []string{}
|
||||
if strings.TrimSpace(task.SourcePaths) != "" {
|
||||
if err := json.Unmarshal([]byte(task.SourcePaths), &sourcePaths); err != nil {
|
||||
return backup.TaskSpec{}, apperror.Internal("BACKUP_TASK_DECODE_FAILED", "无法解析源路径配置", err)
|
||||
}
|
||||
}
|
||||
return backup.TaskSpec{
|
||||
ID: task.ID,
|
||||
Name: task.Name,
|
||||
Type: task.Type,
|
||||
SourcePath: task.SourcePath,
|
||||
SourcePaths: sourcePaths,
|
||||
ExcludePatterns: excludePatterns,
|
||||
StorageTargetID: task.StorageTargetID,
|
||||
StorageTargetType: "",
|
||||
@@ -485,3 +596,28 @@ func buildStorageProviderFromRepos(ctx context.Context, storageTargetID uint, st
|
||||
}
|
||||
return provider, target, nil
|
||||
}
|
||||
|
||||
// hashingReader 在上传过程中同步计算字节数和 SHA-256,零额外 I/O
|
||||
type hashingReader struct {
|
||||
reader io.Reader
|
||||
hash hash.Hash
|
||||
n int64
|
||||
}
|
||||
|
||||
func newHashingReader(reader io.Reader) *hashingReader {
|
||||
h := sha256.New()
|
||||
return &hashingReader{
|
||||
reader: io.TeeReader(reader, h),
|
||||
hash: h,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *hashingReader) Read(p []byte) (int, error) {
|
||||
n, err := r.reader.Read(p)
|
||||
r.n += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (r *hashingReader) Sum() string {
|
||||
return hex.EncodeToString(r.hash.Sum(nil))
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"backupx/server/internal/repository"
|
||||
"backupx/server/internal/storage"
|
||||
"backupx/server/internal/storage/codec"
|
||||
"backupx/server/internal/storage/localdisk"
|
||||
storageRclone "backupx/server/internal/storage/rclone"
|
||||
)
|
||||
|
||||
func newExecutionTestServices(t *testing.T) (*BackupExecutionService, *BackupRecordService, repository.BackupTaskRepository, repository.StorageTargetRepository, repository.BackupRecordRepository, string, string) {
|
||||
@@ -53,9 +53,13 @@ func newExecutionTestServices(t *testing.T) (*BackupExecutionService, *BackupRec
|
||||
}
|
||||
logHub := backup.NewLogHub()
|
||||
runnerRegistry := backup.NewRegistry(backup.NewFileRunner(), backup.NewMySQLRunner(nil), backup.NewSQLiteRunner(), backup.NewPostgreSQLRunner(nil))
|
||||
storageRegistry := storage.NewRegistry(localdisk.NewFactory())
|
||||
storageRegistry := storage.NewRegistry(storageRclone.NewLocalDiskFactory())
|
||||
retentionService := backupretention.NewService(records)
|
||||
executionService := NewBackupExecutionService(tasks, records, targets, storageRegistry, runnerRegistry, logHub, retentionService, cipher, nil, "", 2)
|
||||
tempDir := filepath.Join(baseDir, "tmp")
|
||||
if err := os.MkdirAll(tempDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll tempDir returned error: %v", err)
|
||||
}
|
||||
executionService := NewBackupExecutionService(tasks, records, targets, storageRegistry, runnerRegistry, logHub, retentionService, cipher, nil, tempDir, 2)
|
||||
recordService := NewBackupRecordService(records, executionService, logHub)
|
||||
return executionService, recordService, tasks, targets, records, sourceDir, storageDir
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -29,6 +30,7 @@ type BackupRecordSummary struct {
|
||||
Status string `json:"status"`
|
||||
FileName string `json:"fileName"`
|
||||
FileSize int64 `json:"fileSize"`
|
||||
Checksum string `json:"checksum"`
|
||||
StoragePath string `json:"storagePath"`
|
||||
DurationSeconds int `json:"durationSeconds"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
@@ -38,8 +40,9 @@ type BackupRecordSummary struct {
|
||||
|
||||
type BackupRecordDetail struct {
|
||||
BackupRecordSummary
|
||||
LogContent string `json:"logContent"`
|
||||
LogEvents []backup.LogEvent `json:"logEvents,omitempty"`
|
||||
LogContent string `json:"logContent"`
|
||||
LogEvents []backup.LogEvent `json:"logEvents,omitempty"`
|
||||
StorageUploadResults []StorageUploadResultItem `json:"storageUploadResults,omitempty"`
|
||||
}
|
||||
|
||||
type BackupRecordService struct {
|
||||
@@ -109,6 +112,7 @@ func toBackupRecordSummary(item *model.BackupRecord) BackupRecordSummary {
|
||||
Status: item.Status,
|
||||
FileName: item.FileName,
|
||||
FileSize: item.FileSize,
|
||||
Checksum: item.Checksum,
|
||||
StoragePath: item.StoragePath,
|
||||
DurationSeconds: item.DurationSeconds,
|
||||
ErrorMessage: item.ErrorMessage,
|
||||
@@ -130,5 +134,12 @@ func toBackupRecordDetail(item *model.BackupRecord, logHub *backup.LogHub) *Back
|
||||
detail.LogContent = strings.Join(lines, "\n")
|
||||
}
|
||||
}
|
||||
// 解析多目标上传结果
|
||||
if strings.TrimSpace(item.StorageUploadResults) != "" {
|
||||
var uploadResults []StorageUploadResultItem
|
||||
if err := json.Unmarshal([]byte(item.StorageUploadResults), &uploadResults); err == nil {
|
||||
detail.StorageUploadResults = uploadResults
|
||||
}
|
||||
}
|
||||
return detail
|
||||
}
|
||||
|
||||
@@ -17,23 +17,25 @@ import (
|
||||
const backupTaskMaskedValue = "********"
|
||||
|
||||
type BackupTaskUpsertInput struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=100"`
|
||||
Type string `json:"type" binding:"required,oneof=file mysql sqlite postgresql pgsql"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CronExpr string `json:"cronExpr" binding:"max=64"`
|
||||
SourcePath string `json:"sourcePath" binding:"max=500"`
|
||||
ExcludePatterns []string `json:"excludePatterns"`
|
||||
DBHost string `json:"dbHost" binding:"max=255"`
|
||||
DBPort int `json:"dbPort"`
|
||||
DBUser string `json:"dbUser" binding:"max=100"`
|
||||
DBPassword string `json:"dbPassword" binding:"max=255"`
|
||||
DBName string `json:"dbName" binding:"max=255"`
|
||||
DBPath string `json:"dbPath" binding:"max=500"`
|
||||
StorageTargetID uint `json:"storageTargetId" binding:"required"`
|
||||
RetentionDays int `json:"retentionDays"`
|
||||
Compression string `json:"compression" binding:"omitempty,oneof=gzip none"`
|
||||
Encrypt bool `json:"encrypt"`
|
||||
MaxBackups int `json:"maxBackups"`
|
||||
Name string `json:"name" binding:"required,min=1,max=100"`
|
||||
Type string `json:"type" binding:"required,oneof=file mysql sqlite postgresql pgsql"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CronExpr string `json:"cronExpr" binding:"max=64"`
|
||||
SourcePath string `json:"sourcePath" binding:"max=500"`
|
||||
SourcePaths []string `json:"sourcePaths"`
|
||||
ExcludePatterns []string `json:"excludePatterns"`
|
||||
DBHost string `json:"dbHost" binding:"max=255"`
|
||||
DBPort int `json:"dbPort"`
|
||||
DBUser string `json:"dbUser" binding:"max=100"`
|
||||
DBPassword string `json:"dbPassword" binding:"max=255"`
|
||||
DBName string `json:"dbName" binding:"max=255"`
|
||||
DBPath string `json:"dbPath" binding:"max=500"`
|
||||
StorageTargetID uint `json:"storageTargetId"` // deprecated: 向后兼容
|
||||
StorageTargetIDs []uint `json:"storageTargetIds"` // 新增:多存储目标
|
||||
RetentionDays int `json:"retentionDays"`
|
||||
Compression string `json:"compression" binding:"omitempty,oneof=gzip none"`
|
||||
Encrypt bool `json:"encrypt"`
|
||||
MaxBackups int `json:"maxBackups"`
|
||||
}
|
||||
|
||||
type BackupTaskToggleInput struct {
|
||||
@@ -41,25 +43,28 @@ type BackupTaskToggleInput struct {
|
||||
}
|
||||
|
||||
type BackupTaskSummary struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CronExpr string `json:"cronExpr"`
|
||||
StorageTargetID uint `json:"storageTargetId"`
|
||||
StorageTargetName string `json:"storageTargetName"`
|
||||
RetentionDays int `json:"retentionDays"`
|
||||
Compression string `json:"compression"`
|
||||
Encrypt bool `json:"encrypt"`
|
||||
MaxBackups int `json:"maxBackups"`
|
||||
LastRunAt *time.Time `json:"lastRunAt,omitempty"`
|
||||
LastStatus string `json:"lastStatus"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CronExpr string `json:"cronExpr"`
|
||||
StorageTargetID uint `json:"storageTargetId"` // deprecated: 取第一个
|
||||
StorageTargetName string `json:"storageTargetName"` // deprecated: 取第一个
|
||||
StorageTargetIDs []uint `json:"storageTargetIds"`
|
||||
StorageTargetNames []string `json:"storageTargetNames"`
|
||||
RetentionDays int `json:"retentionDays"`
|
||||
Compression string `json:"compression"`
|
||||
Encrypt bool `json:"encrypt"`
|
||||
MaxBackups int `json:"maxBackups"`
|
||||
LastRunAt *time.Time `json:"lastRunAt,omitempty"`
|
||||
LastStatus string `json:"lastStatus"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type BackupTaskDetail struct {
|
||||
BackupTaskSummary
|
||||
SourcePath string `json:"sourcePath"`
|
||||
SourcePaths []string `json:"sourcePaths"`
|
||||
ExcludePatterns []string `json:"excludePatterns"`
|
||||
DBHost string `json:"dbHost"`
|
||||
DBPort int `json:"dbPort"`
|
||||
@@ -227,19 +232,33 @@ func (s *BackupTaskService) Toggle(ctx context.Context, id uint, enabled bool) (
|
||||
return &returnValue, nil
|
||||
}
|
||||
|
||||
// resolveStorageTargetIDs 统一处理新旧字段,返回有效的存储目标 ID 列表
|
||||
func resolveStorageTargetIDs(input BackupTaskUpsertInput) []uint {
|
||||
if len(input.StorageTargetIDs) > 0 {
|
||||
return input.StorageTargetIDs
|
||||
}
|
||||
if input.StorageTargetID > 0 {
|
||||
return []uint{input.StorageTargetID}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BackupTaskService) validateInput(ctx context.Context, existing *model.BackupTask, input BackupTaskUpsertInput) error {
|
||||
if strings.TrimSpace(input.Name) == "" {
|
||||
return apperror.BadRequest("BACKUP_TASK_INVALID", "任务名称不能为空", nil)
|
||||
}
|
||||
if input.StorageTargetID == 0 {
|
||||
return apperror.BadRequest("BACKUP_TASK_INVALID", "请选择存储目标", nil)
|
||||
targetIDs := resolveStorageTargetIDs(input)
|
||||
if len(targetIDs) == 0 {
|
||||
return apperror.BadRequest("BACKUP_TASK_INVALID", "请选择至少一个存储目标", nil)
|
||||
}
|
||||
target, err := s.targets.FindByID(ctx, input.StorageTargetID)
|
||||
if err != nil {
|
||||
return apperror.Internal("BACKUP_TASK_STORAGE_LOOKUP_FAILED", "无法检查存储目标", err)
|
||||
}
|
||||
if target == nil {
|
||||
return apperror.BadRequest("BACKUP_STORAGE_TARGET_INVALID", "关联的存储目标不存在", nil)
|
||||
for _, tid := range targetIDs {
|
||||
target, err := s.targets.FindByID(ctx, tid)
|
||||
if err != nil {
|
||||
return apperror.Internal("BACKUP_TASK_STORAGE_LOOKUP_FAILED", "无法检查存储目标", err)
|
||||
}
|
||||
if target == nil {
|
||||
return apperror.BadRequest("BACKUP_STORAGE_TARGET_INVALID", fmt.Sprintf("关联的存储目标 %d 不存在", tid), nil)
|
||||
}
|
||||
}
|
||||
if input.RetentionDays < 0 {
|
||||
return apperror.BadRequest("BACKUP_TASK_INVALID", "保留天数不能小于 0", nil)
|
||||
@@ -260,7 +279,8 @@ func (s *BackupTaskService) validateInput(ctx context.Context, existing *model.B
|
||||
func validateTaskTypeSpecificFields(input BackupTaskUpsertInput, passwordRequired bool) error {
|
||||
switch normalizeBackupTaskType(input.Type) {
|
||||
case "file":
|
||||
if strings.TrimSpace(input.SourcePath) == "" {
|
||||
hasSourcePaths := len(resolveSourcePaths(input)) > 0
|
||||
if !hasSourcePaths {
|
||||
return apperror.BadRequest("BACKUP_TASK_INVALID", "文件备份必须填写源路径", nil)
|
||||
}
|
||||
case "mysql", "postgresql":
|
||||
@@ -294,6 +314,10 @@ func (s *BackupTaskService) buildTask(existing *model.BackupTask, input BackupTa
|
||||
if err != nil {
|
||||
return nil, apperror.BadRequest("BACKUP_TASK_INVALID", "排除规则格式不合法", err)
|
||||
}
|
||||
sourcePathsJSON, err := encodeSourcePaths(resolveSourcePaths(input))
|
||||
if err != nil {
|
||||
return nil, apperror.BadRequest("BACKUP_TASK_INVALID", "源路径格式不合法", err)
|
||||
}
|
||||
passwordCiphertext := ""
|
||||
if existing != nil {
|
||||
passwordCiphertext = existing.DBPasswordCiphertext
|
||||
@@ -313,12 +337,30 @@ func (s *BackupTaskService) buildTask(existing *model.BackupTask, input BackupTa
|
||||
if maxBackups == 0 {
|
||||
maxBackups = 10
|
||||
}
|
||||
targetIDs := resolveStorageTargetIDs(input)
|
||||
// 保持旧字段兼容:取第一个
|
||||
primaryTargetID := uint(0)
|
||||
if len(targetIDs) > 0 {
|
||||
primaryTargetID = targetIDs[0]
|
||||
}
|
||||
// 构建多对多关联
|
||||
storageTargets := make([]model.StorageTarget, len(targetIDs))
|
||||
for i, tid := range targetIDs {
|
||||
storageTargets[i] = model.StorageTarget{ID: tid}
|
||||
}
|
||||
// 向后兼容:SourcePath 取第一个
|
||||
resolvedPaths := resolveSourcePaths(input)
|
||||
primarySourcePath := strings.TrimSpace(input.SourcePath)
|
||||
if len(resolvedPaths) > 0 {
|
||||
primarySourcePath = resolvedPaths[0]
|
||||
}
|
||||
item := &model.BackupTask{
|
||||
Name: strings.TrimSpace(input.Name),
|
||||
Type: normalizeBackupTaskType(input.Type),
|
||||
Enabled: input.Enabled,
|
||||
CronExpr: strings.TrimSpace(input.CronExpr),
|
||||
SourcePath: strings.TrimSpace(input.SourcePath),
|
||||
SourcePath: primarySourcePath,
|
||||
SourcePaths: sourcePathsJSON,
|
||||
ExcludePatterns: excludePatterns,
|
||||
DBHost: strings.TrimSpace(input.DBHost),
|
||||
DBPort: input.DBPort,
|
||||
@@ -326,7 +368,8 @@ func (s *BackupTaskService) buildTask(existing *model.BackupTask, input BackupTa
|
||||
DBPasswordCiphertext: passwordCiphertext,
|
||||
DBName: strings.TrimSpace(input.DBName),
|
||||
DBPath: strings.TrimSpace(input.DBPath),
|
||||
StorageTargetID: input.StorageTargetID,
|
||||
StorageTargetID: primaryTargetID,
|
||||
StorageTargets: storageTargets,
|
||||
RetentionDays: input.RetentionDays,
|
||||
Compression: compression,
|
||||
Encrypt: input.Encrypt,
|
||||
@@ -346,9 +389,14 @@ func (s *BackupTaskService) toDetail(item *model.BackupTask) (*BackupTaskDetail,
|
||||
if err != nil {
|
||||
return nil, apperror.Internal("BACKUP_TASK_DECODE_FAILED", "无法解析备份任务配置", err)
|
||||
}
|
||||
sourcePaths, err := decodeSourcePaths(item.SourcePaths)
|
||||
if err != nil {
|
||||
return nil, apperror.Internal("BACKUP_TASK_DECODE_FAILED", "无法解析源路径配置", err)
|
||||
}
|
||||
detail := &BackupTaskDetail{
|
||||
BackupTaskSummary: toBackupTaskSummary(item),
|
||||
SourcePath: item.SourcePath,
|
||||
SourcePaths: sourcePaths,
|
||||
ExcludePatterns: excludePatterns,
|
||||
DBHost: item.DBHost,
|
||||
DBPort: item.DBPort,
|
||||
@@ -364,25 +412,45 @@ func (s *BackupTaskService) toDetail(item *model.BackupTask) (*BackupTaskDetail,
|
||||
}
|
||||
|
||||
func toBackupTaskSummary(item *model.BackupTask) BackupTaskSummary {
|
||||
storageTargetName := ""
|
||||
if item != nil {
|
||||
storageTargetName = item.StorageTarget.Name
|
||||
// 从多对多关联提取 IDs 和 Names
|
||||
var targetIDs []uint
|
||||
var targetNames []string
|
||||
if len(item.StorageTargets) > 0 {
|
||||
for _, t := range item.StorageTargets {
|
||||
targetIDs = append(targetIDs, t.ID)
|
||||
targetNames = append(targetNames, t.Name)
|
||||
}
|
||||
} else if item.StorageTargetID > 0 {
|
||||
// 回退到旧字段
|
||||
targetIDs = []uint{item.StorageTargetID}
|
||||
targetNames = []string{item.StorageTarget.Name}
|
||||
}
|
||||
// 向后兼容:取第一个
|
||||
primaryID := uint(0)
|
||||
primaryName := ""
|
||||
if len(targetIDs) > 0 {
|
||||
primaryID = targetIDs[0]
|
||||
}
|
||||
if len(targetNames) > 0 {
|
||||
primaryName = targetNames[0]
|
||||
}
|
||||
return BackupTaskSummary{
|
||||
ID: item.ID,
|
||||
Name: item.Name,
|
||||
Type: normalizeBackupTaskType(item.Type),
|
||||
Enabled: item.Enabled,
|
||||
CronExpr: item.CronExpr,
|
||||
StorageTargetID: item.StorageTargetID,
|
||||
StorageTargetName: storageTargetName,
|
||||
RetentionDays: item.RetentionDays,
|
||||
Compression: item.Compression,
|
||||
Encrypt: item.Encrypt,
|
||||
MaxBackups: item.MaxBackups,
|
||||
LastRunAt: item.LastRunAt,
|
||||
LastStatus: item.LastStatus,
|
||||
UpdatedAt: item.UpdatedAt,
|
||||
ID: item.ID,
|
||||
Name: item.Name,
|
||||
Type: normalizeBackupTaskType(item.Type),
|
||||
Enabled: item.Enabled,
|
||||
CronExpr: item.CronExpr,
|
||||
StorageTargetID: primaryID,
|
||||
StorageTargetName: primaryName,
|
||||
StorageTargetIDs: targetIDs,
|
||||
StorageTargetNames: targetNames,
|
||||
RetentionDays: item.RetentionDays,
|
||||
Compression: item.Compression,
|
||||
Encrypt: item.Encrypt,
|
||||
MaxBackups: item.MaxBackups,
|
||||
LastRunAt: item.LastRunAt,
|
||||
LastStatus: item.LastStatus,
|
||||
UpdatedAt: item.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,6 +476,47 @@ func decodeExcludePatterns(value string) ([]string, error) {
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// resolveSourcePaths 统一处理 sourcePaths / sourcePath,返回有效路径列表
|
||||
func resolveSourcePaths(input BackupTaskUpsertInput) []string {
|
||||
if len(input.SourcePaths) > 0 {
|
||||
var cleaned []string
|
||||
for _, p := range input.SourcePaths {
|
||||
if trimmed := strings.TrimSpace(p); trimmed != "" {
|
||||
cleaned = append(cleaned, trimmed)
|
||||
}
|
||||
}
|
||||
if len(cleaned) > 0 {
|
||||
return cleaned
|
||||
}
|
||||
}
|
||||
if sp := strings.TrimSpace(input.SourcePath); sp != "" {
|
||||
return []string{sp}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeSourcePaths(paths []string) (string, error) {
|
||||
if len(paths) == 0 {
|
||||
return "[]", nil
|
||||
}
|
||||
encoded, err := json.Marshal(paths)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(encoded), nil
|
||||
}
|
||||
|
||||
func decodeSourcePaths(value string) ([]string, error) {
|
||||
if strings.TrimSpace(value) == "" || strings.TrimSpace(value) == "[]" {
|
||||
return []string{}, nil
|
||||
}
|
||||
var items []string
|
||||
if err := json.Unmarshal([]byte(value), &items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func normalizeBackupTaskType(value string) string {
|
||||
normalized := strings.TrimSpace(strings.ToLower(value))
|
||||
if normalized == "pgsql" {
|
||||
|
||||
141
server/internal/service/database_discovery_service.go
Normal file
141
server/internal/service/database_discovery_service.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/apperror"
|
||||
"backupx/server/internal/backup"
|
||||
)
|
||||
|
||||
type DatabaseDiscoverInput struct {
|
||||
Type string `json:"type" binding:"required,oneof=mysql postgresql"`
|
||||
Host string `json:"host" binding:"required"`
|
||||
Port int `json:"port" binding:"required,min=1"`
|
||||
User string `json:"user" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type DatabaseDiscoverResult struct {
|
||||
Databases []string `json:"databases"`
|
||||
}
|
||||
|
||||
type DatabaseDiscoveryService struct {
|
||||
executor backup.CommandExecutor
|
||||
}
|
||||
|
||||
func NewDatabaseDiscoveryService(executor backup.CommandExecutor) *DatabaseDiscoveryService {
|
||||
return &DatabaseDiscoveryService{executor: executor}
|
||||
}
|
||||
|
||||
func (s *DatabaseDiscoveryService) Discover(ctx context.Context, input DatabaseDiscoverInput) (*DatabaseDiscoverResult, error) {
|
||||
switch strings.TrimSpace(strings.ToLower(input.Type)) {
|
||||
case "mysql":
|
||||
return s.discoverMySQL(ctx, input)
|
||||
case "postgresql":
|
||||
return s.discoverPostgreSQL(ctx, input)
|
||||
default:
|
||||
return nil, apperror.BadRequest("DATABASE_DISCOVER_INVALID_TYPE", "不支持的数据库类型", nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DatabaseDiscoveryService) discoverMySQL(ctx context.Context, input DatabaseDiscoverInput) (*DatabaseDiscoverResult, error) {
|
||||
mysqlPath, err := s.executor.LookPath("mysql")
|
||||
if err != nil {
|
||||
return nil, apperror.BadRequest("DATABASE_DISCOVER_MYSQL_NOT_FOUND", "系统未安装 mysql 客户端", err)
|
||||
}
|
||||
|
||||
timeout, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
args := []string{
|
||||
fmt.Sprintf("--host=%s", input.Host),
|
||||
fmt.Sprintf("--port=%d", input.Port),
|
||||
fmt.Sprintf("--user=%s", input.User),
|
||||
"-e", "SHOW DATABASES",
|
||||
"--skip-column-names",
|
||||
}
|
||||
env := []string{fmt.Sprintf("MYSQL_PWD=%s", input.Password)}
|
||||
|
||||
if err := s.executor.Run(timeout, mysqlPath, args, backup.CommandOptions{
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Env: env,
|
||||
}); err != nil {
|
||||
errMsg := strings.TrimSpace(stderr.String())
|
||||
if errMsg == "" {
|
||||
errMsg = err.Error()
|
||||
}
|
||||
return nil, apperror.BadRequest("DATABASE_DISCOVER_MYSQL_FAILED", fmt.Sprintf("连接 MySQL 失败:%s", sanitizeMessage(errMsg)), err)
|
||||
}
|
||||
|
||||
systemDBs := map[string]bool{
|
||||
"information_schema": true,
|
||||
"performance_schema": true,
|
||||
"mysql": true,
|
||||
"sys": true,
|
||||
}
|
||||
|
||||
var databases []string
|
||||
for _, line := range strings.Split(stdout.String(), "\n") {
|
||||
db := strings.TrimSpace(line)
|
||||
if db == "" || systemDBs[db] {
|
||||
continue
|
||||
}
|
||||
databases = append(databases, db)
|
||||
}
|
||||
|
||||
return &DatabaseDiscoverResult{Databases: databases}, nil
|
||||
}
|
||||
|
||||
func (s *DatabaseDiscoveryService) discoverPostgreSQL(ctx context.Context, input DatabaseDiscoverInput) (*DatabaseDiscoverResult, error) {
|
||||
psqlPath, err := s.executor.LookPath("psql")
|
||||
if err != nil {
|
||||
return nil, apperror.BadRequest("DATABASE_DISCOVER_PSQL_NOT_FOUND", "系统未安装 psql 客户端", err)
|
||||
}
|
||||
|
||||
timeout, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
args := []string{
|
||||
"-h", input.Host,
|
||||
"-p", fmt.Sprintf("%d", input.Port),
|
||||
"-U", input.User,
|
||||
"-d", "postgres",
|
||||
"-t", "-A",
|
||||
"-c", "SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname",
|
||||
}
|
||||
env := []string{fmt.Sprintf("PGPASSWORD=%s", input.Password)}
|
||||
|
||||
if err := s.executor.Run(timeout, psqlPath, args, backup.CommandOptions{
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Env: env,
|
||||
}); err != nil {
|
||||
errMsg := strings.TrimSpace(stderr.String())
|
||||
if errMsg == "" {
|
||||
errMsg = err.Error()
|
||||
}
|
||||
return nil, apperror.BadRequest("DATABASE_DISCOVER_PSQL_FAILED", fmt.Sprintf("连接 PostgreSQL 失败:%s", sanitizeMessage(errMsg)), err)
|
||||
}
|
||||
|
||||
skipDBs := map[string]bool{
|
||||
"postgres": true,
|
||||
}
|
||||
|
||||
var databases []string
|
||||
for _, line := range strings.Split(stdout.String(), "\n") {
|
||||
db := strings.TrimSpace(line)
|
||||
if db == "" || skipDBs[db] || strings.HasPrefix(db, "template") {
|
||||
continue
|
||||
}
|
||||
databases = append(databases, db)
|
||||
}
|
||||
|
||||
return &DatabaseDiscoverResult{Databases: databases}, nil
|
||||
}
|
||||
@@ -53,6 +53,7 @@ type StorageTargetSummary struct {
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Starred bool `json:"starred"`
|
||||
ConfigVersion int `json:"configVersion"`
|
||||
LastTestedAt *time.Time `json:"lastTestedAt"`
|
||||
LastTestStatus string `json:"lastTestStatus"`
|
||||
@@ -209,6 +210,22 @@ func (s *StorageTargetService) Delete(ctx context.Context, id uint) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *StorageTargetService) ToggleStar(ctx context.Context, id uint) (*StorageTargetSummary, error) {
|
||||
item, err := s.targets.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, apperror.Internal("STORAGE_TARGET_GET_FAILED", "无法获取存储目标详情", err)
|
||||
}
|
||||
if item == nil {
|
||||
return nil, apperror.New(http.StatusNotFound, "STORAGE_TARGET_NOT_FOUND", "存储目标不存在", fmt.Errorf("storage target %d not found", id))
|
||||
}
|
||||
item.Starred = !item.Starred
|
||||
if err := s.targets.Update(ctx, item); err != nil {
|
||||
return nil, apperror.Internal("STORAGE_TARGET_UPDATE_FAILED", "无法更新存储目标收藏状态", err)
|
||||
}
|
||||
summary := toStorageTargetSummary(item)
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
func (s *StorageTargetService) TestConnection(ctx context.Context, input StorageTargetTestInput) error {
|
||||
item, err := s.buildStorageTargetForTest(ctx, input)
|
||||
if err != nil {
|
||||
@@ -493,6 +510,7 @@ func toStorageTargetSummary(item *model.StorageTarget) StorageTargetSummary {
|
||||
Type: item.Type,
|
||||
Description: item.Description,
|
||||
Enabled: item.Enabled,
|
||||
Starred: item.Starred,
|
||||
ConfigVersion: item.ConfigVersion,
|
||||
LastTestedAt: item.LastTestedAt,
|
||||
LastTestStatus: item.LastTestStatus,
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
// Package aliyun provides an Aliyun OSS storage factory that delegates to the S3-compatible engine.
|
||||
// Aliyun OSS is fully S3-compatible; we auto-assemble the endpoint from the user-provided region.
|
||||
package aliyun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"backupx/server/internal/storage"
|
||||
"backupx/server/internal/storage/s3"
|
||||
)
|
||||
|
||||
// Config is the user-facing configuration for Aliyun OSS.
|
||||
type Config struct {
|
||||
Region string `json:"region"`
|
||||
Bucket string `json:"bucket"`
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
SecretAccessKey string `json:"secretAccessKey"`
|
||||
Endpoint string `json:"endpoint"` // optional override
|
||||
InternalNetwork bool `json:"internalNetwork"` // use -internal endpoint
|
||||
}
|
||||
|
||||
// Factory creates Aliyun OSS providers by composing the S3 engine.
|
||||
type Factory struct {
|
||||
s3Factory s3.Factory
|
||||
}
|
||||
|
||||
func NewFactory() Factory {
|
||||
return Factory{s3Factory: s3.NewFactory()}
|
||||
}
|
||||
|
||||
func (Factory) Type() storage.ProviderType { return storage.ProviderTypeAliyunOSS }
|
||||
func (Factory) SensitiveFields() []string { return []string{"accessKeyId", "secretAccessKey"} }
|
||||
|
||||
func (f Factory) New(ctx context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
|
||||
cfg, err := storage.DecodeConfig[Config](rawConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
endpoint := strings.TrimSpace(cfg.Endpoint)
|
||||
if endpoint == "" {
|
||||
region := strings.TrimSpace(cfg.Region)
|
||||
if region == "" {
|
||||
return nil, fmt.Errorf("aliyun oss region is required")
|
||||
}
|
||||
suffix := "aliyuncs.com"
|
||||
if cfg.InternalNetwork {
|
||||
endpoint = fmt.Sprintf("https://oss-%s-internal.%s", region, suffix)
|
||||
} else {
|
||||
endpoint = fmt.Sprintf("https://oss-%s.%s", region, suffix)
|
||||
}
|
||||
}
|
||||
|
||||
// Delegate to S3 engine with assembled endpoint.
|
||||
s3Config := map[string]any{
|
||||
"endpoint": endpoint,
|
||||
"region": cfg.Region,
|
||||
"bucket": cfg.Bucket,
|
||||
"accessKeyId": cfg.AccessKeyID,
|
||||
"secretAccessKey": cfg.SecretAccessKey,
|
||||
"forcePathStyle": false, // Aliyun OSS uses virtual-hosted style
|
||||
}
|
||||
return f.s3Factory.New(ctx, s3Config)
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
package ftp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/storage"
|
||||
|
||||
"github.com/jlaffaye/ftp"
|
||||
)
|
||||
|
||||
// Provider implements storage.StorageProvider for FTP.
|
||||
type Provider struct {
|
||||
config storage.FTPConfig
|
||||
}
|
||||
|
||||
// Factory creates FTP storage providers.
|
||||
type Factory struct{}
|
||||
|
||||
// NewFactory returns a new FTP Factory.
|
||||
func NewFactory() Factory {
|
||||
return Factory{}
|
||||
}
|
||||
|
||||
func (Factory) Type() storage.ProviderType { return storage.ProviderTypeFTP }
|
||||
func (Factory) SensitiveFields() []string { return []string{"username", "password"} }
|
||||
|
||||
func (f Factory) New(_ context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
|
||||
cfg, err := storage.DecodeConfig[storage.FTPConfig](rawConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(cfg.Host) == "" {
|
||||
return nil, fmt.Errorf("FTP host is required")
|
||||
}
|
||||
if cfg.Port == 0 {
|
||||
cfg.Port = 21
|
||||
}
|
||||
return &Provider{config: cfg}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Type() storage.ProviderType { return storage.ProviderTypeFTP }
|
||||
|
||||
// dial establishes a connection to the FTP server and logs in.
|
||||
func (p *Provider) dial() (*ftp.ServerConn, error) {
|
||||
addr := fmt.Sprintf("%s:%d", p.config.Host, p.config.Port)
|
||||
|
||||
var opts []ftp.DialOption
|
||||
opts = append(opts, ftp.DialWithTimeout(30*time.Second))
|
||||
if p.config.UseTLS {
|
||||
opts = append(opts, ftp.DialWithExplicitTLS(nil))
|
||||
}
|
||||
|
||||
conn, err := ftp.Dial(addr, opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to FTP server %s: %w", addr, err)
|
||||
}
|
||||
|
||||
username := p.config.Username
|
||||
if username == "" {
|
||||
username = "anonymous"
|
||||
}
|
||||
if err := conn.Login(username, p.config.Password); err != nil {
|
||||
conn.Quit()
|
||||
return nil, fmt.Errorf("FTP login: %w", err)
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func (p *Provider) TestConnection(_ context.Context) error {
|
||||
conn, err := p.dial()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Quit()
|
||||
|
||||
basePath := p.normalizeBasePath()
|
||||
if err := p.ensureDir(conn, basePath); err != nil {
|
||||
return fmt.Errorf("ensure FTP base path: %w", err)
|
||||
}
|
||||
_, err = conn.List(basePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list FTP base path: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) Upload(_ context.Context, objectKey string, reader io.Reader, _ int64, _ map[string]string) error {
|
||||
conn, err := p.dial()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Quit()
|
||||
|
||||
objectPath := p.resolvePath(objectKey)
|
||||
dir := path.Dir(objectPath)
|
||||
if err := p.ensureDir(conn, dir); err != nil {
|
||||
return fmt.Errorf("create FTP directories: %w", err)
|
||||
}
|
||||
|
||||
// Read all data into buffer since FTP STOR needs the full stream
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read upload data: %w", err)
|
||||
}
|
||||
|
||||
if err := conn.Stor(objectPath, bytes.NewReader(data)); err != nil {
|
||||
return fmt.Errorf("FTP upload: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) Download(_ context.Context, objectKey string) (io.ReadCloser, error) {
|
||||
conn, err := p.dial()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
objectPath := p.resolvePath(objectKey)
|
||||
resp, err := conn.Retr(objectPath)
|
||||
if err != nil {
|
||||
conn.Quit()
|
||||
return nil, fmt.Errorf("FTP download: %w", err)
|
||||
}
|
||||
|
||||
// Wrap the response to also close the FTP connection when done
|
||||
return &ftpReadCloser{ReadCloser: resp, conn: conn}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Delete(_ context.Context, objectKey string) error {
|
||||
conn, err := p.dial()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Quit()
|
||||
|
||||
objectPath := p.resolvePath(objectKey)
|
||||
if err := conn.Delete(objectPath); err != nil {
|
||||
return fmt.Errorf("FTP delete: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) List(_ context.Context, prefix string) ([]storage.ObjectInfo, error) {
|
||||
conn, err := p.dial()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Quit()
|
||||
|
||||
basePath := p.normalizeBasePath()
|
||||
entries, err := conn.List(basePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("FTP list: %w", err)
|
||||
}
|
||||
|
||||
items := make([]storage.ObjectInfo, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.Type == ftp.EntryTypeFolder {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimPrefix(path.Join(strings.TrimPrefix(basePath, "/"), entry.Name), "/")
|
||||
if prefix != "" && !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
items = append(items, storage.ObjectInfo{
|
||||
Key: key,
|
||||
Size: int64(entry.Size),
|
||||
UpdatedAt: entry.Time.UTC(),
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// normalizeBasePath returns a cleaned base path with leading slash.
|
||||
func (p *Provider) normalizeBasePath() string {
|
||||
clean := path.Clean("/" + strings.TrimSpace(p.config.BasePath))
|
||||
if clean == "." {
|
||||
return "/"
|
||||
}
|
||||
return clean
|
||||
}
|
||||
|
||||
// resolvePath returns the full FTP path for the given object key.
|
||||
func (p *Provider) resolvePath(objectKey string) string {
|
||||
cleanKey := path.Clean("/" + strings.TrimSpace(objectKey))
|
||||
return path.Clean(path.Join(p.normalizeBasePath(), cleanKey))
|
||||
}
|
||||
|
||||
// ensureDir creates all directories in the path recursively.
|
||||
func (p *Provider) ensureDir(conn *ftp.ServerConn, dirPath string) error {
|
||||
parts := strings.Split(strings.Trim(dirPath, "/"), "/")
|
||||
current := ""
|
||||
for _, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
current = current + "/" + part
|
||||
if err := conn.MakeDir(current); err != nil {
|
||||
// Ignore errors if directory already exists
|
||||
// FTP doesn't have a standard "mkdir if not exists"
|
||||
_ = err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ftpReadCloser wraps an io.ReadCloser from FTP and closes the connection when done.
|
||||
type ftpReadCloser struct {
|
||||
io.ReadCloser
|
||||
conn *ftp.ServerConn
|
||||
}
|
||||
|
||||
func (f *ftpReadCloser) Close() error {
|
||||
err := f.ReadCloser.Close()
|
||||
if f.conn != nil {
|
||||
f.conn.Quit()
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
package googledrive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/storage"
|
||||
"golang.org/x/oauth2"
|
||||
googleoauth "golang.org/x/oauth2/google"
|
||||
"google.golang.org/api/drive/v3"
|
||||
"google.golang.org/api/option"
|
||||
)
|
||||
|
||||
|
||||
type fileInfo struct {
|
||||
ID string
|
||||
Name string
|
||||
Size int64
|
||||
ModifiedTime time.Time
|
||||
}
|
||||
|
||||
type client interface {
|
||||
TestConnection(context.Context, string) error
|
||||
Upload(context.Context, string, string, io.Reader) error
|
||||
Download(context.Context, string, string) (io.ReadCloser, error)
|
||||
Delete(context.Context, string, string) error
|
||||
List(context.Context, string, string) ([]storage.ObjectInfo, error)
|
||||
EnsureFolder(ctx context.Context, parentID, name string) (string, error)
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
client client
|
||||
rootFolder string // user-configured folderId, empty means Drive root
|
||||
folderCache map[string]string // cache: path -> folderID
|
||||
}
|
||||
|
||||
type Factory struct {
|
||||
newClient func(context.Context, storage.GoogleDriveConfig) (client, error)
|
||||
}
|
||||
|
||||
func NewFactory() Factory {
|
||||
return Factory{newClient: newDriveClient}
|
||||
}
|
||||
|
||||
func (Factory) Type() storage.ProviderType { return storage.ProviderTypeGoogleDrive }
|
||||
func (Factory) SensitiveFields() []string {
|
||||
return []string{"clientId", "clientSecret", "refreshToken"}
|
||||
}
|
||||
|
||||
func (f Factory) New(ctx context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
|
||||
cfg, err := storage.DecodeConfig[storage.GoogleDriveConfig](rawConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg = cfg.Normalize()
|
||||
if strings.TrimSpace(cfg.ClientID) == "" || strings.TrimSpace(cfg.ClientSecret) == "" {
|
||||
return nil, fmt.Errorf("google drive client credentials are required")
|
||||
}
|
||||
if strings.TrimSpace(cfg.RefreshToken) == "" {
|
||||
return nil, fmt.Errorf("google drive refresh token is required")
|
||||
}
|
||||
newClient := f.newClient
|
||||
if newClient == nil {
|
||||
newClient = NewFactory().newClient
|
||||
}
|
||||
client, err := newClient(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Provider{
|
||||
client: client,
|
||||
rootFolder: strings.TrimSpace(cfg.FolderID),
|
||||
folderCache: make(map[string]string),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Type() storage.ProviderType { return storage.ProviderTypeGoogleDrive }
|
||||
|
||||
// ensureFolderPath creates nested folders for a path like "BackupX/file/260308"
|
||||
// and returns the deepest folder's ID.
|
||||
func (p *Provider) ensureFolderPath(ctx context.Context, folderPath string) (string, error) {
|
||||
if folderPath == "" || folderPath == "." {
|
||||
return p.rootFolder, nil
|
||||
}
|
||||
if cached, ok := p.folderCache[folderPath]; ok {
|
||||
return cached, nil
|
||||
}
|
||||
parts := strings.Split(path.Clean(folderPath), "/")
|
||||
parentID := p.rootFolder
|
||||
builtPath := ""
|
||||
for _, part := range parts {
|
||||
if part == "" || part == "." {
|
||||
continue
|
||||
}
|
||||
if builtPath == "" {
|
||||
builtPath = part
|
||||
} else {
|
||||
builtPath = builtPath + "/" + part
|
||||
}
|
||||
if cached, ok := p.folderCache[builtPath]; ok {
|
||||
parentID = cached
|
||||
continue
|
||||
}
|
||||
folderID, err := p.client.EnsureFolder(ctx, parentID, part)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ensure folder %s: %w", builtPath, err)
|
||||
}
|
||||
p.folderCache[builtPath] = folderID
|
||||
parentID = folderID
|
||||
}
|
||||
return parentID, nil
|
||||
}
|
||||
|
||||
func (p *Provider) TestConnection(ctx context.Context) error {
|
||||
return p.client.TestConnection(ctx, p.rootFolder)
|
||||
}
|
||||
|
||||
func (p *Provider) Upload(ctx context.Context, objectKey string, reader io.Reader, _ int64, _ map[string]string) error {
|
||||
dir := path.Dir(objectKey)
|
||||
folderID, err := p.ensureFolderPath(ctx, dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.client.Upload(ctx, folderID, objectKey, reader)
|
||||
}
|
||||
|
||||
func (p *Provider) Download(ctx context.Context, objectKey string) (io.ReadCloser, error) {
|
||||
dir := path.Dir(objectKey)
|
||||
folderID, err := p.ensureFolderPath(ctx, dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.client.Download(ctx, folderID, objectKey)
|
||||
}
|
||||
|
||||
func (p *Provider) Delete(ctx context.Context, objectKey string) error {
|
||||
dir := path.Dir(objectKey)
|
||||
folderID, err := p.ensureFolderPath(ctx, dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.client.Delete(ctx, folderID, objectKey)
|
||||
}
|
||||
|
||||
func (p *Provider) List(ctx context.Context, prefix string) ([]storage.ObjectInfo, error) {
|
||||
dir := path.Dir(prefix)
|
||||
folderID, err := p.ensureFolderPath(ctx, dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.client.List(ctx, folderID, prefix)
|
||||
}
|
||||
|
||||
type driveClient struct {
|
||||
service *drive.Service
|
||||
}
|
||||
|
||||
func newDriveClient(ctx context.Context, cfg storage.GoogleDriveConfig) (client, error) {
|
||||
cfg = cfg.Normalize()
|
||||
oauthCfg := &oauth2.Config{
|
||||
ClientID: cfg.ClientID,
|
||||
ClientSecret: cfg.ClientSecret,
|
||||
RedirectURL: cfg.RedirectURL,
|
||||
Endpoint: googleoauth.Endpoint,
|
||||
Scopes: []string{drive.DriveScope},
|
||||
}
|
||||
httpClient := oauthCfg.Client(ctx, &oauth2.Token{RefreshToken: cfg.RefreshToken})
|
||||
service, err := drive.NewService(ctx, option.WithHTTPClient(httpClient))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create google drive service: %w", err)
|
||||
}
|
||||
return &driveClient{service: service}, nil
|
||||
}
|
||||
|
||||
func (c *driveClient) TestConnection(ctx context.Context, folderID string) error {
|
||||
if strings.TrimSpace(folderID) == "" {
|
||||
_, err := c.service.About.Get().Fields("user").Context(ctx).Do()
|
||||
if err != nil {
|
||||
return fmt.Errorf("test google drive connection: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
_, err := c.service.Files.Get(folderID).Fields("id").Context(ctx).Do()
|
||||
if err != nil {
|
||||
return fmt.Errorf("test google drive folder: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *driveClient) EnsureFolder(ctx context.Context, parentID, name string) (string, error) {
|
||||
// Search for existing folder
|
||||
query := fmt.Sprintf("name = '%s' and mimeType = 'application/vnd.google-apps.folder' and trashed = false", escapeQuery(name))
|
||||
if strings.TrimSpace(parentID) != "" {
|
||||
query += fmt.Sprintf(" and '%s' in parents", escapeQuery(parentID))
|
||||
} else {
|
||||
query += " and 'root' in parents"
|
||||
}
|
||||
result, err := c.service.Files.List().Q(query).PageSize(1).Fields("files(id)").Context(ctx).Do()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("search for folder %s: %w", name, err)
|
||||
}
|
||||
if len(result.Files) > 0 {
|
||||
return result.Files[0].Id, nil
|
||||
}
|
||||
// Create the folder
|
||||
folder := &drive.File{
|
||||
Name: name,
|
||||
MimeType: "application/vnd.google-apps.folder",
|
||||
}
|
||||
if strings.TrimSpace(parentID) != "" {
|
||||
folder.Parents = []string{parentID}
|
||||
}
|
||||
created, err := c.service.Files.Create(folder).Fields("id").Context(ctx).Do()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create folder %s: %w", name, err)
|
||||
}
|
||||
return created.Id, nil
|
||||
}
|
||||
|
||||
func (c *driveClient) Upload(ctx context.Context, folderID, objectKey string, reader io.Reader) error {
|
||||
file := &drive.File{Name: path.Base(objectKey)}
|
||||
if strings.TrimSpace(folderID) != "" {
|
||||
file.Parents = []string{folderID}
|
||||
}
|
||||
_, err := c.service.Files.Create(file).Media(reader).Context(ctx).Do()
|
||||
if err != nil {
|
||||
return fmt.Errorf("upload google drive object: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *driveClient) Download(ctx context.Context, folderID, objectKey string) (io.ReadCloser, error) {
|
||||
file, err := c.findFile(ctx, folderID, objectKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response, err := c.service.Files.Get(file.ID).Context(ctx).Download()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download google drive object: %w", err)
|
||||
}
|
||||
return response.Body, nil
|
||||
}
|
||||
|
||||
func (c *driveClient) Delete(ctx context.Context, folderID, objectKey string) error {
|
||||
file, err := c.findFile(ctx, folderID, objectKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.service.Files.Delete(file.ID).Context(ctx).Do(); err != nil {
|
||||
return fmt.Errorf("delete google drive object: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *driveClient) List(ctx context.Context, folderID, prefix string) ([]storage.ObjectInfo, error) {
|
||||
query := "trashed = false"
|
||||
if strings.TrimSpace(folderID) != "" {
|
||||
query += fmt.Sprintf(" and '%s' in parents", escapeQuery(folderID))
|
||||
}
|
||||
if strings.TrimSpace(prefix) != "" {
|
||||
query += fmt.Sprintf(" and name contains '%s'", escapeQuery(prefix))
|
||||
}
|
||||
result, err := c.service.Files.List().Q(query).Fields("files(id,name,size,modifiedTime)").Context(ctx).Do()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list google drive objects: %w", err)
|
||||
}
|
||||
items := make([]storage.ObjectInfo, 0, len(result.Files))
|
||||
for _, file := range result.Files {
|
||||
modifiedAt, _ := time.Parse(time.RFC3339, file.ModifiedTime)
|
||||
items = append(items, storage.ObjectInfo{Key: file.Name, Size: file.Size, UpdatedAt: modifiedAt.UTC()})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (c *driveClient) findFile(ctx context.Context, folderID, objectKey string) (*fileInfo, error) {
|
||||
query := fmt.Sprintf("name = '%s' and trashed = false", escapeQuery(path.Base(objectKey)))
|
||||
if strings.TrimSpace(folderID) != "" {
|
||||
query += fmt.Sprintf(" and '%s' in parents", escapeQuery(folderID))
|
||||
}
|
||||
result, err := c.service.Files.List().Q(query).PageSize(1).Fields("files(id,name,size,modifiedTime)").Context(ctx).Do()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query google drive object: %w", err)
|
||||
}
|
||||
if len(result.Files) == 0 {
|
||||
return nil, fmt.Errorf("google drive object not found: %s", objectKey)
|
||||
}
|
||||
file := result.Files[0]
|
||||
modifiedAt, _ := time.Parse(time.RFC3339, file.ModifiedTime)
|
||||
return &fileInfo{ID: file.Id, Name: file.Name, Size: file.Size, ModifiedTime: modifiedAt.UTC()}, nil
|
||||
}
|
||||
|
||||
func escapeQuery(value string) string {
|
||||
return strings.ReplaceAll(value, "'", "\\'")
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
package googledrive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/storage"
|
||||
)
|
||||
|
||||
type fakeClient struct{ data map[string]string }
|
||||
|
||||
func (c *fakeClient) TestConnection(context.Context, string) error { return nil }
|
||||
func (c *fakeClient) Upload(_ context.Context, _ string, objectKey string, reader io.Reader) error {
|
||||
content, _ := io.ReadAll(reader)
|
||||
c.data[objectKey] = string(content)
|
||||
return nil
|
||||
}
|
||||
func (c *fakeClient) Download(_ context.Context, _ string, objectKey string) (io.ReadCloser, error) {
|
||||
return io.NopCloser(strings.NewReader(c.data[objectKey])), nil
|
||||
}
|
||||
func (c *fakeClient) Delete(_ context.Context, _ string, objectKey string) error {
|
||||
delete(c.data, objectKey)
|
||||
return nil
|
||||
}
|
||||
func (c *fakeClient) List(_ context.Context, _ string, prefix string) ([]storage.ObjectInfo, error) {
|
||||
items := make([]storage.ObjectInfo, 0)
|
||||
for key, value := range c.data {
|
||||
if prefix == "" || strings.HasPrefix(key, prefix) {
|
||||
items = append(items, storage.ObjectInfo{Key: key, Size: int64(len(value)), UpdatedAt: time.Now().UTC()})
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
func (c *fakeClient) EnsureFolder(_ context.Context, _, name string) (string, error) {
|
||||
return "fake-folder-" + name, nil
|
||||
}
|
||||
|
||||
func TestGoogleDriveProviderCRUD(t *testing.T) {
|
||||
factory := Factory{newClient: func(context.Context, storage.GoogleDriveConfig) (client, error) {
|
||||
return &fakeClient{data: make(map[string]string)}, nil
|
||||
}}
|
||||
providerAny, err := factory.New(context.Background(), map[string]any{"clientId": "id", "clientSecret": "secret", "refreshToken": "refresh"})
|
||||
if err != nil {
|
||||
t.Fatalf("Factory.New returned error: %v", err)
|
||||
}
|
||||
provider := providerAny.(*Provider)
|
||||
if err := provider.TestConnection(context.Background()); err != nil {
|
||||
t.Fatalf("TestConnection returned error: %v", err)
|
||||
}
|
||||
if err := provider.Upload(context.Background(), "backup.tar.gz", strings.NewReader("payload"), 7, nil); err != nil {
|
||||
t.Fatalf("Upload returned error: %v", err)
|
||||
}
|
||||
reader, err := provider.Download(context.Background(), "backup.tar.gz")
|
||||
if err != nil {
|
||||
t.Fatalf("Download returned error: %v", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
content, _ := io.ReadAll(reader)
|
||||
if string(content) != "payload" {
|
||||
t.Fatalf("unexpected content: %s", string(content))
|
||||
}
|
||||
items, err := provider.List(context.Background(), "backup")
|
||||
if err != nil {
|
||||
t.Fatalf("List returned error: %v", err)
|
||||
}
|
||||
if len(items) != 1 || items[0].Key != "backup.tar.gz" {
|
||||
t.Fatalf("unexpected list result: %#v", items)
|
||||
}
|
||||
if err := provider.Delete(context.Background(), "backup.tar.gz"); err != nil {
|
||||
t.Fatalf("Delete returned error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
package localdisk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"backupx/server/internal/storage"
|
||||
)
|
||||
|
||||
type Provider struct {
|
||||
basePath string
|
||||
}
|
||||
|
||||
type Factory struct{}
|
||||
|
||||
func NewFactory() Factory { return Factory{} }
|
||||
|
||||
func (Factory) Type() storage.ProviderType { return storage.ProviderTypeLocalDisk }
|
||||
func (Factory) SensitiveFields() []string { return nil }
|
||||
|
||||
func (Factory) New(_ context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
|
||||
cfg, err := storage.DecodeConfig[storage.LocalDiskConfig](rawConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(cfg.BasePath) == "" {
|
||||
return nil, fmt.Errorf("local disk basePath is required")
|
||||
}
|
||||
return &Provider{basePath: filepath.Clean(cfg.BasePath)}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Type() storage.ProviderType { return storage.ProviderTypeLocalDisk }
|
||||
|
||||
func (p *Provider) TestConnection(_ context.Context) error {
|
||||
if err := os.MkdirAll(p.basePath, 0o755); err != nil {
|
||||
return fmt.Errorf("ensure local disk base path: %w", err)
|
||||
}
|
||||
tempFile, err := os.CreateTemp(p.basePath, ".backupx-connection-test-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("write access check failed: %w", err)
|
||||
}
|
||||
name := tempFile.Name()
|
||||
_ = tempFile.Close()
|
||||
_ = os.Remove(name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) Upload(_ context.Context, objectKey string, reader io.Reader, _ int64, _ map[string]string) error {
|
||||
targetPath, err := p.resolvePath(objectKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
||||
return fmt.Errorf("create local disk directories: %w", err)
|
||||
}
|
||||
file, err := os.Create(targetPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create local disk object: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
if _, err := io.Copy(file, reader); err != nil {
|
||||
return fmt.Errorf("write local disk object: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) Download(_ context.Context, objectKey string) (io.ReadCloser, error) {
|
||||
targetPath, err := p.resolvePath(objectKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file, err := os.Open(targetPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open local disk object: %w", err)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Delete(_ context.Context, objectKey string) error {
|
||||
targetPath, err := p.resolvePath(objectKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(targetPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("delete local disk object: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) List(_ context.Context, prefix string) ([]storage.ObjectInfo, error) {
|
||||
items := make([]storage.ObjectInfo, 0)
|
||||
err := filepath.WalkDir(p.basePath, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(p.basePath, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := filepath.ToSlash(rel)
|
||||
if prefix != "" && !strings.HasPrefix(key, prefix) {
|
||||
return nil
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items = append(items, storage.ObjectInfo{Key: key, Size: info.Size(), UpdatedAt: info.ModTime().UTC()})
|
||||
return nil
|
||||
})
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("list local disk objects: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (p *Provider) resolvePath(objectKey string) (string, error) {
|
||||
cleanBase := filepath.Clean(p.basePath)
|
||||
cleanKey := filepath.Clean(filepath.FromSlash(strings.TrimSpace(objectKey)))
|
||||
if cleanKey == "." || cleanKey == string(filepath.Separator) || cleanKey == "" {
|
||||
return "", fmt.Errorf("object key is required")
|
||||
}
|
||||
fullPath := filepath.Clean(filepath.Join(cleanBase, cleanKey))
|
||||
baseWithSep := cleanBase + string(filepath.Separator)
|
||||
if fullPath != cleanBase && !strings.HasPrefix(fullPath, baseWithSep) {
|
||||
return "", fmt.Errorf("object key escapes base path")
|
||||
}
|
||||
return fullPath, nil
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package localdisk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLocalDiskProviderCRUD(t *testing.T) {
|
||||
providerAny, err := (Factory{}).New(context.Background(), map[string]any{"basePath": t.TempDir()})
|
||||
if err != nil {
|
||||
t.Fatalf("Factory.New returned error: %v", err)
|
||||
}
|
||||
provider := providerAny.(*Provider)
|
||||
if err := provider.TestConnection(context.Background()); err != nil {
|
||||
t.Fatalf("TestConnection returned error: %v", err)
|
||||
}
|
||||
if err := provider.Upload(context.Background(), "daily/backup.txt", strings.NewReader("hello"), 5, nil); err != nil {
|
||||
t.Fatalf("Upload returned error: %v", err)
|
||||
}
|
||||
reader, err := provider.Download(context.Background(), "daily/backup.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Download returned error: %v", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
content, _ := io.ReadAll(reader)
|
||||
if string(content) != "hello" {
|
||||
t.Fatalf("expected downloaded content to match, got %s", string(content))
|
||||
}
|
||||
items, err := provider.List(context.Background(), "daily")
|
||||
if err != nil {
|
||||
t.Fatalf("List returned error: %v", err)
|
||||
}
|
||||
if len(items) != 1 || items[0].Key != "daily/backup.txt" {
|
||||
t.Fatalf("unexpected list result: %#v", items)
|
||||
}
|
||||
if err := provider.Delete(context.Background(), "daily/backup.txt"); err != nil {
|
||||
t.Fatalf("Delete returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDiskProviderRejectsTraversal(t *testing.T) {
|
||||
providerAny, err := (Factory{}).New(context.Background(), map[string]any{"basePath": t.TempDir()})
|
||||
if err != nil {
|
||||
t.Fatalf("Factory.New returned error: %v", err)
|
||||
}
|
||||
provider := providerAny.(*Provider)
|
||||
if _, err := provider.resolvePath("../escape.txt"); err == nil {
|
||||
t.Fatalf("expected traversal to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
// Package qiniu provides a Qiniu Cloud Kodo storage factory that delegates to the S3-compatible engine.
|
||||
// Qiniu Kodo is S3-compatible; we auto-assemble the endpoint from the user-provided region.
|
||||
package qiniu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"backupx/server/internal/storage"
|
||||
"backupx/server/internal/storage/s3"
|
||||
)
|
||||
|
||||
// Config is the user-facing configuration for Qiniu Kodo.
|
||||
type Config struct {
|
||||
Region string `json:"region"` // e.g. z0, z1, z2, na0, as0
|
||||
Bucket string `json:"bucket"`
|
||||
AccessKey string `json:"accessKeyId"`
|
||||
SecretKey string `json:"secretAccessKey"`
|
||||
Endpoint string `json:"endpoint"` // optional override
|
||||
}
|
||||
|
||||
// regionEndpoints maps Qiniu storage region codes to their S3-compatible endpoints.
|
||||
var regionEndpoints = map[string]string{
|
||||
"z0": "https://s3-cn-east-1.qiniucs.com",
|
||||
"cn-east-2": "https://s3-cn-east-2.qiniucs.com",
|
||||
"z1": "https://s3-cn-north-1.qiniucs.com",
|
||||
"z2": "https://s3-cn-south-1.qiniucs.com",
|
||||
"na0": "https://s3-us-north-1.qiniucs.com",
|
||||
"as0": "https://s3-ap-southeast-1.qiniucs.com",
|
||||
}
|
||||
|
||||
// Factory creates Qiniu Kodo providers by composing the S3 engine.
|
||||
type Factory struct {
|
||||
s3Factory s3.Factory
|
||||
}
|
||||
|
||||
func NewFactory() Factory {
|
||||
return Factory{s3Factory: s3.NewFactory()}
|
||||
}
|
||||
|
||||
func (Factory) Type() storage.ProviderType { return storage.ProviderTypeQiniuKodo }
|
||||
func (Factory) SensitiveFields() []string { return []string{"accessKeyId", "secretAccessKey"} }
|
||||
|
||||
func (f Factory) New(ctx context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
|
||||
cfg, err := storage.DecodeConfig[Config](rawConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
endpoint := strings.TrimSpace(cfg.Endpoint)
|
||||
if endpoint == "" {
|
||||
region := strings.TrimSpace(cfg.Region)
|
||||
if region == "" {
|
||||
return nil, fmt.Errorf("qiniu kodo region is required")
|
||||
}
|
||||
var ok bool
|
||||
endpoint, ok = regionEndpoints[region]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unsupported qiniu region: %s (supported: z0, cn-east-2, z1, z2, na0, as0)", region)
|
||||
}
|
||||
}
|
||||
|
||||
s3Config := map[string]any{
|
||||
"endpoint": endpoint,
|
||||
"region": cfg.Region,
|
||||
"bucket": cfg.Bucket,
|
||||
"accessKeyId": cfg.AccessKey,
|
||||
"secretAccessKey": cfg.SecretKey,
|
||||
"forcePathStyle": true, // Qiniu S3-compatible uses path-style
|
||||
}
|
||||
return f.s3Factory.New(ctx, s3Config)
|
||||
}
|
||||
11
server/internal/storage/rclone/backends.go
Normal file
11
server/internal/storage/rclone/backends.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// Package rclone 提供基于 rclone 的统一存储后端实现。
|
||||
// 按需引入 rclone backend,避免 backend/all 导致二进制膨胀。
|
||||
package rclone
|
||||
|
||||
import (
|
||||
_ "github.com/rclone/rclone/backend/drive"
|
||||
_ "github.com/rclone/rclone/backend/ftp"
|
||||
_ "github.com/rclone/rclone/backend/local"
|
||||
_ "github.com/rclone/rclone/backend/s3"
|
||||
_ "github.com/rclone/rclone/backend/webdav"
|
||||
)
|
||||
347
server/internal/storage/rclone/factory.go
Normal file
347
server/internal/storage/rclone/factory.go
Normal file
@@ -0,0 +1,347 @@
|
||||
package rclone
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"backupx/server/internal/storage"
|
||||
|
||||
"github.com/rclone/rclone/fs"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 辅助函数
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// quoteParam 对 rclone 连接字符串中含特殊字符的值加单引号保护。
|
||||
func quoteParam(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
if !strings.ContainsAny(s, ",:='") {
|
||||
return s
|
||||
}
|
||||
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
|
||||
}
|
||||
|
||||
// newFs 创建 rclone fs.Fs 实例并包装为 Provider。
|
||||
func newFs(ctx context.Context, providerType storage.ProviderType, remote string) (*Provider, error) {
|
||||
rfs, err := fs.NewFs(ctx, remote)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create rclone fs for %s: %w", providerType, err)
|
||||
}
|
||||
return newProvider(providerType, rfs), nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LocalDisk
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type LocalDiskFactory struct{}
|
||||
|
||||
func NewLocalDiskFactory() LocalDiskFactory { return LocalDiskFactory{} }
|
||||
|
||||
func (LocalDiskFactory) Type() storage.ProviderType { return storage.ProviderTypeLocalDisk }
|
||||
func (LocalDiskFactory) SensitiveFields() []string { return nil }
|
||||
|
||||
func (LocalDiskFactory) New(ctx context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
|
||||
cfg, err := storage.DecodeConfig[storage.LocalDiskConfig](rawConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
basePath := strings.TrimSpace(cfg.BasePath)
|
||||
if basePath == "" {
|
||||
return nil, fmt.Errorf("local disk basePath is required")
|
||||
}
|
||||
return newFs(ctx, storage.ProviderTypeLocalDisk, basePath)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// S3
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type S3Factory struct{}
|
||||
|
||||
func NewS3Factory() S3Factory { return S3Factory{} }
|
||||
|
||||
func (S3Factory) Type() storage.ProviderType { return storage.ProviderTypeS3 }
|
||||
func (S3Factory) SensitiveFields() []string { return []string{"accessKeyId", "secretAccessKey"} }
|
||||
|
||||
func (S3Factory) New(ctx context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
|
||||
cfg, err := storage.DecodeConfig[storage.S3Config](rawConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(cfg.Bucket) == "" {
|
||||
return nil, fmt.Errorf("s3 bucket is required")
|
||||
}
|
||||
if strings.TrimSpace(cfg.AccessKeyID) == "" || strings.TrimSpace(cfg.SecretAccessKey) == "" {
|
||||
return nil, fmt.Errorf("s3 credentials are required")
|
||||
}
|
||||
return newFs(ctx, storage.ProviderTypeS3, buildS3Remote("Other", cfg.AccessKeyID, cfg.SecretAccessKey, cfg.Endpoint, cfg.Region, cfg.Bucket, cfg.ForcePathStyle))
|
||||
}
|
||||
|
||||
// buildS3Remote 构建 S3 兼容存储的 rclone 连接字符串。
|
||||
func buildS3Remote(provider, keyID, secret, endpoint, region, bucket string, pathStyle bool) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(":s3,provider=")
|
||||
b.WriteString(quoteParam(provider))
|
||||
b.WriteString(",access_key_id=")
|
||||
b.WriteString(quoteParam(keyID))
|
||||
b.WriteString(",secret_access_key=")
|
||||
b.WriteString(quoteParam(secret))
|
||||
if strings.TrimSpace(endpoint) != "" {
|
||||
b.WriteString(",endpoint=")
|
||||
b.WriteString(quoteParam(strings.TrimRight(endpoint, "/")))
|
||||
}
|
||||
if strings.TrimSpace(region) != "" {
|
||||
b.WriteString(",region=")
|
||||
b.WriteString(quoteParam(region))
|
||||
}
|
||||
if pathStyle {
|
||||
b.WriteString(",force_path_style=true")
|
||||
}
|
||||
b.WriteString(",env_auth=false,no_check_bucket=true:")
|
||||
b.WriteString(bucket)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebDAV
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type WebDAVFactory struct{}
|
||||
|
||||
func NewWebDAVFactory() WebDAVFactory { return WebDAVFactory{} }
|
||||
|
||||
func (WebDAVFactory) Type() storage.ProviderType { return storage.ProviderTypeWebDAV }
|
||||
func (WebDAVFactory) SensitiveFields() []string { return []string{"username", "password"} }
|
||||
|
||||
func (WebDAVFactory) New(ctx context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
|
||||
cfg, err := storage.DecodeConfig[storage.WebDAVConfig](rawConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(cfg.Endpoint) == "" {
|
||||
return nil, fmt.Errorf("webdav endpoint is required")
|
||||
}
|
||||
remote := fmt.Sprintf(":webdav,url=%s,user=%s,pass=%s:%s",
|
||||
quoteParam(strings.TrimRight(cfg.Endpoint, "/")),
|
||||
quoteParam(cfg.Username),
|
||||
quoteParam(cfg.Password),
|
||||
strings.TrimSpace(cfg.BasePath))
|
||||
return newFs(ctx, storage.ProviderTypeWebDAV, remote)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Google Drive
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type GoogleDriveFactory struct{}
|
||||
|
||||
func NewGoogleDriveFactory() GoogleDriveFactory { return GoogleDriveFactory{} }
|
||||
|
||||
func (GoogleDriveFactory) Type() storage.ProviderType { return storage.ProviderTypeGoogleDrive }
|
||||
func (GoogleDriveFactory) SensitiveFields() []string {
|
||||
return []string{"clientId", "clientSecret", "refreshToken"}
|
||||
}
|
||||
|
||||
func (GoogleDriveFactory) New(ctx context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
|
||||
cfg, err := storage.DecodeConfig[storage.GoogleDriveConfig](rawConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg = cfg.Normalize()
|
||||
if strings.TrimSpace(cfg.ClientID) == "" || strings.TrimSpace(cfg.ClientSecret) == "" {
|
||||
return nil, fmt.Errorf("google drive client credentials are required")
|
||||
}
|
||||
if strings.TrimSpace(cfg.RefreshToken) == "" {
|
||||
return nil, fmt.Errorf("google drive refresh token is required")
|
||||
}
|
||||
// 构造 rclone 所需的 OAuth2 token JSON
|
||||
tokenJSON := fmt.Sprintf(`{"access_token":"","token_type":"Bearer","refresh_token":"%s","expiry":"0001-01-01T00:00:00Z"}`,
|
||||
strings.ReplaceAll(cfg.RefreshToken, `"`, `\"`))
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(":drive,client_id=")
|
||||
b.WriteString(quoteParam(cfg.ClientID))
|
||||
b.WriteString(",client_secret=")
|
||||
b.WriteString(quoteParam(cfg.ClientSecret))
|
||||
b.WriteString(",token=")
|
||||
b.WriteString(quoteParam(tokenJSON))
|
||||
if strings.TrimSpace(cfg.FolderID) != "" {
|
||||
b.WriteString(",root_folder_id=")
|
||||
b.WriteString(quoteParam(cfg.FolderID))
|
||||
}
|
||||
b.WriteString(":")
|
||||
return newFs(ctx, storage.ProviderTypeGoogleDrive, b.String())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FTP
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type FTPFactory struct{}
|
||||
|
||||
func NewFTPFactory() FTPFactory { return FTPFactory{} }
|
||||
|
||||
func (FTPFactory) Type() storage.ProviderType { return storage.ProviderTypeFTP }
|
||||
func (FTPFactory) SensitiveFields() []string { return []string{"username", "password"} }
|
||||
|
||||
func (FTPFactory) New(ctx context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
|
||||
cfg, err := storage.DecodeConfig[storage.FTPConfig](rawConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(cfg.Host) == "" {
|
||||
return nil, fmt.Errorf("FTP host is required")
|
||||
}
|
||||
port := cfg.Port
|
||||
if port == 0 {
|
||||
port = 21
|
||||
}
|
||||
username := strings.TrimSpace(cfg.Username)
|
||||
if username == "" {
|
||||
username = "anonymous"
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf(":ftp,host=%s,port=%d,user=%s,pass=%s",
|
||||
quoteParam(cfg.Host), port, quoteParam(username), quoteParam(cfg.Password)))
|
||||
if cfg.UseTLS {
|
||||
b.WriteString(",tls=true,explicit_tls=true")
|
||||
}
|
||||
b.WriteString(":")
|
||||
basePath := strings.TrimSpace(cfg.BasePath)
|
||||
if basePath != "" {
|
||||
b.WriteString(basePath)
|
||||
}
|
||||
return newFs(ctx, storage.ProviderTypeFTP, b.String())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 阿里云 OSS(委托 S3 引擎)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type AliyunOSSFactory struct{}
|
||||
|
||||
func NewAliyunOSSFactory() AliyunOSSFactory { return AliyunOSSFactory{} }
|
||||
|
||||
func (AliyunOSSFactory) Type() storage.ProviderType { return storage.ProviderTypeAliyunOSS }
|
||||
func (AliyunOSSFactory) SensitiveFields() []string { return []string{"accessKeyId", "secretAccessKey"} }
|
||||
|
||||
// AliyunConfig 是阿里云 OSS 的用户配置。
|
||||
type AliyunConfig struct {
|
||||
Region string `json:"region"`
|
||||
Bucket string `json:"bucket"`
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
SecretAccessKey string `json:"secretAccessKey"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
InternalNetwork bool `json:"internalNetwork"`
|
||||
}
|
||||
|
||||
func (AliyunOSSFactory) New(ctx context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
|
||||
cfg, err := storage.DecodeConfig[AliyunConfig](rawConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
endpoint := strings.TrimSpace(cfg.Endpoint)
|
||||
if endpoint == "" {
|
||||
region := strings.TrimSpace(cfg.Region)
|
||||
if region == "" {
|
||||
return nil, fmt.Errorf("aliyun oss region is required")
|
||||
}
|
||||
if cfg.InternalNetwork {
|
||||
endpoint = fmt.Sprintf("https://oss-%s-internal.aliyuncs.com", region)
|
||||
} else {
|
||||
endpoint = fmt.Sprintf("https://oss-%s.aliyuncs.com", region)
|
||||
}
|
||||
}
|
||||
return newFs(ctx, storage.ProviderTypeAliyunOSS, buildS3Remote("Alibaba", cfg.AccessKeyID, cfg.SecretAccessKey, endpoint, cfg.Region, cfg.Bucket, false))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 腾讯云 COS(委托 S3 引擎)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TencentCOSFactory struct{}
|
||||
|
||||
func NewTencentCOSFactory() TencentCOSFactory { return TencentCOSFactory{} }
|
||||
|
||||
func (TencentCOSFactory) Type() storage.ProviderType { return storage.ProviderTypeTencentCOS }
|
||||
func (TencentCOSFactory) SensitiveFields() []string { return []string{"accessKeyId", "secretAccessKey"} }
|
||||
|
||||
// TencentConfig 是腾讯云 COS 的用户配置。
|
||||
type TencentConfig struct {
|
||||
Region string `json:"region"`
|
||||
Bucket string `json:"bucket"`
|
||||
SecretID string `json:"accessKeyId"`
|
||||
SecretKey string `json:"secretAccessKey"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
}
|
||||
|
||||
func (TencentCOSFactory) New(ctx context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
|
||||
cfg, err := storage.DecodeConfig[TencentConfig](rawConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
endpoint := strings.TrimSpace(cfg.Endpoint)
|
||||
if endpoint == "" {
|
||||
region := strings.TrimSpace(cfg.Region)
|
||||
if region == "" {
|
||||
return nil, fmt.Errorf("tencent cos region is required")
|
||||
}
|
||||
endpoint = fmt.Sprintf("https://cos.%s.myqcloud.com", region)
|
||||
}
|
||||
return newFs(ctx, storage.ProviderTypeTencentCOS, buildS3Remote("TencentCOS", cfg.SecretID, cfg.SecretKey, endpoint, cfg.Region, cfg.Bucket, false))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 七牛云 Kodo(委托 S3 引擎)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type QiniuKodoFactory struct{}
|
||||
|
||||
func NewQiniuKodoFactory() QiniuKodoFactory { return QiniuKodoFactory{} }
|
||||
|
||||
func (QiniuKodoFactory) Type() storage.ProviderType { return storage.ProviderTypeQiniuKodo }
|
||||
func (QiniuKodoFactory) SensitiveFields() []string { return []string{"accessKeyId", "secretAccessKey"} }
|
||||
|
||||
// QiniuConfig 是七牛云 Kodo 的用户配置。
|
||||
type QiniuConfig struct {
|
||||
Region string `json:"region"`
|
||||
Bucket string `json:"bucket"`
|
||||
AccessKey string `json:"accessKeyId"`
|
||||
SecretKey string `json:"secretAccessKey"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
}
|
||||
|
||||
// regionEndpoints 映射七牛区域代码到 S3 兼容 endpoint。
|
||||
var regionEndpoints = map[string]string{
|
||||
"z0": "https://s3-cn-east-1.qiniucs.com",
|
||||
"cn-east-2": "https://s3-cn-east-2.qiniucs.com",
|
||||
"z1": "https://s3-cn-north-1.qiniucs.com",
|
||||
"z2": "https://s3-cn-south-1.qiniucs.com",
|
||||
"na0": "https://s3-us-north-1.qiniucs.com",
|
||||
"as0": "https://s3-ap-southeast-1.qiniucs.com",
|
||||
}
|
||||
|
||||
func (QiniuKodoFactory) New(ctx context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
|
||||
cfg, err := storage.DecodeConfig[QiniuConfig](rawConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
endpoint := strings.TrimSpace(cfg.Endpoint)
|
||||
if endpoint == "" {
|
||||
region := strings.TrimSpace(cfg.Region)
|
||||
if region == "" {
|
||||
return nil, fmt.Errorf("qiniu kodo region is required")
|
||||
}
|
||||
var ok bool
|
||||
endpoint, ok = regionEndpoints[region]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unsupported qiniu region: %s (supported: z0, cn-east-2, z1, z2, na0, as0)", region)
|
||||
}
|
||||
}
|
||||
return newFs(ctx, storage.ProviderTypeQiniuKodo, buildS3Remote("Qiniu", cfg.AccessKey, cfg.SecretKey, endpoint, cfg.Region, cfg.Bucket, true))
|
||||
}
|
||||
112
server/internal/storage/rclone/provider.go
Normal file
112
server/internal/storage/rclone/provider.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package rclone
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/storage"
|
||||
|
||||
"github.com/rclone/rclone/fs"
|
||||
"github.com/rclone/rclone/fs/object"
|
||||
"github.com/rclone/rclone/fs/walk"
|
||||
)
|
||||
|
||||
// Provider 包装 rclone fs.Fs,实现 storage.StorageProvider 接口。
|
||||
type Provider struct {
|
||||
providerType storage.ProviderType
|
||||
rfs fs.Fs
|
||||
}
|
||||
|
||||
func newProvider(providerType storage.ProviderType, rfs fs.Fs) *Provider {
|
||||
return &Provider{providerType: providerType, rfs: rfs}
|
||||
}
|
||||
|
||||
func (p *Provider) Type() storage.ProviderType { return p.providerType }
|
||||
|
||||
// TestConnection 通过列出根目录验证连通性。
|
||||
func (p *Provider) TestConnection(ctx context.Context) error {
|
||||
_, err := p.rfs.List(ctx, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("rclone test connection: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Upload 通过 rclone fs.Fs.Put 上传文件。
|
||||
func (p *Provider) Upload(ctx context.Context, objectKey string, reader io.Reader, size int64, _ map[string]string) error {
|
||||
dir := pathDir(objectKey)
|
||||
if dir != "" && dir != "." {
|
||||
if err := p.rfs.Mkdir(ctx, dir); err != nil {
|
||||
return fmt.Errorf("rclone mkdir %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
info := object.NewStaticObjectInfo(objectKey, time.Now(), size, true, nil, nil)
|
||||
if _, err := p.rfs.Put(ctx, reader, info); err != nil {
|
||||
return fmt.Errorf("rclone upload %s: %w", objectKey, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Download 通过 rclone 获取对象并返回 io.ReadCloser。
|
||||
func (p *Provider) Download(ctx context.Context, objectKey string) (io.ReadCloser, error) {
|
||||
obj, err := p.rfs.NewObject(ctx, objectKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rclone find object %s: %w", objectKey, err)
|
||||
}
|
||||
reader, err := obj.Open(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rclone download %s: %w", objectKey, err)
|
||||
}
|
||||
return reader, nil
|
||||
}
|
||||
|
||||
// Delete 通过 rclone 删除远端对象。
|
||||
func (p *Provider) Delete(ctx context.Context, objectKey string) error {
|
||||
obj, err := p.rfs.NewObject(ctx, objectKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rclone find object %s: %w", objectKey, err)
|
||||
}
|
||||
if err := obj.Remove(ctx); err != nil {
|
||||
return fmt.Errorf("rclone delete %s: %w", objectKey, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List 递归列出指定前缀下的所有对象。
|
||||
func (p *Provider) List(ctx context.Context, prefix string) ([]storage.ObjectInfo, error) {
|
||||
var items []storage.ObjectInfo
|
||||
err := walk.ListR(ctx, p.rfs, prefix, true, -1, walk.ListObjects, func(entries fs.DirEntries) error {
|
||||
for _, entry := range entries {
|
||||
obj, ok := entry.(fs.Object)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key := obj.Remote()
|
||||
if prefix != "" && !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
items = append(items, storage.ObjectInfo{
|
||||
Key: key,
|
||||
Size: obj.Size(),
|
||||
UpdatedAt: obj.ModTime(ctx),
|
||||
})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rclone list %s: %w", prefix, err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// pathDir 返回 objectKey 的目录部分(正斜杠分隔)。
|
||||
func pathDir(objectKey string) string {
|
||||
idx := strings.LastIndex(objectKey, "/")
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
return objectKey[:idx]
|
||||
}
|
||||
129
server/internal/storage/rclone/provider_test.go
Normal file
129
server/internal/storage/rclone/provider_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package rclone
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProviderLocalDiskCRUD(t *testing.T) {
|
||||
factory := NewLocalDiskFactory()
|
||||
provider, err := factory.New(context.Background(), map[string]any{"basePath": t.TempDir()})
|
||||
if err != nil {
|
||||
t.Fatalf("Factory.New returned error: %v", err)
|
||||
}
|
||||
if err := provider.TestConnection(context.Background()); err != nil {
|
||||
t.Fatalf("TestConnection returned error: %v", err)
|
||||
}
|
||||
|
||||
// Upload
|
||||
if err := provider.Upload(context.Background(), "daily/backup.txt", strings.NewReader("hello"), 5, nil); err != nil {
|
||||
t.Fatalf("Upload returned error: %v", err)
|
||||
}
|
||||
|
||||
// Download
|
||||
reader, err := provider.Download(context.Background(), "daily/backup.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Download returned error: %v", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
content, _ := io.ReadAll(reader)
|
||||
if string(content) != "hello" {
|
||||
t.Fatalf("expected 'hello', got %q", string(content))
|
||||
}
|
||||
|
||||
// List with prefix
|
||||
items, err := provider.List(context.Background(), "daily")
|
||||
if err != nil {
|
||||
t.Fatalf("List returned error: %v", err)
|
||||
}
|
||||
if len(items) != 1 || items[0].Key != "daily/backup.txt" {
|
||||
t.Fatalf("unexpected list result: %#v", items)
|
||||
}
|
||||
|
||||
// Delete
|
||||
if err := provider.Delete(context.Background(), "daily/backup.txt"); err != nil {
|
||||
t.Fatalf("Delete returned error: %v", err)
|
||||
}
|
||||
|
||||
// List after delete should be empty
|
||||
items, err = provider.List(context.Background(), "daily")
|
||||
if err != nil {
|
||||
t.Fatalf("List after delete returned error: %v", err)
|
||||
}
|
||||
if len(items) != 0 {
|
||||
t.Fatalf("expected empty list after delete, got %d items", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderLocalDiskRequiresBasePath(t *testing.T) {
|
||||
_, err := NewLocalDiskFactory().New(context.Background(), map[string]any{"basePath": ""})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty basePath")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderS3RequiresBucketAndCredentials(t *testing.T) {
|
||||
factory := NewS3Factory()
|
||||
_, err := factory.New(context.Background(), map[string]any{"bucket": "", "accessKeyId": "a", "secretAccessKey": "b"})
|
||||
if err == nil || !strings.Contains(err.Error(), "bucket") {
|
||||
t.Fatalf("expected bucket required error, got %v", err)
|
||||
}
|
||||
_, err = factory.New(context.Background(), map[string]any{"bucket": "demo", "accessKeyId": "", "secretAccessKey": "b"})
|
||||
if err == nil || !strings.Contains(err.Error(), "credentials") {
|
||||
t.Fatalf("expected credentials required error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuoteParam(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"simple", "simple"},
|
||||
{"", ""},
|
||||
{"has,comma", "'has,comma'"},
|
||||
{"has:colon", "'has:colon'"},
|
||||
{"has=equals", "'has=equals'"},
|
||||
{"has'quote", "'has''quote'"},
|
||||
{"a,b:c=d'e", "'a,b:c=d''e'"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := quoteParam(tt.input)
|
||||
if got != tt.expected {
|
||||
t.Errorf("quoteParam(%q) = %q, want %q", tt.input, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildS3Remote(t *testing.T) {
|
||||
remote := buildS3Remote("Alibaba", "keyID", "secret", "https://oss-cn-hangzhou.aliyuncs.com", "cn-hangzhou", "my-bucket", false)
|
||||
if !strings.Contains(remote, "provider=Alibaba") {
|
||||
t.Fatalf("expected provider=Alibaba in remote: %s", remote)
|
||||
}
|
||||
if !strings.Contains(remote, ":my-bucket") {
|
||||
t.Fatalf("expected :my-bucket suffix in remote: %s", remote)
|
||||
}
|
||||
if !strings.HasPrefix(remote, ":s3,") {
|
||||
t.Fatalf("expected :s3, prefix in remote: %s", remote)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathDir(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"BackupX/file/260308/backup.tar.gz", "BackupX/file/260308"},
|
||||
{"backup.tar.gz", ""},
|
||||
{"a/b", "a"},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := pathDir(tt.input)
|
||||
if got != tt.expected {
|
||||
t.Errorf("pathDir(%q) = %q, want %q", tt.input, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
package s3
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/storage"
|
||||
awscore "github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
)
|
||||
|
||||
type client interface {
|
||||
HeadBucket(context.Context, *awss3.HeadBucketInput, ...func(*awss3.Options)) (*awss3.HeadBucketOutput, error)
|
||||
PutObject(context.Context, *awss3.PutObjectInput, ...func(*awss3.Options)) (*awss3.PutObjectOutput, error)
|
||||
GetObject(context.Context, *awss3.GetObjectInput, ...func(*awss3.Options)) (*awss3.GetObjectOutput, error)
|
||||
DeleteObject(context.Context, *awss3.DeleteObjectInput, ...func(*awss3.Options)) (*awss3.DeleteObjectOutput, error)
|
||||
ListObjectsV2(context.Context, *awss3.ListObjectsV2Input, ...func(*awss3.Options)) (*awss3.ListObjectsV2Output, error)
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
client client
|
||||
bucket string
|
||||
}
|
||||
|
||||
type Factory struct {
|
||||
newClient func(cfg storage.S3Config) client
|
||||
}
|
||||
|
||||
func NewFactory() Factory {
|
||||
return Factory{newClient: func(cfg storage.S3Config) client {
|
||||
region := strings.TrimSpace(cfg.Region)
|
||||
if region == "" {
|
||||
region = "us-east-1"
|
||||
}
|
||||
awsConfig := awscore.Config{
|
||||
Region: region,
|
||||
Credentials: credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, ""),
|
||||
}
|
||||
return awss3.NewFromConfig(awsConfig, func(options *awss3.Options) {
|
||||
options.UsePathStyle = cfg.ForcePathStyle
|
||||
if strings.TrimSpace(cfg.Endpoint) != "" {
|
||||
options.BaseEndpoint = awscore.String(strings.TrimRight(cfg.Endpoint, "/"))
|
||||
}
|
||||
})
|
||||
}}
|
||||
}
|
||||
|
||||
func (Factory) Type() storage.ProviderType { return storage.ProviderTypeS3 }
|
||||
func (Factory) SensitiveFields() []string { return []string{"accessKeyId", "secretAccessKey"} }
|
||||
|
||||
func (f Factory) New(_ context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
|
||||
cfg, err := storage.DecodeConfig[storage.S3Config](rawConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(cfg.Bucket) == "" {
|
||||
return nil, fmt.Errorf("s3 bucket is required")
|
||||
}
|
||||
if strings.TrimSpace(cfg.AccessKeyID) == "" || strings.TrimSpace(cfg.SecretAccessKey) == "" {
|
||||
return nil, fmt.Errorf("s3 credentials are required")
|
||||
}
|
||||
newClient := f.newClient
|
||||
if newClient == nil {
|
||||
factory := NewFactory()
|
||||
newClient = factory.newClient
|
||||
}
|
||||
return &Provider{client: newClient(cfg), bucket: cfg.Bucket}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Type() storage.ProviderType { return storage.ProviderTypeS3 }
|
||||
|
||||
func (p *Provider) TestConnection(ctx context.Context) error {
|
||||
_, err := p.client.HeadBucket(ctx, &awss3.HeadBucketInput{Bucket: awscore.String(p.bucket)})
|
||||
if err != nil {
|
||||
return fmt.Errorf("test s3 connection: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) Upload(ctx context.Context, objectKey string, reader io.Reader, _ int64, metadata map[string]string) error {
|
||||
_, err := p.client.PutObject(ctx, &awss3.PutObjectInput{Bucket: awscore.String(p.bucket), Key: awscore.String(objectKey), Body: reader, Metadata: metadata})
|
||||
if err != nil {
|
||||
return fmt.Errorf("upload s3 object: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) Download(ctx context.Context, objectKey string) (io.ReadCloser, error) {
|
||||
result, err := p.client.GetObject(ctx, &awss3.GetObjectInput{Bucket: awscore.String(p.bucket), Key: awscore.String(objectKey)})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download s3 object: %w", err)
|
||||
}
|
||||
return result.Body, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Delete(ctx context.Context, objectKey string) error {
|
||||
_, err := p.client.DeleteObject(ctx, &awss3.DeleteObjectInput{Bucket: awscore.String(p.bucket), Key: awscore.String(objectKey)})
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete s3 object: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) List(ctx context.Context, prefix string) ([]storage.ObjectInfo, error) {
|
||||
result, err := p.client.ListObjectsV2(ctx, &awss3.ListObjectsV2Input{Bucket: awscore.String(p.bucket), Prefix: awscore.String(prefix)})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list s3 objects: %w", err)
|
||||
}
|
||||
items := make([]storage.ObjectInfo, 0, len(result.Contents))
|
||||
for _, object := range result.Contents {
|
||||
updatedAt := time.Time{}
|
||||
if object.LastModified != nil {
|
||||
updatedAt = object.LastModified.UTC()
|
||||
}
|
||||
size := int64(0)
|
||||
if object.Size != nil {
|
||||
size = *object.Size
|
||||
}
|
||||
items = append(items, storage.ObjectInfo{Key: awscore.ToString(object.Key), Size: size, UpdatedAt: updatedAt})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package s3
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/storage"
|
||||
|
||||
awscore "github.com/aws/aws-sdk-go-v2/aws"
|
||||
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
awss3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
)
|
||||
|
||||
type fakeClient struct{ data map[string]string }
|
||||
|
||||
func (c *fakeClient) HeadBucket(context.Context, *awss3.HeadBucketInput, ...func(*awss3.Options)) (*awss3.HeadBucketOutput, error) {
|
||||
return &awss3.HeadBucketOutput{}, nil
|
||||
}
|
||||
|
||||
func (c *fakeClient) PutObject(_ context.Context, input *awss3.PutObjectInput, _ ...func(*awss3.Options)) (*awss3.PutObjectOutput, error) {
|
||||
body, _ := io.ReadAll(input.Body)
|
||||
c.data[awscore.ToString(input.Key)] = string(body)
|
||||
return &awss3.PutObjectOutput{}, nil
|
||||
}
|
||||
|
||||
func (c *fakeClient) GetObject(_ context.Context, input *awss3.GetObjectInput, _ ...func(*awss3.Options)) (*awss3.GetObjectOutput, error) {
|
||||
return &awss3.GetObjectOutput{Body: io.NopCloser(strings.NewReader(c.data[awscore.ToString(input.Key)]))}, nil
|
||||
}
|
||||
|
||||
func (c *fakeClient) DeleteObject(_ context.Context, input *awss3.DeleteObjectInput, _ ...func(*awss3.Options)) (*awss3.DeleteObjectOutput, error) {
|
||||
delete(c.data, awscore.ToString(input.Key))
|
||||
return &awss3.DeleteObjectOutput{}, nil
|
||||
}
|
||||
|
||||
func (c *fakeClient) ListObjectsV2(_ context.Context, _ *awss3.ListObjectsV2Input, _ ...func(*awss3.Options)) (*awss3.ListObjectsV2Output, error) {
|
||||
now := time.Now().UTC()
|
||||
return &awss3.ListObjectsV2Output{Contents: []awss3types.Object{{Key: awscore.String("backup.tar.gz"), Size: awscore.Int64(10), LastModified: &now}}}, nil
|
||||
}
|
||||
|
||||
func TestS3ProviderCRUD(t *testing.T) {
|
||||
factory := Factory{newClient: func(cfg storage.S3Config) client {
|
||||
return &fakeClient{data: make(map[string]string)}
|
||||
}}
|
||||
providerAny, err := factory.New(context.Background(), map[string]any{"bucket": "demo", "accessKeyId": "a", "secretAccessKey": "b"})
|
||||
if err != nil {
|
||||
t.Fatalf("Factory.New returned error: %v", err)
|
||||
}
|
||||
provider := providerAny.(*Provider)
|
||||
if err := provider.TestConnection(context.Background()); err != nil {
|
||||
t.Fatalf("TestConnection returned error: %v", err)
|
||||
}
|
||||
if err := provider.Upload(context.Background(), "backup.tar.gz", bytes.NewBufferString("payload"), 7, nil); err != nil {
|
||||
t.Fatalf("Upload returned error: %v", err)
|
||||
}
|
||||
reader, err := provider.Download(context.Background(), "backup.tar.gz")
|
||||
if err != nil {
|
||||
t.Fatalf("Download returned error: %v", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
content, _ := io.ReadAll(reader)
|
||||
if string(content) != "payload" {
|
||||
t.Fatalf("unexpected content: %s", string(content))
|
||||
}
|
||||
items, err := provider.List(context.Background(), "backup")
|
||||
if err != nil {
|
||||
t.Fatalf("List returned error: %v", err)
|
||||
}
|
||||
if len(items) != 1 || items[0].Key != "backup.tar.gz" {
|
||||
t.Fatalf("unexpected list result: %#v", items)
|
||||
}
|
||||
if err := provider.Delete(context.Background(), "backup.tar.gz"); err != nil {
|
||||
t.Fatalf("Delete returned error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package s3provider
|
||||
|
||||
import "backupx/server/internal/storage/s3"
|
||||
|
||||
type Factory = s3.Factory
|
||||
|
||||
func NewFactory() Factory {
|
||||
return s3.NewFactory()
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
// Package tencent provides a Tencent Cloud COS storage factory that delegates to the S3-compatible engine.
|
||||
// Tencent COS is fully S3-compatible; we auto-assemble the endpoint from region and appId.
|
||||
package tencent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"backupx/server/internal/storage"
|
||||
"backupx/server/internal/storage/s3"
|
||||
)
|
||||
|
||||
// Config is the user-facing configuration for Tencent COS.
|
||||
type Config struct {
|
||||
Region string `json:"region"`
|
||||
Bucket string `json:"bucket"` // format: bucketname-appid
|
||||
SecretID string `json:"accessKeyId"`
|
||||
SecretKey string `json:"secretAccessKey"`
|
||||
Endpoint string `json:"endpoint"` // optional override
|
||||
}
|
||||
|
||||
// Factory creates Tencent COS providers by composing the S3 engine.
|
||||
type Factory struct {
|
||||
s3Factory s3.Factory
|
||||
}
|
||||
|
||||
func NewFactory() Factory {
|
||||
return Factory{s3Factory: s3.NewFactory()}
|
||||
}
|
||||
|
||||
func (Factory) Type() storage.ProviderType { return storage.ProviderTypeTencentCOS }
|
||||
func (Factory) SensitiveFields() []string { return []string{"accessKeyId", "secretAccessKey"} }
|
||||
|
||||
func (f Factory) New(ctx context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
|
||||
cfg, err := storage.DecodeConfig[Config](rawConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
endpoint := strings.TrimSpace(cfg.Endpoint)
|
||||
if endpoint == "" {
|
||||
region := strings.TrimSpace(cfg.Region)
|
||||
if region == "" {
|
||||
return nil, fmt.Errorf("tencent cos region is required")
|
||||
}
|
||||
// Tencent COS S3-compatible endpoint format
|
||||
endpoint = fmt.Sprintf("https://cos.%s.myqcloud.com", region)
|
||||
}
|
||||
|
||||
s3Config := map[string]any{
|
||||
"endpoint": endpoint,
|
||||
"region": cfg.Region,
|
||||
"bucket": cfg.Bucket,
|
||||
"accessKeyId": cfg.SecretID,
|
||||
"secretAccessKey": cfg.SecretKey,
|
||||
"forcePathStyle": false, // COS uses virtual-hosted style
|
||||
}
|
||||
return f.s3Factory.New(ctx, s3Config)
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
package webdav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"backupx/server/internal/storage"
|
||||
gowebdav "github.com/studio-b12/gowebdav"
|
||||
)
|
||||
|
||||
type client interface {
|
||||
ReadDir(path string) ([]os.FileInfo, error)
|
||||
WriteStream(path string, stream io.Reader, perm os.FileMode) error
|
||||
ReadStream(path string) (io.ReadCloser, error)
|
||||
Remove(path string) error
|
||||
MkdirAll(path string, perm os.FileMode) error
|
||||
Stat(path string) (os.FileInfo, error)
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
client client
|
||||
basePath string
|
||||
}
|
||||
|
||||
type Factory struct {
|
||||
newClient func(cfg storage.WebDAVConfig) client
|
||||
}
|
||||
|
||||
func NewFactory() Factory {
|
||||
return Factory{newClient: func(cfg storage.WebDAVConfig) client {
|
||||
return gowebdav.NewClient(strings.TrimRight(cfg.Endpoint, "/"), cfg.Username, cfg.Password)
|
||||
}}
|
||||
}
|
||||
|
||||
func (Factory) Type() storage.ProviderType { return storage.ProviderTypeWebDAV }
|
||||
func (Factory) SensitiveFields() []string { return []string{"username", "password"} }
|
||||
|
||||
func (f Factory) New(_ context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
|
||||
cfg, err := storage.DecodeConfig[storage.WebDAVConfig](rawConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(cfg.Endpoint) == "" {
|
||||
return nil, fmt.Errorf("webdav endpoint is required")
|
||||
}
|
||||
newClient := f.newClient
|
||||
if newClient == nil {
|
||||
factory := NewFactory()
|
||||
newClient = factory.newClient
|
||||
}
|
||||
return &Provider{client: newClient(cfg), basePath: normalizeBasePath(cfg.BasePath)}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Type() storage.ProviderType { return storage.ProviderTypeWebDAV }
|
||||
|
||||
func (p *Provider) TestConnection(_ context.Context) error {
|
||||
if err := p.client.MkdirAll(p.basePath, 0o755); err != nil {
|
||||
return fmt.Errorf("ensure webdav base path: %w", err)
|
||||
}
|
||||
if _, err := p.client.Stat(p.basePath); err != nil {
|
||||
return fmt.Errorf("stat webdav base path: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) Upload(_ context.Context, objectKey string, reader io.Reader, _ int64, _ map[string]string) error {
|
||||
objectPath := p.resolvePath(objectKey)
|
||||
if err := p.client.MkdirAll(path.Dir(objectPath), 0o755); err != nil {
|
||||
return fmt.Errorf("create webdav directories: %w", err)
|
||||
}
|
||||
if err := p.client.WriteStream(objectPath, reader, 0o644); err != nil {
|
||||
return fmt.Errorf("write webdav object: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) Download(_ context.Context, objectKey string) (io.ReadCloser, error) {
|
||||
reader, err := p.client.ReadStream(p.resolvePath(objectKey))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read webdav object: %w", err)
|
||||
}
|
||||
return reader, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Delete(_ context.Context, objectKey string) error {
|
||||
if err := p.client.Remove(p.resolvePath(objectKey)); err != nil {
|
||||
return fmt.Errorf("delete webdav object: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) List(_ context.Context, prefix string) ([]storage.ObjectInfo, error) {
|
||||
entries, err := p.client.ReadDir(p.basePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list webdav directory: %w", err)
|
||||
}
|
||||
items := make([]storage.ObjectInfo, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimPrefix(path.Join(strings.TrimPrefix(p.basePath, "/"), entry.Name()), "/")
|
||||
if prefix != "" && !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
items = append(items, storage.ObjectInfo{Key: key, Size: entry.Size(), UpdatedAt: entry.ModTime().UTC()})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func normalizeBasePath(value string) string {
|
||||
clean := path.Clean("/" + strings.TrimSpace(value))
|
||||
if clean == "." {
|
||||
return "/"
|
||||
}
|
||||
return clean
|
||||
}
|
||||
|
||||
func (p *Provider) resolvePath(objectKey string) string {
|
||||
cleanKey := path.Clean("/" + strings.TrimSpace(objectKey))
|
||||
return path.Clean(path.Join(p.basePath, cleanKey))
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package webdav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/storage"
|
||||
)
|
||||
|
||||
type fakeFileInfo struct {
|
||||
name string
|
||||
size int64
|
||||
mod time.Time
|
||||
dir bool
|
||||
}
|
||||
|
||||
func (f fakeFileInfo) Name() string { return f.name }
|
||||
func (f fakeFileInfo) Size() int64 { return f.size }
|
||||
func (f fakeFileInfo) Mode() os.FileMode { return 0 }
|
||||
func (f fakeFileInfo) ModTime() time.Time { return f.mod }
|
||||
func (f fakeFileInfo) IsDir() bool { return f.dir }
|
||||
func (f fakeFileInfo) Sys() any { return nil }
|
||||
|
||||
type fakeClient struct{ data map[string]string }
|
||||
|
||||
func (c *fakeClient) ReadDir(_ string) ([]os.FileInfo, error) {
|
||||
return []os.FileInfo{fakeFileInfo{name: "backup.tar.gz", size: int64(len(c.data["/storage/backup.tar.gz"])), mod: time.Now().UTC()}}, nil
|
||||
}
|
||||
func (c *fakeClient) WriteStream(path string, stream io.Reader, _ os.FileMode) error {
|
||||
content, _ := io.ReadAll(stream)
|
||||
c.data[path] = string(content)
|
||||
return nil
|
||||
}
|
||||
func (c *fakeClient) ReadStream(path string) (io.ReadCloser, error) {
|
||||
return io.NopCloser(strings.NewReader(c.data[path])), nil
|
||||
}
|
||||
func (c *fakeClient) Remove(path string) error { delete(c.data, path); return nil }
|
||||
func (c *fakeClient) MkdirAll(_ string, _ os.FileMode) error { return nil }
|
||||
func (c *fakeClient) Stat(path string) (os.FileInfo, error) {
|
||||
return fakeFileInfo{name: path, dir: true}, nil
|
||||
}
|
||||
|
||||
func TestWebDAVProviderCRUD(t *testing.T) {
|
||||
factory := Factory{newClient: func(storage.WebDAVConfig) client { return &fakeClient{data: make(map[string]string)} }}
|
||||
providerAny, err := factory.New(context.Background(), map[string]any{"endpoint": "http://dav.example.com", "basePath": "/storage"})
|
||||
if err != nil {
|
||||
t.Fatalf("Factory.New returned error: %v", err)
|
||||
}
|
||||
provider := providerAny.(*Provider)
|
||||
if err := provider.TestConnection(context.Background()); err != nil {
|
||||
t.Fatalf("TestConnection returned error: %v", err)
|
||||
}
|
||||
if err := provider.Upload(context.Background(), "backup.tar.gz", strings.NewReader("payload"), 7, nil); err != nil {
|
||||
t.Fatalf("Upload returned error: %v", err)
|
||||
}
|
||||
reader, err := provider.Download(context.Background(), "backup.tar.gz")
|
||||
if err != nil {
|
||||
t.Fatalf("Download returned error: %v", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
content, _ := io.ReadAll(reader)
|
||||
if string(content) != "payload" {
|
||||
t.Fatalf("unexpected content: %s", string(content))
|
||||
}
|
||||
items, err := provider.List(context.Background(), "storage")
|
||||
if err != nil {
|
||||
t.Fatalf("List returned error: %v", err)
|
||||
}
|
||||
if len(items) != 1 || items[0].Key != "storage/backup.tar.gz" {
|
||||
t.Fatalf("unexpected list result: %#v", items)
|
||||
}
|
||||
if err := provider.Delete(context.Background(), "backup.tar.gz"); err != nil {
|
||||
t.Fatalf("Delete returned error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package webdavprovider
|
||||
|
||||
import "backupx/server/internal/storage/webdav"
|
||||
|
||||
type Factory = webdav.Factory
|
||||
|
||||
func NewFactory() Factory {
|
||||
return webdav.NewFactory()
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Alert, Button, Descriptions, Drawer, Space, Spin, Tag, Typography } from '@arco-design/web-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { deleteBackupRecord, downloadBackupRecord, getBackupRecord, restoreBackupRecord, streamBackupRecordLogs } from '../../services/backup-records'
|
||||
import type { BackupLogEvent, BackupRecordDetail, BackupRecordStatus } from '../../types/backup-records'
|
||||
import type { BackupLogEvent, BackupRecordDetail, BackupRecordStatus, StorageUploadResultItem } from '../../types/backup-records'
|
||||
import { resolveErrorMessage } from '../../utils/error'
|
||||
import { formatBytes, formatDateTime, formatDuration } from '../../utils/format'
|
||||
|
||||
@@ -221,6 +221,19 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
{record.storageUploadResults && record.storageUploadResults.length > 1 && (
|
||||
<div>
|
||||
<Typography.Title heading={6}>存储目标上传结果</Typography.Title>
|
||||
<Descriptions
|
||||
column={1}
|
||||
data={record.storageUploadResults.map((r: StorageUploadResultItem) => ({
|
||||
label: r.storageTargetName,
|
||||
value: r.status === 'success' ? '上传成功' : `上传失败: ${r.error || '未知错误'}`,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Typography.Title heading={6}>执行日志</Typography.Title>
|
||||
<div className="log-viewer">{logText || '暂无日志输出'}</div>
|
||||
|
||||
@@ -29,7 +29,7 @@ export function BackupTaskDetailDrawer({ visible, task, onCancel }: BackupTaskDe
|
||||
border
|
||||
data={[
|
||||
{ label: 'Cron', value: task.cronExpr || '仅手动执行' },
|
||||
{ label: '存储目标', value: task.storageTargetName || task.storageTargetId },
|
||||
{ label: '存储目标', value: task.storageTargetNames?.length > 0 ? task.storageTargetNames.join('、') : (task.storageTargetName || task.storageTargetId) },
|
||||
{ label: '保留天数', value: task.retentionDays },
|
||||
{ label: '最大保留份数', value: task.maxBackups },
|
||||
{ label: '压缩', value: task.compression },
|
||||
@@ -40,7 +40,15 @@ export function BackupTaskDetailDrawer({ visible, task, onCancel }: BackupTaskDe
|
||||
]}
|
||||
/>
|
||||
{task.type === 'file' ? (
|
||||
<Descriptions border column={1} data={[{ label: '源路径', value: task.sourcePath || '-' }, { label: '排除规则', value: task.excludePatterns.join(', ') || '-' }]} />
|
||||
<Descriptions border column={1} data={[
|
||||
{
|
||||
label: '源路径',
|
||||
value: task.sourcePaths?.length > 0
|
||||
? task.sourcePaths.join('\n')
|
||||
: (task.sourcePath || '-'),
|
||||
},
|
||||
{ label: '排除规则', value: task.excludePatterns.join(', ') || '-' },
|
||||
]} />
|
||||
) : null}
|
||||
{task.type === 'sqlite' ? <Descriptions border column={1} data={[{ label: 'SQLite 路径', value: task.dbPath || '-' }]} /> : null}
|
||||
{task.type === 'mysql' || task.type === 'postgresql' ? (
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { Alert, Button, Divider, Drawer, Input, InputNumber, Select, Space, Steps, Switch, Typography } from '@arco-design/web-react'
|
||||
import { Alert, Button, Divider, Drawer, Input, InputNumber, Select, Space, Steps, Switch, Typography, Grid } from '@arco-design/web-react'
|
||||
import { IconDelete, IconPlus } from '@arco-design/web-react/icon'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { CronInput } from '../CronInput'
|
||||
import type { StorageTargetSummary } from '../../types/storage-targets'
|
||||
import type { StorageTargetDetail, StorageTargetPayload, StorageTargetSummary } from '../../types/storage-targets'
|
||||
import type { StorageConnectionTestResult } from '../../types/storage-targets'
|
||||
import type { BackupTaskDetail, BackupTaskPayload, BackupTaskType } from '../../types/backup-tasks'
|
||||
import { DatabasePicker } from '../common/DatabasePicker'
|
||||
import { DirectoryPicker } from '../common/DirectoryPicker'
|
||||
import { StorageTargetFormDrawer } from '../storage-targets/StorageTargetFormDrawer'
|
||||
import {
|
||||
backupCompressionOptions,
|
||||
backupTaskTypeOptions,
|
||||
@@ -17,17 +22,24 @@ interface BackupTaskFormDrawerProps {
|
||||
loading: boolean
|
||||
initialValue: BackupTaskDetail | null
|
||||
storageTargets: StorageTargetSummary[]
|
||||
localNodeId?: number
|
||||
onCancel: () => void
|
||||
onSubmit: (value: BackupTaskPayload, taskId?: number) => Promise<void>
|
||||
onCreateStorageTarget?: (value: StorageTargetPayload) => Promise<StorageTargetDetail>
|
||||
onTestStorageTarget?: (value: StorageTargetPayload, targetId?: number) => Promise<StorageConnectionTestResult>
|
||||
onGoogleDriveAuth?: (value: StorageTargetPayload, targetId?: number) => Promise<void>
|
||||
onStorageTargetCreated?: () => Promise<void>
|
||||
}
|
||||
|
||||
function createEmptyDraft(storageTargetId?: number): BackupTaskPayload {
|
||||
function createEmptyDraft(storageTargets?: StorageTargetSummary[]): BackupTaskPayload {
|
||||
const defaultIds = storageTargets && storageTargets.length > 0 ? [storageTargets[0].id] : []
|
||||
return {
|
||||
name: '',
|
||||
type: 'file',
|
||||
enabled: true,
|
||||
cronExpr: '',
|
||||
sourcePath: '',
|
||||
sourcePaths: [''],
|
||||
excludePatterns: [],
|
||||
dbHost: '',
|
||||
dbPort: 0,
|
||||
@@ -35,7 +47,8 @@ function createEmptyDraft(storageTargetId?: number): BackupTaskPayload {
|
||||
dbPassword: '',
|
||||
dbName: '',
|
||||
dbPath: '',
|
||||
storageTargetId: storageTargetId ?? 0,
|
||||
storageTargetId: defaultIds[0] ?? 0,
|
||||
storageTargetIds: defaultIds,
|
||||
nodeId: 0,
|
||||
tags: '',
|
||||
retentionDays: 30,
|
||||
@@ -45,11 +58,14 @@ function createEmptyDraft(storageTargetId?: number): BackupTaskPayload {
|
||||
}
|
||||
}
|
||||
|
||||
export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTargets, onCancel, onSubmit }: BackupTaskFormDrawerProps) {
|
||||
export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTargets, localNodeId, onCancel, onSubmit, onCreateStorageTarget, onTestStorageTarget, onGoogleDriveAuth, onStorageTargetCreated }: BackupTaskFormDrawerProps) {
|
||||
const [draft, setDraft] = useState<BackupTaskPayload>(createEmptyDraft())
|
||||
const [excludePatternsText, setExcludePatternsText] = useState('')
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const [error, setError] = useState('')
|
||||
const [quickCreateVisible, setQuickCreateVisible] = useState(false)
|
||||
const [quickCreateLoading, setQuickCreateLoading] = useState(false)
|
||||
const [quickCreateTesting, setQuickCreateTesting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
@@ -57,7 +73,7 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
|
||||
}
|
||||
|
||||
if (!initialValue) {
|
||||
const nextDraft = createEmptyDraft(storageTargets[0]?.id)
|
||||
const nextDraft = createEmptyDraft(storageTargets)
|
||||
setDraft(nextDraft)
|
||||
setExcludePatternsText('')
|
||||
setCurrentStep(0)
|
||||
@@ -65,12 +81,24 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
|
||||
return
|
||||
}
|
||||
|
||||
const editTargetIds = initialValue.storageTargetIds?.length > 0
|
||||
? initialValue.storageTargetIds
|
||||
: initialValue.storageTargetId > 0
|
||||
? [initialValue.storageTargetId]
|
||||
: []
|
||||
// 编辑时:sourcePaths 优先,为空回退 sourcePath
|
||||
const editSourcePaths = initialValue.sourcePaths?.length > 0
|
||||
? initialValue.sourcePaths
|
||||
: initialValue.sourcePath
|
||||
? [initialValue.sourcePath]
|
||||
: ['']
|
||||
setDraft({
|
||||
name: initialValue.name,
|
||||
type: initialValue.type,
|
||||
enabled: initialValue.enabled,
|
||||
cronExpr: initialValue.cronExpr,
|
||||
sourcePath: initialValue.sourcePath,
|
||||
sourcePaths: editSourcePaths,
|
||||
excludePatterns: initialValue.excludePatterns,
|
||||
dbHost: initialValue.dbHost,
|
||||
dbPort: initialValue.dbPort,
|
||||
@@ -78,7 +106,8 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
|
||||
dbPassword: '',
|
||||
dbName: initialValue.dbName,
|
||||
dbPath: initialValue.dbPath,
|
||||
storageTargetId: initialValue.storageTargetId,
|
||||
storageTargetId: editTargetIds[0] ?? 0,
|
||||
storageTargetIds: editTargetIds,
|
||||
nodeId: (initialValue as any).nodeId ?? 0,
|
||||
tags: (initialValue as any).tags ?? '',
|
||||
retentionDays: initialValue.retentionDays,
|
||||
@@ -92,7 +121,17 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
|
||||
}, [initialValue, storageTargets, visible])
|
||||
|
||||
const storageTargetOptions = useMemo(
|
||||
() => storageTargets.map((item) => ({ label: item.name, value: item.id, disabled: !item.enabled })),
|
||||
() => {
|
||||
const sorted = [...storageTargets].sort((a, b) => {
|
||||
if (a.starred !== b.starred) return a.starred ? -1 : 1
|
||||
return 0
|
||||
})
|
||||
return sorted.map((item) => ({
|
||||
label: item.starred ? `★ ${item.name}` : item.name,
|
||||
value: item.id,
|
||||
disabled: !item.enabled,
|
||||
}))
|
||||
},
|
||||
[storageTargets],
|
||||
)
|
||||
|
||||
@@ -105,6 +144,7 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
|
||||
...current,
|
||||
type: value,
|
||||
sourcePath: value === 'file' ? current.sourcePath : '',
|
||||
sourcePaths: value === 'file' ? current.sourcePaths : [''],
|
||||
excludePatterns: value === 'file' ? current.excludePatterns : [],
|
||||
dbHost: value === 'mysql' || value === 'postgresql' || value === 'saphana' ? current.dbHost : '',
|
||||
dbPort: value === 'mysql' || value === 'postgresql' || value === 'saphana' ? current.dbPort || getDefaultPort(value) : 0,
|
||||
@@ -122,8 +162,8 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
|
||||
if (!value.name.trim()) {
|
||||
return '请输入任务名称'
|
||||
}
|
||||
if (!value.storageTargetId) {
|
||||
return '请选择存储目标'
|
||||
if (!value.storageTargetIds || value.storageTargetIds.length === 0) {
|
||||
return '请选择至少一个存储目标'
|
||||
}
|
||||
if (value.cronExpr.trim() && value.cronExpr.trim().split(/\s+/).length < 5) {
|
||||
return 'Cron 表达式至少需要 5 段'
|
||||
@@ -134,8 +174,11 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
|
||||
if (value.maxBackups < 0) {
|
||||
return '最大保留份数不能小于 0'
|
||||
}
|
||||
if (isFileBackupTask(value.type) && !value.sourcePath.trim()) {
|
||||
return '请输入源路径'
|
||||
if (isFileBackupTask(value.type)) {
|
||||
const validPaths = (value.sourcePaths ?? []).filter((p) => p.trim())
|
||||
if (validPaths.length === 0 && !value.sourcePath.trim()) {
|
||||
return '请输入至少一个源路径'
|
||||
}
|
||||
}
|
||||
if (isSQLiteBackupTask(value.type) && !value.dbPath.trim()) {
|
||||
return '请输入 SQLite 数据库路径'
|
||||
@@ -161,8 +204,11 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const validSourcePaths = (draft.sourcePaths ?? []).filter((p) => p.trim())
|
||||
const nextValue: BackupTaskPayload = {
|
||||
...draft,
|
||||
sourcePaths: validSourcePaths,
|
||||
sourcePath: validSourcePaths[0] ?? draft.sourcePath,
|
||||
excludePatterns: excludePatternsText
|
||||
.split('\n')
|
||||
.map((item) => item.trim())
|
||||
@@ -203,14 +249,65 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
|
||||
)
|
||||
}
|
||||
|
||||
function updateSourcePath(index: number, value: string) {
|
||||
setDraft((current) => {
|
||||
const next = [...(current.sourcePaths ?? [''])]
|
||||
next[index] = value
|
||||
return { ...current, sourcePaths: next, sourcePath: next[0] ?? '' }
|
||||
})
|
||||
}
|
||||
|
||||
function addSourcePath() {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
sourcePaths: [...(current.sourcePaths ?? ['']), ''],
|
||||
}))
|
||||
}
|
||||
|
||||
function removeSourcePath(index: number) {
|
||||
setDraft((current) => {
|
||||
const next = [...(current.sourcePaths ?? [''])]
|
||||
next.splice(index, 1)
|
||||
if (next.length === 0) next.push('')
|
||||
return { ...current, sourcePaths: next, sourcePath: next[0] ?? '' }
|
||||
})
|
||||
}
|
||||
|
||||
function renderSourceStep() {
|
||||
const paths = draft.sourcePaths?.length > 0 ? draft.sourcePaths : ['']
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
{isFileBackupTask(draft.type) ? (
|
||||
<>
|
||||
<div>
|
||||
<Typography.Text>源路径</Typography.Text>
|
||||
<Input value={draft.sourcePath} placeholder="例如:/var/www/html" onChange={(value) => updateDraft({ sourcePath: value })} />
|
||||
<Space direction="vertical" size="medium" style={{ width: '100%' }}>
|
||||
{paths.map((p, index) => (
|
||||
<Grid.Row key={index} gutter={8} align="center">
|
||||
<Grid.Col flex="auto">
|
||||
<DirectoryPicker
|
||||
value={p}
|
||||
placeholder={`源路径 ${index + 1},例如:/var/www/html`}
|
||||
mode="directory"
|
||||
nodeId={localNodeId}
|
||||
onChange={(value) => updateSourcePath(index, value)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col flex="none">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<IconDelete />}
|
||||
status="danger"
|
||||
disabled={paths.length <= 1}
|
||||
onClick={() => removeSourcePath(index)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid.Row>
|
||||
))}
|
||||
<Button type="dashed" long icon={<IconPlus />} onClick={addSourcePath}>
|
||||
添加源路径
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text>排除规则</Typography.Text>
|
||||
@@ -227,7 +324,13 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
|
||||
{isSQLiteBackupTask(draft.type) ? (
|
||||
<div>
|
||||
<Typography.Text>SQLite 数据库文件</Typography.Text>
|
||||
<Input value={draft.dbPath} placeholder="例如:/data/app.db" onChange={(value) => updateDraft({ dbPath: value })} />
|
||||
<DirectoryPicker
|
||||
value={draft.dbPath}
|
||||
placeholder="例如:/data/app.db"
|
||||
mode="file"
|
||||
nodeId={localNodeId}
|
||||
onChange={(value) => updateDraft({ dbPath: value })}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -251,7 +354,19 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text>数据库名称</Typography.Text>
|
||||
<Input value={draft.dbName} placeholder="例如:app_prod" onChange={(value) => updateDraft({ dbName: value })} />
|
||||
{(draft.type === 'mysql' || draft.type === 'postgresql') ? (
|
||||
<DatabasePicker
|
||||
dbType={draft.type}
|
||||
dbHost={draft.dbHost}
|
||||
dbPort={draft.dbPort}
|
||||
dbUser={draft.dbUser}
|
||||
dbPassword={draft.dbPassword}
|
||||
value={draft.dbName}
|
||||
onChange={(value) => updateDraft({ dbName: value })}
|
||||
/>
|
||||
) : (
|
||||
<Input value={draft.dbName} placeholder="例如:app_prod" onChange={(value) => updateDraft({ dbName: value })} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
@@ -259,12 +374,53 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
|
||||
)
|
||||
}
|
||||
|
||||
async function handleQuickCreateSubmit(value: StorageTargetPayload) {
|
||||
if (!onCreateStorageTarget) return
|
||||
setQuickCreateLoading(true)
|
||||
try {
|
||||
const created = await onCreateStorageTarget(value)
|
||||
setQuickCreateVisible(false)
|
||||
if (onStorageTargetCreated) {
|
||||
await onStorageTargetCreated()
|
||||
}
|
||||
const currentIds = draft.storageTargetIds ?? []
|
||||
const nextIds = [...currentIds, created.id]
|
||||
updateDraft({ storageTargetIds: nextIds, storageTargetId: nextIds[0] ?? 0 })
|
||||
} finally {
|
||||
setQuickCreateLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleQuickCreateTest(value: StorageTargetPayload, targetId?: number): Promise<StorageConnectionTestResult> {
|
||||
if (!onTestStorageTarget) return { success: false, message: '测试不可用' }
|
||||
setQuickCreateTesting(true)
|
||||
try {
|
||||
return await onTestStorageTarget(value, targetId)
|
||||
} finally {
|
||||
setQuickCreateTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
function renderPolicyStep() {
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Typography.Text>存储目标</Typography.Text>
|
||||
<Select value={draft.storageTargetId || undefined} placeholder="请选择存储目标" options={storageTargetOptions} onChange={(value) => updateDraft({ storageTargetId: Number(value) })} />
|
||||
<Space style={{ width: '100%' }} align="start">
|
||||
<Select
|
||||
style={{ flex: 1 }}
|
||||
mode="multiple"
|
||||
value={draft.storageTargetIds?.length > 0 ? draft.storageTargetIds : undefined}
|
||||
placeholder="请选择存储目标(可多选)"
|
||||
options={storageTargetOptions}
|
||||
onChange={(values: number[]) => updateDraft({ storageTargetIds: values, storageTargetId: values[0] ?? 0 })}
|
||||
/>
|
||||
{onCreateStorageTarget && (
|
||||
<Button type="outline" size="small" onClick={() => setQuickCreateVisible(true)}>
|
||||
+ 快速新建
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text>压缩策略</Typography.Text>
|
||||
@@ -320,6 +476,19 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
|
||||
)}
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
{onCreateStorageTarget && (
|
||||
<StorageTargetFormDrawer
|
||||
visible={quickCreateVisible}
|
||||
loading={quickCreateLoading}
|
||||
testing={quickCreateTesting}
|
||||
initialValue={null}
|
||||
onCancel={() => setQuickCreateVisible(false)}
|
||||
onSubmit={handleQuickCreateSubmit}
|
||||
onTest={handleQuickCreateTest}
|
||||
onGoogleDriveAuth={onGoogleDriveAuth ?? (async () => {})}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
120
web/src/components/common/DatabasePicker.tsx
Normal file
120
web/src/components/common/DatabasePicker.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Button, Checkbox, Input, Message, Space, Spin, Typography } from '@arco-design/web-react'
|
||||
import { useState } from 'react'
|
||||
import { discoverDatabases } from '../../services/database'
|
||||
|
||||
interface DatabasePickerProps {
|
||||
dbType: 'mysql' | 'postgresql'
|
||||
dbHost: string
|
||||
dbPort: number
|
||||
dbUser: string
|
||||
dbPassword: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export function DatabasePicker({ dbType, dbHost, dbPort, dbUser, dbPassword, value, onChange }: DatabasePickerProps) {
|
||||
const [databases, setDatabases] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [discovered, setDiscovered] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const selectedDbs = value
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const canDiscover = dbHost.trim() && dbPort > 0 && dbUser.trim() && dbPassword.trim()
|
||||
|
||||
async function handleDiscover() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const result = await discoverDatabases({
|
||||
type: dbType,
|
||||
host: dbHost.trim(),
|
||||
port: dbPort,
|
||||
user: dbUser.trim(),
|
||||
password: dbPassword.trim(),
|
||||
})
|
||||
setDatabases(result)
|
||||
setDiscovered(true)
|
||||
if (result.length === 0) {
|
||||
setError('未发现用户数据库')
|
||||
}
|
||||
} catch (discoverError: any) {
|
||||
const msg = discoverError?.response?.data?.message ?? discoverError?.message ?? '发现数据库失败'
|
||||
setError(msg)
|
||||
Message.error(msg)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggle(db: string, checked: boolean) {
|
||||
let next: string[]
|
||||
if (checked) {
|
||||
next = [...selectedDbs, db]
|
||||
} else {
|
||||
next = selectedDbs.filter((d) => d !== db)
|
||||
}
|
||||
onChange(next.join(','))
|
||||
}
|
||||
|
||||
function handleSelectAll() {
|
||||
onChange(databases.join(','))
|
||||
}
|
||||
|
||||
function handleDeselectAll() {
|
||||
onChange('')
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="medium" style={{ width: '100%' }}>
|
||||
<Space style={{ width: '100%' }}>
|
||||
<Input
|
||||
style={{ flex: 1 }}
|
||||
value={value}
|
||||
placeholder="数据库名称(多个以逗号分隔)"
|
||||
onChange={onChange}
|
||||
/>
|
||||
<Button
|
||||
type="outline"
|
||||
size="small"
|
||||
loading={loading}
|
||||
disabled={!canDiscover}
|
||||
onClick={handleDiscover}
|
||||
>
|
||||
发现数据库
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
{error && <Typography.Text type="error">{error}</Typography.Text>}
|
||||
|
||||
{loading && <Spin size={16} />}
|
||||
|
||||
{discovered && databases.length > 0 && (
|
||||
<div style={{ border: '1px solid var(--color-border-2)', borderRadius: 4, padding: '8px 12px', maxHeight: 200, overflow: 'auto' }}>
|
||||
<Space size="mini" style={{ marginBottom: 8 }}>
|
||||
<Button type="text" size="mini" onClick={handleSelectAll}>
|
||||
全选
|
||||
</Button>
|
||||
<Button type="text" size="mini" onClick={handleDeselectAll}>
|
||||
清空
|
||||
</Button>
|
||||
</Space>
|
||||
<Space direction="vertical" size={4}>
|
||||
{databases.map((db) => (
|
||||
<Checkbox
|
||||
key={db}
|
||||
checked={selectedDbs.includes(db)}
|
||||
onChange={(checked) => handleToggle(db, checked)}
|
||||
>
|
||||
{db}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
155
web/src/components/common/DirectoryPicker.tsx
Normal file
155
web/src/components/common/DirectoryPicker.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { Button, Input, Message, Modal, Space, Spin, Tree, Typography } from '@arco-design/web-react'
|
||||
import { IconFolder, IconFile } from '@arco-design/web-react/icon'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { listNodeDirectory } from '../../services/nodes'
|
||||
import type { DirEntry } from '../../types/nodes'
|
||||
|
||||
interface DirectoryPickerProps {
|
||||
value: string
|
||||
onChange: (path: string) => void
|
||||
placeholder?: string
|
||||
mode?: 'directory' | 'file'
|
||||
nodeId?: number
|
||||
}
|
||||
|
||||
interface TreeNodeData {
|
||||
key: string
|
||||
title: string
|
||||
icon?: React.ReactNode
|
||||
isLeaf: boolean
|
||||
children?: TreeNodeData[]
|
||||
loaded?: boolean
|
||||
}
|
||||
|
||||
function entriesToTreeNodes(entries: DirEntry[], mode: 'directory' | 'file'): TreeNodeData[] {
|
||||
return entries
|
||||
.filter((entry) => mode === 'file' || entry.isDir)
|
||||
.map((entry) => ({
|
||||
key: entry.path,
|
||||
title: entry.name,
|
||||
icon: entry.isDir ? <IconFolder /> : <IconFile />,
|
||||
isLeaf: !entry.isDir,
|
||||
}))
|
||||
}
|
||||
|
||||
export function DirectoryPicker({ value, onChange, placeholder, mode = 'directory', nodeId }: DirectoryPickerProps) {
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const [treeData, setTreeData] = useState<TreeNodeData[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selectedPath, setSelectedPath] = useState('')
|
||||
|
||||
const loadDirectory = useCallback(
|
||||
async (path: string): Promise<TreeNodeData[]> => {
|
||||
if (nodeId === undefined) return []
|
||||
try {
|
||||
const entries = await listNodeDirectory(nodeId, path)
|
||||
return entriesToTreeNodes(entries, mode)
|
||||
} catch {
|
||||
Message.error(`加载目录失败: ${path}`)
|
||||
return []
|
||||
}
|
||||
},
|
||||
[nodeId, mode],
|
||||
)
|
||||
|
||||
async function handleOpen() {
|
||||
setModalVisible(true)
|
||||
setSelectedPath(value || '')
|
||||
setLoading(true)
|
||||
try {
|
||||
const rootNodes = await loadDirectory('/')
|
||||
setTreeData(rootNodes)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ArcoDesign Tree loadMore: node.props.dataRef 指向 treeData 中的原始对象
|
||||
async function handleLoadMore(treeNode: any): Promise<void> {
|
||||
const nodeKey = treeNode.props.dataRef?.key ?? treeNode.props._key
|
||||
if (!nodeKey) return
|
||||
|
||||
const children = await loadDirectory(nodeKey)
|
||||
|
||||
setTreeData((prev) => {
|
||||
function insertChildren(nodes: TreeNodeData[]): TreeNodeData[] {
|
||||
return nodes.map((n) => {
|
||||
if (n.key === nodeKey) {
|
||||
return { ...n, children, loaded: true }
|
||||
}
|
||||
if (n.children && n.children.length > 0) {
|
||||
return { ...n, children: insertChildren(n.children) }
|
||||
}
|
||||
return n
|
||||
})
|
||||
}
|
||||
return insertChildren(prev)
|
||||
})
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (selectedPath) {
|
||||
onChange(selectedPath)
|
||||
}
|
||||
setModalVisible(false)
|
||||
}
|
||||
|
||||
// 没有 nodeId 时退化为普通输入框
|
||||
if (nodeId === undefined) {
|
||||
return <Input value={value} placeholder={placeholder} onChange={onChange} />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Space style={{ width: '100%' }}>
|
||||
<Input style={{ flex: 1 }} value={value} placeholder={placeholder} onChange={onChange} />
|
||||
<Button type="outline" size="small" onClick={handleOpen}>
|
||||
浏览
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Modal
|
||||
title={mode === 'directory' ? '选择目录' : '选择文件'}
|
||||
visible={modalVisible}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
onOk={handleConfirm}
|
||||
okText="选择"
|
||||
cancelText="取消"
|
||||
style={{ width: 560 }}
|
||||
okButtonProps={{ disabled: !selectedPath }}
|
||||
unmountOnExit
|
||||
>
|
||||
{selectedPath && (
|
||||
<div style={{ padding: '8px 12px', marginBottom: 12, background: 'var(--color-fill-2)', borderRadius: 4 }}>
|
||||
<Typography.Text copyable style={{ fontSize: 13 }}>
|
||||
{selectedPath}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
{loading ? (
|
||||
<Spin style={{ display: 'block', textAlign: 'center', padding: 40 }} />
|
||||
) : treeData.length === 0 ? (
|
||||
<Typography.Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: 40 }}>
|
||||
目录为空
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<div style={{ maxHeight: 420, overflow: 'auto', border: '1px solid var(--color-border)', borderRadius: 4, padding: '4px 0' }}>
|
||||
<Tree
|
||||
blockNode
|
||||
showLine
|
||||
treeData={treeData as any}
|
||||
selectedKeys={selectedPath ? [selectedPath] : []}
|
||||
onSelect={(keys) => {
|
||||
if (keys.length > 0) {
|
||||
setSelectedPath(keys[0] as string)
|
||||
}
|
||||
}}
|
||||
loadMore={handleLoadMore}
|
||||
icons={{ switcherIcon: <IconFolder style={{ fontSize: 14 }} /> }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
IconDown,
|
||||
IconCloud,
|
||||
IconDesktop,
|
||||
IconList,
|
||||
} from '@arco-design/web-react/icon'
|
||||
import { useState } from 'react'
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
@@ -37,6 +38,9 @@ function resolveSelectedKey(pathname: string) {
|
||||
if (pathname.startsWith('/settings/notifications')) {
|
||||
return '/settings/notifications'
|
||||
}
|
||||
if (pathname.startsWith('/audit')) {
|
||||
return '/audit'
|
||||
}
|
||||
if (pathname.startsWith('/nodes')) {
|
||||
return '/nodes'
|
||||
}
|
||||
@@ -53,6 +57,7 @@ const menuItems = [
|
||||
{ key: '/storage-targets', label: '存储目标', icon: <IconStorage /> },
|
||||
{ key: '/nodes', label: '节点管理', icon: <IconDesktop /> },
|
||||
{ key: '/settings/notifications', label: '通知配置', icon: <IconNotification /> },
|
||||
{ key: '/audit', label: '审计日志', icon: <IconList /> },
|
||||
{ key: '/settings', label: '系统设置', icon: <IconSettings /> },
|
||||
]
|
||||
|
||||
|
||||
154
web/src/pages/audit/AuditLogsPage.tsx
Normal file
154
web/src/pages/audit/AuditLogsPage.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { PageHeader, Select, Space, Table, Tag, Typography } from '@arco-design/web-react'
|
||||
import type { ColumnProps } from '@arco-design/web-react/es/Table'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { listAuditLogs } from '../../services/audit'
|
||||
import type { AuditLog } from '../../types/audit'
|
||||
import { formatDateTime } from '../../utils/format'
|
||||
import { resolveErrorMessage } from '../../utils/error'
|
||||
|
||||
const categoryOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '认证', value: 'auth' },
|
||||
{ label: '存储目标', value: 'storage_target' },
|
||||
{ label: '备份任务', value: 'backup_task' },
|
||||
{ label: '备份记录', value: 'backup_record' },
|
||||
{ label: '系统设置', value: 'settings' },
|
||||
]
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
auth: '认证',
|
||||
storage_target: '存储目标',
|
||||
backup_task: '备份任务',
|
||||
backup_record: '备份记录',
|
||||
settings: '系统设置',
|
||||
}
|
||||
|
||||
const actionLabels: Record<string, string> = {
|
||||
login_success: '登录成功',
|
||||
login_failed: '登录失败',
|
||||
setup: '系统初始化',
|
||||
change_password: '修改密码',
|
||||
create: '创建',
|
||||
update: '更新',
|
||||
delete: '删除',
|
||||
enable: '启用',
|
||||
disable: '停用',
|
||||
run: '执行',
|
||||
restore: '恢复',
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const columns: ColumnProps<AuditLog>[] = [
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 180,
|
||||
render: (_, record) => formatDateTime(record.createdAt),
|
||||
},
|
||||
{
|
||||
title: '分类',
|
||||
dataIndex: 'category',
|
||||
width: 100,
|
||||
render: (_, record) => (
|
||||
<Tag bordered>{categoryLabels[record.category] ?? record.category}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
width: 100,
|
||||
render: (_, record) => actionLabels[record.action] ?? record.action,
|
||||
},
|
||||
{
|
||||
title: '用户',
|
||||
dataIndex: 'username',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '目标',
|
||||
dataIndex: 'targetName',
|
||||
width: 160,
|
||||
render: (_, record) => record.targetName || record.targetId || '-',
|
||||
},
|
||||
{
|
||||
title: '详情',
|
||||
dataIndex: 'detail',
|
||||
render: (_, record) => record.detail || '-',
|
||||
},
|
||||
{
|
||||
title: 'IP',
|
||||
dataIndex: 'clientIp',
|
||||
width: 130,
|
||||
render: (_, record) => record.clientIp || '-',
|
||||
},
|
||||
]
|
||||
|
||||
export function AuditLogsPage() {
|
||||
const [logs, setLogs] = useState<AuditLog[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [category, setCategory] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const fetchData = useCallback(async (cat: string, currentPage: number) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await listAuditLogs({
|
||||
category: cat || undefined,
|
||||
limit: PAGE_SIZE,
|
||||
offset: (currentPage - 1) * PAGE_SIZE,
|
||||
})
|
||||
setLogs(result.items ?? [])
|
||||
setTotal(result.total ?? 0)
|
||||
setError('')
|
||||
} catch (loadError) {
|
||||
setError(resolveErrorMessage(loadError, '加载审计日志失败'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void fetchData(category, page)
|
||||
}, [category, page, fetchData])
|
||||
|
||||
function handleCategoryChange(value: string) {
|
||||
setCategory(value)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<PageHeader
|
||||
style={{ paddingBottom: 0 }}
|
||||
title="审计日志"
|
||||
subTitle="记录系统中所有关键操作,保障数据操作链可溯源"
|
||||
/>
|
||||
{error ? <Typography.Text type="error">{error}</Typography.Text> : null}
|
||||
<Space>
|
||||
<Select
|
||||
style={{ width: 160 }}
|
||||
value={category}
|
||||
options={categoryOptions}
|
||||
onChange={handleCategoryChange}
|
||||
placeholder="筛选分类"
|
||||
/>
|
||||
</Space>
|
||||
<Table
|
||||
columns={columns}
|
||||
data={logs}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
total,
|
||||
current: page,
|
||||
pageSize: PAGE_SIZE,
|
||||
onChange: setPage,
|
||||
showTotal: true,
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
@@ -98,6 +98,11 @@ export function BackupRecordsPage() {
|
||||
<Space direction="vertical" size={2}>
|
||||
<Typography.Text>{record.fileName || '-'}</Typography.Text>
|
||||
<Typography.Text type="secondary">{formatBytes(record.fileSize)}</Typography.Text>
|
||||
{record.checksum && (
|
||||
<Typography.Text type="secondary" copyable style={{ fontSize: 11 }}>
|
||||
SHA-256: {record.checksum.substring(0, 16)}...
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -5,9 +5,10 @@ import { BackupTaskDetailDrawer } from '../../components/backup-tasks/BackupTask
|
||||
import { BackupTaskFormDrawer } from '../../components/backup-tasks/BackupTaskFormDrawer'
|
||||
import { getBackupTaskStatusColor, getBackupTaskStatusLabel, getBackupTaskTypeLabel } from '../../components/backup-tasks/field-config'
|
||||
import { createBackupTask, deleteBackupTask, getBackupTask, listBackupTasks, runBackupTask, toggleBackupTask, updateBackupTask } from '../../services/backup-tasks'
|
||||
import { listStorageTargets } from '../../services/storage-targets'
|
||||
import { listNodes } from '../../services/nodes'
|
||||
import { createStorageTarget, listStorageTargets, startGoogleDriveAuth, testStorageTarget } from '../../services/storage-targets'
|
||||
import type { BackupTaskDetail, BackupTaskPayload, BackupTaskSummary } from '../../types/backup-tasks'
|
||||
import type { StorageTargetSummary } from '../../types/storage-targets'
|
||||
import type { StorageTargetPayload, StorageTargetSummary } from '../../types/storage-targets'
|
||||
import { resolveErrorMessage } from '../../utils/error'
|
||||
import { formatDateTime } from '../../utils/format'
|
||||
|
||||
@@ -22,15 +23,20 @@ export function BackupTasksPage() {
|
||||
const [editingTask, setEditingTask] = useState<BackupTaskDetail | null>(null)
|
||||
const [detailTask, setDetailTask] = useState<BackupTaskDetail | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [localNodeId, setLocalNodeId] = useState<number | undefined>(undefined)
|
||||
|
||||
const enabledStorageTargets = useMemo(() => storageTargets.filter((item) => item.enabled), [storageTargets])
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [taskList, targetList] = await Promise.all([listBackupTasks(), listStorageTargets()])
|
||||
const [taskList, targetList, nodeList] = await Promise.all([listBackupTasks(), listStorageTargets(), listNodes()])
|
||||
setTasks(taskList)
|
||||
setStorageTargets(targetList)
|
||||
const localNode = nodeList.find((n) => n.isLocal)
|
||||
if (localNode) {
|
||||
setLocalNodeId(localNode.id)
|
||||
}
|
||||
setError('')
|
||||
} catch (loadError) {
|
||||
setError(resolveErrorMessage(loadError, '加载备份任务失败'))
|
||||
@@ -123,6 +129,28 @@ export function BackupTasksPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateStorageTarget(value: StorageTargetPayload) {
|
||||
const result = await createStorageTarget(value)
|
||||
Message.success('存储目标已创建')
|
||||
return result
|
||||
}
|
||||
|
||||
async function handleTestStorageTarget(value: StorageTargetPayload) {
|
||||
const result = await testStorageTarget(value)
|
||||
Message.success(result.message)
|
||||
return result
|
||||
}
|
||||
|
||||
async function handleGoogleDriveAuth(value: StorageTargetPayload, targetId?: number) {
|
||||
const result = await startGoogleDriveAuth(value, targetId)
|
||||
window.open(result.authUrl, '_blank')
|
||||
}
|
||||
|
||||
async function reloadStorageTargets() {
|
||||
const targetList = await listStorageTargets()
|
||||
setStorageTargets(targetList)
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '任务名称',
|
||||
@@ -146,8 +174,18 @@ export function BackupTasksPage() {
|
||||
},
|
||||
{
|
||||
title: '存储目标',
|
||||
dataIndex: 'storageTargetName',
|
||||
render: (value: string) => value || '-',
|
||||
dataIndex: 'storageTargetNames',
|
||||
render: (_: unknown, record: BackupTaskSummary) => {
|
||||
const names = record.storageTargetNames?.length > 0 ? record.storageTargetNames : record.storageTargetName ? [record.storageTargetName] : []
|
||||
if (names.length === 0) return '-'
|
||||
return (
|
||||
<Space size={4} wrap>
|
||||
{names.map((name, i) => (
|
||||
<Tag key={i} color="arcoblue" bordered>{name}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '策略',
|
||||
@@ -228,11 +266,16 @@ export function BackupTasksPage() {
|
||||
loading={submitting}
|
||||
initialValue={editingTask}
|
||||
storageTargets={enabledStorageTargets}
|
||||
localNodeId={localNodeId}
|
||||
onCancel={() => {
|
||||
setDrawerVisible(false)
|
||||
setEditingTask(null)
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
onCreateStorageTarget={handleCreateStorageTarget}
|
||||
onTestStorageTarget={handleTestStorageTarget}
|
||||
onGoogleDriveAuth={handleGoogleDriveAuth}
|
||||
onStorageTargetCreated={reloadStorageTargets}
|
||||
/>
|
||||
|
||||
<BackupTaskDetailDrawer
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
startGoogleDriveAuth,
|
||||
testSavedStorageTarget,
|
||||
testStorageTarget,
|
||||
toggleStorageTargetStar,
|
||||
updateStorageTarget,
|
||||
} from '../../services/storage-targets'
|
||||
import type { StorageConnectionTestResult, StorageTargetDetail, StorageTargetPayload, StorageTargetSummary } from '../../types/storage-targets'
|
||||
@@ -148,6 +149,15 @@ export function StorageTargetsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleStar(id: number) {
|
||||
try {
|
||||
await toggleStorageTargetStar(id)
|
||||
await loadTargets()
|
||||
} catch (starError) {
|
||||
Message.error(resolveErrorMessage(starError))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGoogleDriveAuth(value: StorageTargetPayload, targetId?: number) {
|
||||
try {
|
||||
const result = await startGoogleDriveAuth(value, targetId)
|
||||
@@ -194,7 +204,7 @@ export function StorageTargetsPage() {
|
||||
<Space size="large" align="start" style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Typography.Title heading={6} style={{ marginBottom: 4 }}>
|
||||
{target.name}
|
||||
{target.starred ? '★ ' : ''}{target.name}
|
||||
</Typography.Title>
|
||||
<Space>
|
||||
{getStorageTargetTypeLabel(target.type) && <Tag color="arcoblue" bordered>{getStorageTargetTypeLabel(target.type)}</Tag>}
|
||||
@@ -211,6 +221,9 @@ export function StorageTargetsPage() {
|
||||
<Typography.Text type="secondary">更新时间:{target.updatedAt}</Typography.Text>
|
||||
|
||||
<Space wrap size="mini">
|
||||
<Button size="small" type="text" onClick={() => void handleToggleStar(target.id)}>
|
||||
{target.starred ? '取消收藏' : '收藏'}
|
||||
</Button>
|
||||
<Button size="small" type="text" onClick={() => void openEdit(target.id)} loading={submitting && editingTarget?.id === target.id}>
|
||||
编辑
|
||||
</Button>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { BackupTasksPage } from '../pages/backup-tasks/BackupTasksPage'
|
||||
import { GoogleDriveCallbackPage } from '../pages/storage-targets/GoogleDriveCallbackPage'
|
||||
import { StorageTargetsPage } from '../pages/storage-targets/StorageTargetsPage'
|
||||
import { SettingsPage } from '../pages/settings/SettingsPage'
|
||||
import { AuditLogsPage } from '../pages/audit/AuditLogsPage'
|
||||
import NodesPage from '../pages/nodes/NodesPage'
|
||||
import { ProtectedRoute } from './ProtectedRoute'
|
||||
|
||||
@@ -32,6 +33,7 @@ export function RouterView() {
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="settings/notifications" element={<NotificationsPage />} />
|
||||
<Route path="nodes" element={<NodesPage />} />
|
||||
<Route path="audit" element={<AuditLogsPage />} />
|
||||
<Route path="system-info" element={<Navigate to="/settings" replace />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
7
web/src/services/audit.ts
Normal file
7
web/src/services/audit.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { http } from './http'
|
||||
import type { AuditLogListResult } from '../types/audit'
|
||||
|
||||
export async function listAuditLogs(params: { category?: string; limit?: number; offset?: number }) {
|
||||
const response = await http.get<{ code: string; message: string; data: AuditLogListResult }>('/audit-logs', { params })
|
||||
return response.data.data
|
||||
}
|
||||
18
web/src/services/database.ts
Normal file
18
web/src/services/database.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { http, type ApiEnvelope, unwrapApiEnvelope } from './http'
|
||||
|
||||
export interface DatabaseDiscoverPayload {
|
||||
type: 'mysql' | 'postgresql'
|
||||
host: string
|
||||
port: number
|
||||
user: string
|
||||
password: string
|
||||
}
|
||||
|
||||
interface DatabaseDiscoverResult {
|
||||
databases: string[]
|
||||
}
|
||||
|
||||
export async function discoverDatabases(payload: DatabaseDiscoverPayload): Promise<string[]> {
|
||||
const response = await http.post<ApiEnvelope<DatabaseDiscoverResult>>('/database/discover', payload, { timeout: 10000 })
|
||||
return unwrapApiEnvelope(response.data).databases ?? []
|
||||
}
|
||||
@@ -74,6 +74,11 @@ export interface StorageTargetUsage {
|
||||
totalSize: number
|
||||
}
|
||||
|
||||
export async function toggleStorageTargetStar(id: number) {
|
||||
const response = await http.put<ApiEnvelope<StorageTargetSummary>>(`/storage-targets/${id}/star`)
|
||||
return unwrap(response.data)
|
||||
}
|
||||
|
||||
export async function getStorageTargetUsage(id: number) {
|
||||
const response = await http.get<ApiEnvelope<StorageTargetUsage>>(`/storage-targets/${id}/usage`)
|
||||
return unwrap(response.data)
|
||||
|
||||
18
web/src/types/audit.ts
Normal file
18
web/src/types/audit.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export interface AuditLog {
|
||||
id: number
|
||||
userId: number
|
||||
username: string
|
||||
category: string
|
||||
action: string
|
||||
targetType: string
|
||||
targetId: string
|
||||
targetName: string
|
||||
detail: string
|
||||
clientIp: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface AuditLogListResult {
|
||||
items: AuditLog[]
|
||||
total: number
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export interface BackupRecordSummary {
|
||||
status: BackupRecordStatus
|
||||
fileName: string
|
||||
fileSize: number
|
||||
checksum: string
|
||||
storagePath: string
|
||||
durationSeconds: number
|
||||
errorMessage: string
|
||||
@@ -26,9 +27,19 @@ export interface BackupRecordSummary {
|
||||
completedAt?: string
|
||||
}
|
||||
|
||||
export interface StorageUploadResultItem {
|
||||
storageTargetId: number
|
||||
storageTargetName: string
|
||||
status: 'success' | 'failed'
|
||||
storagePath?: string
|
||||
fileSize?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface BackupRecordDetail extends BackupRecordSummary {
|
||||
logContent: string
|
||||
logEvents?: BackupLogEvent[]
|
||||
storageUploadResults?: StorageUploadResultItem[]
|
||||
}
|
||||
|
||||
export interface BackupRecordListFilter {
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface BackupTaskSummary {
|
||||
cronExpr: string
|
||||
storageTargetId: number
|
||||
storageTargetName: string
|
||||
storageTargetIds: number[]
|
||||
storageTargetNames: string[]
|
||||
nodeId: number
|
||||
nodeName?: string
|
||||
tags: string
|
||||
@@ -24,6 +26,7 @@ export interface BackupTaskSummary {
|
||||
|
||||
export interface BackupTaskDetail extends BackupTaskSummary {
|
||||
sourcePath: string
|
||||
sourcePaths: string[]
|
||||
excludePatterns: string[]
|
||||
dbHost: string
|
||||
dbPort: number
|
||||
@@ -40,6 +43,7 @@ export interface BackupTaskPayload {
|
||||
enabled: boolean
|
||||
cronExpr: string
|
||||
sourcePath: string
|
||||
sourcePaths: string[]
|
||||
excludePatterns: string[]
|
||||
dbHost: string
|
||||
dbPort: number
|
||||
@@ -48,6 +52,7 @@ export interface BackupTaskPayload {
|
||||
dbName: string
|
||||
dbPath: string
|
||||
storageTargetId: number
|
||||
storageTargetIds: number[]
|
||||
nodeId: number
|
||||
tags: string
|
||||
retentionDays: number
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface StorageTargetSummary {
|
||||
type: StorageTargetType
|
||||
description: string
|
||||
enabled: boolean
|
||||
starred: boolean
|
||||
updatedAt: string
|
||||
lastTestedAt?: string
|
||||
lastTestStatus: StorageTestStatus
|
||||
|
||||
Reference in New Issue
Block a user