first commit

This commit is contained in:
Awuqing
2026-03-17 13:29:09 +08:00
commit eadd3f8961
219 changed files with 22394 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
web/node_modules/

191
LICENSE Normal file
View File

@@ -0,0 +1,191 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2026 BackupX Contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

30
Makefile Normal file
View File

@@ -0,0 +1,30 @@
.PHONY: build dev test clean
# 一次性构建前后端
build: build-server build-web
build-server:
cd server && go build -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:
cd server && go test ./...
test-web:
cd web && npm run test
# 清理构建产物
clean:
rm -rf server/bin web/dist

436
README.md Normal file
View File

@@ -0,0 +1,436 @@
<p align="center">
<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>
</p>
<p align="center">
<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/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">
<img src="https://img.shields.io/badge/License-Apache_2.0-blue?style=flat-square" alt="License">
</p>
</p>
---
BackupX 是一个面向 **Linux / macOS 服务器**的自托管备份管理平台。通过企业级 Web 控制台,轻松配置目录备份、数据库备份,并将备份文件安全存储到阿里云 OSS、腾讯云 COS、七牛云 Kodo、Google Drive、S3 兼容存储、WebDAV 或本地磁盘。
支持 **多节点集群管理**,可统一管控分布在不同服务器上的备份任务。
> **适用人群**:拥有 Linux 服务器的个人开发者 / 小团队 / 企业运维
## Features
### 📦 多种备份类型
- **文件/目录** — 支持自定义排除规则(如 `node_modules``*.log`
- **MySQL** — 通过 `mysqldump` 原生工具
- **SQLite** — 安全文件拷贝
- **PostgreSQL** — 通过 `pg_dump` 原生工具
### ☁️ 多云存储后端
| 厂商 | 类型 | 说明 |
|------|------|------|
| 🇨🇳 **阿里云 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 等 |
| 💾 **本地磁盘** | `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
### 从源码构建
```bash
# 克隆项目
git clone https://github.com/yourname/backupx.git
cd backupx
# 一键构建前后端
make build
# 启动后端服务(默认监听 :8340
cd server && ./bin/backupx
```
### 访问 Web UI
打开浏览器访问 `http://your-server:8340`,首次使用会引导您创建管理员账户。
## Configuration
配置文件路径默认为 `./config.yaml`,也可通过环境变量 `BACKUPX_` 前缀覆盖。
```yaml
# config.yaml
server:
host: "0.0.0.0"
port: 8340
mode: "release" # debug | release
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 # 日志保留天数
```
> 💡 `jwt_secret` 和 `encryption_key` 首次启动时自动生成并持久化到数据库,无需手动配置。
## 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 │ ││
│ ┌──────────┐ │ │ 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 |
| **安全** | 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 执行
### 添加节点
```bash
# 在 Web 控制台 → 节点管理 → 添加节点
# 系统将生成一个唯一的 64 位十六进制 Token
# 在远程服务器上配置 Agent 启动参数
./backupx-agent --master http://master-server:8340 --token <your-token>
```
### 目录探针 API
Master 提供 `GET /api/nodes/:id/fs/list?path=/` 接口,可远程浏览节点的文件系统目录。前端在创建备份任务的"源路径"输入时可使用树形选择器直接浏览目标机器的目录结构。
## Project Structure
```
backupx/
├── server/ # Go 后端
│ ├── cmd/backupx/ # 入口
│ ├── internal/
│ │ ├── app/ # 应用组装 (DI)
│ │ ├── backup/ # 备份引擎 (file/mysql/sqlite/pgsql)
│ │ ├── config/ # 配置加载 (viper)
│ │ ├── http/ # HTTP 处理器 + 路由
│ │ ├── model/ # GORM 数据模型
│ │ ├── notify/ # 通知 (email/webhook/telegram)
│ │ ├── repository/ # 数据访问层
│ │ ├── scheduler/ # Cron 调度器
│ │ ├── security/ # JWT + 限流
│ │ ├── service/ # 业务逻辑层
│ │ └── storage/ # 存储后端 (插件化)
│ │ ├── aliyun/ # 阿里云 OSS
│ │ ├── tencent/ # 腾讯云 COS
│ │ ├── qiniu/ # 七牛云 Kodo
│ │ ├── s3/ # S3 Compatible
│ │ ├── googledrive/ # Google Drive
│ │ ├── webdav/ # WebDAV
│ │ └── localdisk/ # 本地磁盘
│ └── pkg/ # 工具包 (compress/crypto/response)
├── web/ # React 前端
│ └── src/
│ ├── components/ # 通用组件 (CronEditor/PathSelector/...)
│ ├── pages/ # 页面 (Dashboard/Tasks/Storage/Nodes/...)
│ ├── services/ # API 请求封装
│ ├── stores/ # Zustand 状态管理
│ ├── locales/ # i18n 语言包 (zh-CN/en-US)
│ └── router/ # 路由配置
├── deploy/ # 部署配置
│ ├── nginx.conf # Nginx 参考配置
│ ├── backupx.service # systemd 服务单元
│ └── install.sh # 一键安装脚本
└── Makefile # 构建命令
```
## Development
### 前置条件
- **Go** ≥ 1.21
- **Node.js** ≥ 18
- **npm**
### 开发模式
```bash
# 终端 1启动后端 (热重载需配合 air)
make dev-server
# 终端 2启动前端 (Vite HMR)
make dev-web
```
### 运行测试
```bash
# 运行全部测试
make test
# 仅后端
make test-server # go test ./...
# 仅前端
make test-web # npm run test
```
### 构建
```bash
# 构建前后端
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 反向代理(如已安装)
### 手动部署
```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
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;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
## API Reference
所有 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 /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点击授权
## 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>

18
deploy/backupx.service Normal file
View File

@@ -0,0 +1,18 @@
[Unit]
Description=BackupX API Service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=backupx
Group=backupx
WorkingDirectory=/opt/backupx
ExecStart=/opt/backupx/bin/backupx -config /etc/backupx/config.yaml
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target

72
deploy/install.sh Executable file
View File

@@ -0,0 +1,72 @@
#!/bin/sh
set -eu
PROJECT_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
PREFIX="${PREFIX:-/opt/backupx}"
ETC_DIR="${ETC_DIR:-/etc/backupx}"
SERVICE_NAME="backupx"
APP_USER="backupx"
APP_GROUP="backupx"
BIN_SOURCE="${BIN_SOURCE:-$PROJECT_ROOT/server/backupx}"
WEB_SOURCE="${WEB_SOURCE:-$PROJECT_ROOT/web/dist}"
CONFIG_TEMPLATE="${CONFIG_TEMPLATE:-$PROJECT_ROOT/server/config.example.yaml}"
SERVICE_SOURCE="${SERVICE_SOURCE:-$PROJECT_ROOT/deploy/backupx.service}"
NGINX_SOURCE="${NGINX_SOURCE:-$PROJECT_ROOT/deploy/nginx.conf}"
if [ "$(id -u)" -ne 0 ]; then
echo "请使用 root 或 sudo 执行安装脚本。" >&2
exit 1
fi
if [ ! -f "$BIN_SOURCE" ]; then
echo "未找到后端二进制:$BIN_SOURCE" >&2
echo "请先执行cd \"$PROJECT_ROOT/server\" && go build -o backupx ./cmd/backupx" >&2
exit 1
fi
if [ ! -d "$WEB_SOURCE" ]; then
echo "未找到前端构建产物:$WEB_SOURCE" >&2
echo "请先执行cd \"$PROJECT_ROOT/web\" && npm run build" >&2
exit 1
fi
if ! getent group "$APP_GROUP" >/dev/null 2>&1; then
groupadd --system "$APP_GROUP"
fi
if ! id "$APP_USER" >/dev/null 2>&1; then
useradd --system --gid "$APP_GROUP" --home-dir "$PREFIX" --shell /usr/sbin/nologin "$APP_USER"
fi
install -d -o "$APP_USER" -g "$APP_GROUP" "$PREFIX" "$PREFIX/bin" "$PREFIX/web" "$PREFIX/data" "$ETC_DIR"
install -m 0755 "$BIN_SOURCE" "$PREFIX/bin/backupx"
cp -R "$WEB_SOURCE/." "$PREFIX/web/"
chown -R "$APP_USER:$APP_GROUP" "$PREFIX"
if [ ! -f "$ETC_DIR/config.yaml" ]; then
install -m 0640 "$CONFIG_TEMPLATE" "$ETC_DIR/config.yaml"
fi
install -m 0644 "$SERVICE_SOURCE" "/etc/systemd/system/$SERVICE_NAME.service"
systemctl daemon-reload
systemctl enable --now "$SERVICE_NAME"
if [ -d "/etc/nginx/conf.d" ]; then
install -m 0644 "$NGINX_SOURCE" "/etc/nginx/conf.d/$SERVICE_NAME.conf"
if command -v nginx >/dev/null 2>&1; then
nginx -t
systemctl reload nginx || true
fi
fi
cat <<MESSAGE
安装完成。
- 二进制目录:$PREFIX/bin/backupx
- 前端目录:$PREFIX/web
- 配置文件:$ETC_DIR/config.yaml
- systemd 服务:/etc/systemd/system/$SERVICE_NAME.service
如需修改监听地址、数据库路径或日志级别,请编辑 "$ETC_DIR/config.yaml" 后执行:
systemctl restart "$SERVICE_NAME"
MESSAGE

24
deploy/nginx.conf Normal file
View File

@@ -0,0 +1,24 @@
server {
listen 80;
server_name _;
root /opt/backupx/web;
index index.html;
location /api/ {
proxy_pass http://127.0.0.1:8340/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;
}
location / {
try_files $uri $uri/ /index.html;
}
}

BIN
server/.DS_Store vendored Normal file

Binary file not shown.

14
server/Makefile Normal file
View File

@@ -0,0 +1,14 @@
APP_NAME=backupx
BUILD_DIR=./bin
.PHONY: build run test
build:
mkdir -p $(BUILD_DIR)
go build -o $(BUILD_DIR)/$(APP_NAME) ./cmd/backupx
run:
go run ./cmd/backupx
test:
go test ./...

View File

@@ -0,0 +1,50 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"os/signal"
"syscall"
"backupx/server/internal/app"
"backupx/server/internal/config"
)
var version = "dev"
func main() {
var configPath string
var showVersion bool
flag.StringVar(&configPath, "config", "", "path to config file")
flag.BoolVar(&showVersion, "version", false, "print version")
flag.Parse()
if showVersion {
fmt.Println(version)
return
}
cfg, err := config.Load(configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "load config: %v\n", err)
os.Exit(1)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
application, err := app.New(ctx, cfg, version)
if err != nil {
fmt.Fprintf(os.Stderr, "bootstrap app: %v\n", err)
os.Exit(1)
}
defer application.Close()
if err := application.Run(ctx); err != nil {
application.Logger().Error("application exited with error", app.ErrorField(err))
os.Exit(1)
}
}

View File

@@ -0,0 +1,24 @@
# config.yaml
server:
host: "0.0.0.0"
port: 8340
mode: "release" # debug | release
database:
path: "./data/backupx.db" # SQLite 数据库路径
security:
jwt_secret: "" # 留空则自动生成
jwt_expire: "24h"
encryption_key: "" # AES 加密密钥,留空自动生成
backup:
temp_dir: "./data/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 # 天

96
server/go.mod Normal file
View File

@@ -0,0 +1,96 @@
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/natefinch/lumberjack v2.0.0+incompatible
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.33.0
golang.org/x/oauth2 v0.25.0
google.golang.org/api v0.215.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
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 // 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/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/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/dustin/go-humanize v1.0.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/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // 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/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/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/subosito/gotenv v1.6.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
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.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
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
)

223
server/go.sum Normal file
View File

@@ -0,0 +1,223 @@
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=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
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/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/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/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=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11 h1:BYf7XNsJMzl4mObARUBUib+j2tf0U//JAAtTnYqvqCw=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11/go.mod h1:aEUS4WrNk/+FxkBZZa7tVgp4pGH+kFGW40Y8rCPqt5g=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 h1:X1Tow7suZk9UCJHE1Iw9GMZJJl0dAnKXXP1NaSDHwmw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19/go.mod h1:/rARO8psX+4sfjUQXp5LLifjUt8DuATZ31WptNJTyQA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19 h1:JnQeStZvPHFHeyky/7LbMlyQjUa+jIBj36OlWm0pzIk=
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/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/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/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
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/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-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/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
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/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/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/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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.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/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
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/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/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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/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/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
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/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/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/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.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/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.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=
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.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
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.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
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=
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/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/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

188
server/internal/app/app.go Normal file
View File

@@ -0,0 +1,188 @@
package app
import (
"context"
"errors"
"fmt"
stdhttp "net/http"
"time"
"backupx/server/internal/backup"
backupretention "backupx/server/internal/backup/retention"
"backupx/server/internal/config"
"backupx/server/internal/database"
aphttp "backupx/server/internal/http"
"backupx/server/internal/logger"
"backupx/server/internal/notify"
"backupx/server/internal/repository"
"backupx/server/internal/scheduler"
"backupx/server/internal/security"
"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"
storageTencent "backupx/server/internal/storage/tencent"
storageQiniu "backupx/server/internal/storage/qiniu"
storageS3 "backupx/server/internal/storage/s3"
storageWebDAV "backupx/server/internal/storage/webdav"
"go.uber.org/zap"
"gorm.io/gorm"
)
type Application struct {
cfg config.Config
version string
logger *zap.Logger
db *gorm.DB
httpServer *stdhttp.Server
scheduler *scheduler.Service
}
func New(ctx context.Context, cfg config.Config, version string) (*Application, error) {
appLogger, err := logger.New(cfg.Log)
if err != nil {
return nil, fmt.Errorf("init logger: %w", err)
}
db, err := database.Open(cfg.Database, appLogger)
if err != nil {
return nil, fmt.Errorf("init database: %w", err)
}
userRepo := repository.NewUserRepository(db)
systemConfigRepo := repository.NewSystemConfigRepository(db)
storageTargetRepo := repository.NewStorageTargetRepository(db)
backupTaskRepo := repository.NewBackupTaskRepository(db)
backupRecordRepo := repository.NewBackupRecordRepository(db)
notificationRepo := repository.NewNotificationRepository(db)
oauthSessionRepo := repository.NewOAuthSessionRepository(db)
resolvedSecurity, err := service.ResolveSecurity(ctx, cfg.Security, systemConfigRepo)
if err != nil {
return nil, fmt.Errorf("resolve security config: %w", err)
}
jwtManager := security.NewJWTManager(resolvedSecurity.JWTSecret, config.MustJWTDuration(cfg.Security))
rateLimiter := security.NewLoginRateLimiter(5, time.Minute)
authService := service.NewAuthService(userRepo, systemConfigRepo, jwtManager, rateLimiter)
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(),
)
storageTargetService := service.NewStorageTargetService(storageTargetRepo, oauthSessionRepo, storageRegistry, configCipher)
storageTargetService.SetBackupTaskRepository(backupTaskRepo)
storageTargetService.SetBackupRecordRepository(backupRecordRepo)
backupTaskService := service.NewBackupTaskService(backupTaskRepo, storageTargetRepo, configCipher)
backupRunnerRegistry := backup.NewRegistry(backup.NewFileRunner(), backup.NewSQLiteRunner(), backup.NewMySQLRunner(nil), backup.NewPostgreSQLRunner(nil))
logHub := backup.NewLogHub()
retentionService := backupretention.NewService(backupRecordRepo)
notifyRegistry := notify.NewRegistry(notify.NewEmailNotifier(), notify.NewWebhookNotifier(), notify.NewTelegramNotifier())
notificationService := service.NewNotificationService(notificationRepo, notifyRegistry, configCipher)
backupExecutionService := service.NewBackupExecutionService(backupTaskRepo, backupRecordRepo, storageTargetRepo, storageRegistry, backupRunnerRegistry, logHub, retentionService, configCipher, notificationService, cfg.Backup.TempDir, cfg.Backup.MaxConcurrent)
schedulerService := scheduler.NewService(backupTaskRepo, backupExecutionService, appLogger)
backupTaskService.SetScheduler(schedulerService)
backupRecordService := service.NewBackupRecordService(backupRecordRepo, backupExecutionService, logHub)
dashboardService := service.NewDashboardService(backupTaskRepo, backupRecordRepo, storageTargetRepo)
settingsService := service.NewSettingsService(systemConfigRepo)
// Cluster: Node management
nodeRepo := repository.NewNodeRepository(db)
nodeService := service.NewNodeService(nodeRepo)
if err := nodeService.EnsureLocalNode(ctx); err != nil {
appLogger.Warn("failed to ensure local node", zap.Error(err))
}
router := aphttp.NewRouter(aphttp.RouterDependencies{
Config: cfg,
Version: version,
Logger: appLogger,
AuthService: authService,
SystemService: systemService,
StorageTargetService: storageTargetService,
BackupTaskService: backupTaskService,
BackupExecutionService: backupExecutionService,
BackupRecordService: backupRecordService,
NotificationService: notificationService,
DashboardService: dashboardService,
SettingsService: settingsService,
NodeService: nodeService,
JWTManager: jwtManager,
UserRepository: userRepo,
SystemConfigRepo: systemConfigRepo,
})
httpServer := &stdhttp.Server{
Addr: cfg.Address(),
Handler: router,
ReadHeaderTimeout: 10 * time.Second,
}
return &Application{
cfg: cfg,
version: version,
logger: appLogger,
db: db,
httpServer: httpServer,
scheduler: schedulerService,
}, nil
}
func (a *Application) Run(ctx context.Context) error {
if a.scheduler != nil {
if err := a.scheduler.Start(context.Background()); err != nil {
return fmt.Errorf("start scheduler: %w", err)
}
}
errCh := make(chan error, 1)
go func() {
a.logger.Info("http server listening", zap.String("addr", a.cfg.Address()), zap.String("version", a.version))
if err := a.httpServer.ListenAndServe(); err != nil && !errors.Is(err, stdhttp.ErrServerClosed) {
errCh <- err
return
}
errCh <- nil
}()
select {
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
a.logger.Info("shutdown signal received")
if err := a.httpServer.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shutdown http server: %w", err)
}
if a.scheduler != nil {
if err := a.scheduler.Stop(context.Background()); err != nil {
return fmt.Errorf("stop scheduler: %w", err)
}
}
return nil
case err := <-errCh:
if err != nil {
return fmt.Errorf("serve http: %w", err)
}
return nil
}
}
func (a *Application) Close() {
if a.logger != nil {
_ = a.logger.Sync()
}
}
func (a *Application) Logger() *zap.Logger {
return a.logger
}
func ErrorField(err error) zap.Field {
return zap.Error(err)
}

View File

@@ -0,0 +1,55 @@
package apperror
import "net/http"
type AppError struct {
Status int
Code string
Message string
Err error
}
func (e *AppError) Error() string {
if e == nil {
return ""
}
if e.Err != nil {
return e.Err.Error()
}
return e.Message
}
func (e *AppError) Unwrap() error {
if e == nil {
return nil
}
return e.Err
}
func New(status int, code, message string, err error) *AppError {
return &AppError{Status: status, Code: code, Message: message, Err: err}
}
func BadRequest(code, message string, err error) *AppError {
return New(http.StatusBadRequest, code, message, err)
}
func Unauthorized(code, message string, err error) *AppError {
return New(http.StatusUnauthorized, code, message, err)
}
func Forbidden(code, message string, err error) *AppError {
return New(http.StatusForbidden, code, message, err)
}
func Conflict(code, message string, err error) *AppError {
return New(http.StatusConflict, code, message, err)
}
func TooManyRequests(code, message string, err error) *AppError {
return New(http.StatusTooManyRequests, code, message, err)
}
func Internal(code, message string, err error) *AppError {
return New(http.StatusInternalServerError, code, message, err)
}

View File

@@ -0,0 +1,189 @@
package backup
import (
"archive/tar"
"compress/gzip"
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
func CreateTarGz(ctx context.Context, sourcePath string, excludePatterns []string, destinationPath string, logger LogWriter) (int64, error) {
sourcePath = filepath.Clean(strings.TrimSpace(sourcePath))
if sourcePath == "" {
return 0, fmt.Errorf("source path is required")
}
if err := os.MkdirAll(filepath.Dir(destinationPath), 0o755); err != nil {
return 0, fmt.Errorf("create destination directory: %w", err)
}
file, err := os.Create(destinationPath)
if err != nil {
return 0, fmt.Errorf("create archive file: %w", err)
}
defer file.Close()
gzipWriter, err := gzip.NewWriterLevel(file, gzip.DefaultCompression)
if err != nil {
return 0, fmt.Errorf("create gzip writer: %w", err)
}
defer gzipWriter.Close()
tarWriter := tar.NewWriter(gzipWriter)
defer tarWriter.Close()
baseParent := filepath.Dir(sourcePath)
walkErr := filepath.Walk(sourcePath, func(path string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
rel, err := filepath.Rel(baseParent, path)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
if shouldExcludeArchive(rel, excludePatterns) {
if info.IsDir() {
return filepath.SkipDir
}
if logger != nil {
logger.WriteLine(fmt.Sprintf("跳过排除路径:%s", rel))
}
return nil
}
header, err := tar.FileInfoHeader(info, "")
if err != nil {
return fmt.Errorf("build tar header: %w", err)
}
header.Name = rel
if info.IsDir() && !strings.HasSuffix(header.Name, "/") {
header.Name += "/"
}
if err := tarWriter.WriteHeader(header); err != nil {
return fmt.Errorf("write tar header: %w", err)
}
if info.IsDir() {
return nil
}
input, err := os.Open(path)
if err != nil {
return fmt.Errorf("open source file: %w", err)
}
defer input.Close()
if _, err := io.Copy(tarWriter, input); err != nil {
return fmt.Errorf("write tar body: %w", err)
}
return nil
})
if walkErr != nil {
return 0, walkErr
}
if err := tarWriter.Close(); err != nil {
return 0, fmt.Errorf("close tar writer: %w", err)
}
if err := gzipWriter.Close(); err != nil {
return 0, fmt.Errorf("close gzip writer: %w", err)
}
if err := file.Close(); err != nil {
return 0, fmt.Errorf("close archive file: %w", err)
}
info, err := os.Stat(destinationPath)
if err != nil {
return 0, fmt.Errorf("stat archive file: %w", err)
}
return info.Size(), nil
}
func ExtractTarGz(ctx context.Context, archivePath string, destinationDir string, logger LogWriter) error {
archivePath = filepath.Clean(archivePath)
destinationDir = filepath.Clean(destinationDir)
file, err := os.Open(archivePath)
if err != nil {
return fmt.Errorf("open archive file: %w", err)
}
defer file.Close()
gzipReader, err := gzip.NewReader(file)
if err != nil {
return fmt.Errorf("open gzip reader: %w", err)
}
defer gzipReader.Close()
tarReader := tar.NewReader(gzipReader)
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
header, err := tarReader.Next()
if err == io.EOF {
return nil
}
if err != nil {
return fmt.Errorf("read tar entry: %w", err)
}
targetPath, err := secureJoin(destinationDir, header.Name)
if err != nil {
return err
}
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(targetPath, 0o755); err != nil {
return fmt.Errorf("create restore directory: %w", err)
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return fmt.Errorf("create restore parent directory: %w", err)
}
output, err := os.Create(targetPath)
if err != nil {
return fmt.Errorf("create restore file: %w", err)
}
if _, err := io.Copy(output, tarReader); err != nil {
output.Close()
return fmt.Errorf("write restore file: %w", err)
}
if err := output.Close(); err != nil {
return fmt.Errorf("close restore file: %w", err)
}
if logger != nil {
logger.WriteLine(fmt.Sprintf("已恢复文件:%s", targetPath))
}
}
}
}
func shouldExcludeArchive(rel string, patterns []string) bool {
rel = filepath.ToSlash(strings.TrimSpace(rel))
base := filepath.Base(rel)
for _, pattern := range patterns {
trimmed := strings.TrimSpace(pattern)
if trimmed == "" {
continue
}
if matched, _ := filepath.Match(trimmed, rel); matched {
return true
}
if matched, _ := filepath.Match(trimmed, base); matched {
return true
}
if strings.Contains(rel, trimmed) {
return true
}
}
return false
}
func secureJoin(root string, relative string) (string, error) {
root = filepath.Clean(root)
target := filepath.Clean(filepath.Join(root, filepath.FromSlash(relative)))
rootWithSep := root + string(filepath.Separator)
if target != root && !strings.HasPrefix(target, rootWithSep) {
return "", fmt.Errorf("archive entry escapes destination: %s", relative)
}
return target, nil
}

View File

@@ -0,0 +1,41 @@
package backup
import (
"context"
"io"
"os"
"os/exec"
)
type CommandOptions struct {
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
Env []string
}
type CommandExecutor interface {
LookPath(file string) (string, error)
Run(ctx context.Context, name string, args []string, options CommandOptions) error
}
type OSCommandExecutor struct{}
func NewOSCommandExecutor() *OSCommandExecutor {
return &OSCommandExecutor{}
}
func (e *OSCommandExecutor) LookPath(file string) (string, error) {
return exec.LookPath(file)
}
func (e *OSCommandExecutor) Run(ctx context.Context, name string, args []string, options CommandOptions) error {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Stdin = options.Stdin
cmd.Stdout = options.Stdout
cmd.Stderr = options.Stderr
if len(options.Env) > 0 {
cmd.Env = append(os.Environ(), options.Env...)
}
return cmd.Run()
}

View File

@@ -0,0 +1,37 @@
//go:build ignore
package backup
import (
"context"
"io"
"os"
"os/exec"
)
type CommandExecutor interface {
LookPath(file string) (string, error)
Run(ctx context.Context, name string, args []string, env map[string]string, stdin io.Reader, stdout io.Writer, stderr io.Writer) error
}
type OSCommandExecutor struct{}
func NewOSCommandExecutor() *OSCommandExecutor {
return &OSCommandExecutor{}
}
func (e *OSCommandExecutor) LookPath(file string) (string, error) {
return exec.LookPath(file)
}
func (e *OSCommandExecutor) Run(ctx context.Context, name string, args []string, env map[string]string, stdin io.Reader, stdout io.Writer, stderr io.Writer) error {
command := exec.CommandContext(ctx, name, args...)
command.Stdin = stdin
command.Stdout = stdout
command.Stderr = stderr
command.Env = os.Environ()
for key, value := range env {
command.Env = append(command.Env, key+"="+value)
}
return command.Run()
}

View File

@@ -0,0 +1,16 @@
package backup
import "strings"
func normalizeDatabaseNames(items []string) []string {
result := make([]string, 0, len(items))
for _, item := range items {
for _, part := range strings.Split(item, ",") {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
result = append(result, trimmed)
}
}
}
return result
}

View File

@@ -0,0 +1,106 @@
package backup
import (
"bytes"
"context"
"errors"
"io"
"os"
"testing"
)
type fakeCommandExecutor struct {
lastName string
lastArgs []string
env []string
lookupErr error
runFunc func(name string, args []string, options CommandOptions) error
}
func (f *fakeCommandExecutor) LookPath(string) (string, error) {
if f.lookupErr != nil {
return "", f.lookupErr
}
return "/usr/bin/fake", nil
}
func (f *fakeCommandExecutor) Run(_ context.Context, name string, args []string, options CommandOptions) error {
f.lastName = name
f.lastArgs = append([]string{}, args...)
f.env = append([]string{}, options.Env...)
if f.runFunc != nil {
return f.runFunc(name, args, options)
}
return nil
}
func TestMySQLRunnerUsesExpectedCommands(t *testing.T) {
executor := &fakeCommandExecutor{runFunc: func(name string, args []string, options CommandOptions) error {
if options.Stdout != nil {
_, _ = io.WriteString(options.Stdout, "mysql dump")
}
return nil
}}
runner := NewMySQLRunner(executor)
result, err := runner.Run(context.Background(), TaskSpec{Name: "mysql", Type: "mysql", Database: DatabaseSpec{Host: "127.0.0.1", Port: 3306, User: "root", Password: "secret", Names: []string{"app, audit"}}}, NopLogWriter{})
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
if executor.lastName != "mysqldump" {
t.Fatalf("expected mysqldump, got %s", executor.lastName)
}
if len(executor.lastArgs) == 0 || executor.lastArgs[len(executor.lastArgs)-2] != "app" || executor.lastArgs[len(executor.lastArgs)-1] != "audit" {
t.Fatalf("unexpected mysql args: %#v", executor.lastArgs)
}
if _, err := os.Stat(result.ArtifactPath); err != nil {
t.Fatalf("artifact file missing: %v", err)
}
}
func TestPostgreSQLRunnerRestoreUsesPsql(t *testing.T) {
executor := &fakeCommandExecutor{}
runner := NewPostgreSQLRunner(executor)
artifact := filepathJoinTempFile(t, "restore.sql", "select 1;")
if err := runner.Restore(context.Background(), TaskSpec{Name: "postgres", Type: "postgresql", Database: DatabaseSpec{Host: "127.0.0.1", Port: 5432, User: "postgres", Password: "secret"}}, artifact, NopLogWriter{}); err != nil {
t.Fatalf("Restore returned error: %v", err)
}
if executor.lastName != "psql" {
t.Fatalf("expected psql, got %s", executor.lastName)
}
}
func TestMySQLRunnerReturnsLookupError(t *testing.T) {
runner := NewMySQLRunner(&fakeCommandExecutor{lookupErr: errors.New("missing")})
_, err := runner.Run(context.Background(), TaskSpec{Name: "mysql", Type: "mysql", Database: DatabaseSpec{Host: "127.0.0.1", Port: 3306, User: "root", Password: "secret", Names: []string{"app"}}}, NopLogWriter{})
if err == nil {
t.Fatal("expected error when mysqldump is missing")
}
}
func filepathJoinTempFile(t *testing.T, name string, content string) string {
t.Helper()
filePath := t.TempDir() + "/" + name
if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
return filePath
}
func TestPostgreSQLRunnerRunAppendsMultipleDatabaseDumps(t *testing.T) {
executor := &fakeCommandExecutor{runFunc: func(name string, args []string, options CommandOptions) error {
_, _ = io.Copy(options.Stdout, bytes.NewBufferString(args[len(args)-1]))
return nil
}}
runner := NewPostgreSQLRunner(executor)
result, err := runner.Run(context.Background(), TaskSpec{Name: "pg", Type: "postgresql", Database: DatabaseSpec{Host: "127.0.0.1", Port: 5432, User: "postgres", Password: "secret", Names: []string{"app", "audit"}}}, NopLogWriter{})
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
content, err := os.ReadFile(result.ArtifactPath)
if err != nil {
t.Fatalf("ReadFile returned error: %v", err)
}
if !bytes.Contains(content, []byte("app")) || !bytes.Contains(content, []byte("audit")) {
t.Fatalf("unexpected pg dump content: %s", string(content))
}
}

View File

@@ -0,0 +1,191 @@
package backup
import (
"archive/tar"
"context"
"fmt"
"io"
"os"
"path"
"path/filepath"
"strings"
)
type FileRunner struct{}
func NewFileRunner() *FileRunner {
return &FileRunner{}
}
func (r *FileRunner) Type() string {
return "file"
}
func (r *FileRunner) Run(_ context.Context, task TaskSpec, writer LogWriter) (*RunResult, error) {
sourcePath := filepath.Clean(strings.TrimSpace(task.SourcePath))
if sourcePath == "" {
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)
}
tempDir, artifactPath, err := createTempArtifact(task.TempDir, task.Name, "tar")
if err != nil {
return nil, err
}
artifactFile, err := os.Create(artifactPath)
if err != nil {
return nil, fmt.Errorf("create tar artifact: %w", err)
}
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)
if err != nil {
return err
}
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
}
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: %w", walkErr)
}
if info.IsDir() {
writer.WriteLine(fmt.Sprintf("目录打包完成(%d 个目录,%d 个文件)", dirCount, fileCount))
} else {
writer.WriteLine("文件打包完成")
}
return &RunResult{ArtifactPath: artifactPath, FileName: filepath.Base(artifactPath), TempDir: tempDir}, nil
}
func (r *FileRunner) Restore(_ context.Context, task TaskSpec, artifactPath string, writer LogWriter) error {
artifactFile, err := os.Open(artifactPath)
if err != nil {
return fmt.Errorf("open tar artifact: %w", err)
}
defer artifactFile.Close()
targetParent := filepath.Dir(filepath.Clean(strings.TrimSpace(task.SourcePath)))
if err := os.MkdirAll(targetParent, 0o755); err != nil {
return fmt.Errorf("create restore parent: %w", err)
}
tr := tar.NewReader(artifactFile)
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("read tar entry: %w", err)
}
cleanName := path.Clean(strings.TrimSpace(header.Name))
if cleanName == "." || cleanName == "" {
continue
}
targetPath := filepath.Clean(filepath.Join(targetParent, filepath.FromSlash(cleanName)))
parentWithSep := filepath.Clean(targetParent) + string(filepath.Separator)
if targetPath != filepath.Clean(targetParent) && !strings.HasPrefix(targetPath, parentWithSep) {
return fmt.Errorf("tar entry escapes restore path")
}
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(targetPath, os.FileMode(header.Mode)); err != nil {
return fmt.Errorf("create restore dir: %w", err)
}
case tar.TypeReg, tar.TypeRegA:
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return fmt.Errorf("create restore parent dir: %w", err)
}
file, err := os.OpenFile(targetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(header.Mode))
if err != nil {
return fmt.Errorf("create restore file: %w", err)
}
if _, err := io.Copy(file, tr); err != nil {
file.Close()
return fmt.Errorf("write restore file: %w", err)
}
if err := file.Close(); err != nil {
return fmt.Errorf("close restore file: %w", err)
}
}
}
writer.WriteLine("文件恢复完成")
return nil
}
func normalizeExcludePatterns(items []string) []string {
result := make([]string, 0, len(items))
for _, item := range items {
trimmed := strings.TrimSpace(item)
if trimmed != "" {
result = append(result, filepath.ToSlash(trimmed))
}
}
return result
}
func shouldExcludeEntry(relPath string, isDir bool, patterns []string) bool {
relPath = filepath.ToSlash(relPath)
base := path.Base(relPath)
for _, pattern := range patterns {
if matched, _ := path.Match(pattern, relPath); matched {
return true
}
if matched, _ := path.Match(pattern, base); matched {
return true
}
if isDir && strings.TrimSuffix(pattern, "/") == base {
return true
}
}
return false
}

View File

@@ -0,0 +1,69 @@
package backup
import (
"archive/tar"
"context"
"os"
"path/filepath"
"testing"
)
type bufferWriter struct{ lines []string }
func (w *bufferWriter) WriteLine(message string) { w.lines = append(w.lines, message) }
func TestFileRunnerRunAndRestore(t *testing.T) {
tempDir := t.TempDir()
sourceDir := filepath.Join(tempDir, "site")
if err := os.MkdirAll(filepath.Join(sourceDir, "node_modules"), 0o755); err != nil {
t.Fatalf("MkdirAll returned error: %v", err)
}
if err := os.WriteFile(filepath.Join(sourceDir, "index.html"), []byte("ok"), 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
if err := os.WriteFile(filepath.Join(sourceDir, "app.log"), []byte("skip"), 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
if err := os.WriteFile(filepath.Join(sourceDir, "node_modules", "pkg.json"), []byte("skip-dir"), 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
runner := NewFileRunner()
writer := &bufferWriter{}
result, err := runner.Run(context.Background(), TaskSpec{Name: "site files", Type: "file", SourcePath: sourceDir, ExcludePatterns: []string{"*.log", "node_modules"}}, writer)
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
archiveFile, err := os.Open(result.ArtifactPath)
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
defer archiveFile.Close()
reader := tar.NewReader(archiveFile)
entries := map[string]bool{}
for {
header, err := reader.Next()
if err != nil {
break
}
entries[header.Name] = true
}
if !entries["site/index.html"] {
t.Fatalf("expected site/index.html in archive, got %#v", entries)
}
if entries["site/app.log"] || entries["site/node_modules/pkg.json"] {
t.Fatalf("unexpected excluded entries: %#v", entries)
}
if err := os.RemoveAll(sourceDir); err != nil {
t.Fatalf("RemoveAll returned error: %v", err)
}
if err := runner.Restore(context.Background(), TaskSpec{Name: "site files", Type: "file", SourcePath: sourceDir}, result.ArtifactPath, writer); err != nil {
t.Fatalf("Restore returned error: %v", err)
}
content, err := os.ReadFile(filepath.Join(sourceDir, "index.html"))
if err != nil {
t.Fatalf("ReadFile returned error: %v", err)
}
if string(content) != "ok" {
t.Fatalf("unexpected restored content: %s", string(content))
}
}

View File

@@ -0,0 +1,41 @@
package backup
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
func createTempArtifact(baseDir, taskName string, extension string) (string, string, error) {
tempDir, err := os.MkdirTemp(baseDir, "backupx-run-*")
if err != nil {
return "", "", fmt.Errorf("create temp dir: %w", err)
}
base := sanitizeFileName(taskName)
if base == "" {
base = "backup"
}
fileName := fmt.Sprintf("%s_%s.%s", base, time.Now().UTC().Format("20060102T150405"), strings.TrimPrefix(extension, "."))
return tempDir, filepath.Join(tempDir, fileName), nil
}
func sanitizeFileName(value string) string {
builder := strings.Builder{}
for _, char := range strings.TrimSpace(value) {
switch {
case char >= 'a' && char <= 'z':
builder.WriteRune(char)
case char >= 'A' && char <= 'Z':
builder.WriteRune(char + ('a' - 'A'))
case char >= '0' && char <= '9':
builder.WriteRune(char)
case char == '-' || char == '_':
builder.WriteRune(char)
case char == ' ' || char == '.':
builder.WriteRune('_')
}
}
return strings.Trim(builder.String(), "_")
}

View File

@@ -0,0 +1,110 @@
package backup
import (
"sync"
"time"
)
type LogHub struct {
mu sync.RWMutex
streams map[uint]*logStreamState
}
type logStreamState struct {
nextSequence int64
events []LogEvent
subscribers map[int]chan LogEvent
nextSubID int
completed bool
status string
}
func NewLogHub() *LogHub {
return &LogHub{streams: make(map[uint]*logStreamState)}
}
func (h *LogHub) Append(recordID uint, level, message string) LogEvent {
h.mu.Lock()
defer h.mu.Unlock()
state := h.ensureState(recordID)
state.nextSequence++
event := LogEvent{RecordID: recordID, Sequence: state.nextSequence, Level: level, Message: message, Timestamp: time.Now().UTC(), Status: state.status}
state.events = append(state.events, event)
for _, subscriber := range state.subscribers {
select {
case subscriber <- event:
default:
}
}
return event
}
func (h *LogHub) Snapshot(recordID uint) []LogEvent {
h.mu.RLock()
defer h.mu.RUnlock()
state, ok := h.streams[recordID]
if !ok {
return nil
}
result := make([]LogEvent, len(state.events))
copy(result, state.events)
return result
}
func (h *LogHub) Subscribe(recordID uint, buffer int) (<-chan LogEvent, func()) {
if buffer <= 0 {
buffer = 32
}
h.mu.Lock()
defer h.mu.Unlock()
state := h.ensureState(recordID)
state.nextSubID++
id := state.nextSubID
channel := make(chan LogEvent, buffer)
state.subscribers[id] = channel
for _, event := range state.events {
channel <- event
}
cancel := func() {
h.mu.Lock()
defer h.mu.Unlock()
stream, ok := h.streams[recordID]
if !ok {
return
}
subscriber, ok := stream.subscribers[id]
if !ok {
return
}
delete(stream.subscribers, id)
close(subscriber)
}
return channel, cancel
}
func (h *LogHub) Complete(recordID uint, status string) {
h.mu.Lock()
defer h.mu.Unlock()
state := h.ensureState(recordID)
state.completed = true
state.status = status
state.nextSequence++
event := LogEvent{RecordID: recordID, Sequence: state.nextSequence, Level: "info", Message: "stream completed", Timestamp: time.Now().UTC(), Completed: true, Status: status}
state.events = append(state.events, event)
for _, subscriber := range state.subscribers {
select {
case subscriber <- event:
default:
}
}
}
func (h *LogHub) ensureState(recordID uint) *logStreamState {
state, ok := h.streams[recordID]
if ok {
return state
}
state = &logStreamState{subscribers: make(map[int]chan LogEvent), status: "running"}
h.streams[recordID] = state
return state
}

View File

@@ -0,0 +1,26 @@
package backup
import "testing"
func TestLogHubAppendSubscribeAndComplete(t *testing.T) {
hub := NewLogHub()
channel, cancel := hub.Subscribe(1, 4)
defer cancel()
first := hub.Append(1, "info", "started")
if first.Sequence != 1 || first.Message != "started" {
t.Fatalf("unexpected first event: %#v", first)
}
snapshot := hub.Snapshot(1)
if len(snapshot) != 1 {
t.Fatalf("expected snapshot size 1, got %d", len(snapshot))
}
event := <-channel
if event.Message != "started" {
t.Fatalf("unexpected streamed event: %#v", event)
}
hub.Complete(1, "success")
completeEvent := <-channel
if !completeEvent.Completed || completeEvent.Status != "success" {
t.Fatalf("unexpected completion event: %#v", completeEvent)
}
}

View File

@@ -0,0 +1,56 @@
package backup
import (
"fmt"
"strings"
"sync"
)
type ExecutionLogger struct {
recordID uint
hub *LogHub
mu sync.Mutex
buffer strings.Builder
}
func NewExecutionLogger(recordID uint, hub *LogHub) *ExecutionLogger {
return &ExecutionLogger{recordID: recordID, hub: hub}
}
func (l *ExecutionLogger) Write(level, message string) {
trimmed := strings.TrimSpace(message)
if trimmed == "" {
return
}
l.mu.Lock()
defer l.mu.Unlock()
if l.buffer.Len() > 0 {
l.buffer.WriteByte('\n')
}
l.buffer.WriteString(trimmed)
if l.hub != nil {
l.hub.Append(l.recordID, level, trimmed)
}
}
func (l *ExecutionLogger) Infof(format string, args ...any) {
l.Write("info", fmt.Sprintf(format, args...))
}
func (l *ExecutionLogger) Errorf(format string, args ...any) {
l.Write("error", fmt.Sprintf(format, args...))
}
func (l *ExecutionLogger) Warnf(format string, args ...any) {
l.Write("warn", fmt.Sprintf(format, args...))
}
func (l *ExecutionLogger) WriteLine(message string) {
l.Infof("%s", message)
}
func (l *ExecutionLogger) String() string {
l.mu.Lock()
defer l.mu.Unlock()
return l.buffer.String()
}

View File

@@ -0,0 +1,163 @@
package backup
import (
"bufio"
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
type MySQLRunner struct {
executor CommandExecutor
}
func NewMySQLRunner(executor CommandExecutor) *MySQLRunner {
if executor == nil {
executor = NewOSCommandExecutor()
}
return &MySQLRunner{executor: executor}
}
func (r *MySQLRunner) Type() string {
return "mysql"
}
func (r *MySQLRunner) Run(ctx context.Context, task TaskSpec, writer LogWriter) (*RunResult, error) {
if _, err := r.executor.LookPath("mysqldump"); err != nil {
return nil, fmt.Errorf("未找到 mysqldump 命令 (请确保服务器已安装 mysql-client 或 mariadb-client)")
}
startedAt := task.StartedAt
if startedAt.IsZero() {
startedAt = time.Now().UTC()
}
tempDir, err := CreateTaskTempDir(task.Name, startedAt)
if err != nil {
return nil, err
}
fileName := BuildArtifactName(task.Name, startedAt, "sql")
artifactPath := filepath.Join(tempDir, fileName)
file, err := os.Create(artifactPath)
if err != nil {
return nil, fmt.Errorf("create mysql dump file: %w", err)
}
defer file.Close()
dbNames := normalizeDatabaseNames(task.Database.Names)
if len(dbNames) == 0 {
return nil, fmt.Errorf("mysql database names are required")
}
args := []string{
"--host", task.Database.Host,
"--port", strconv.Itoa(task.Database.Port),
"--user", task.Database.User,
"--single-transaction",
"--quick",
"--routines",
"--triggers",
"--events",
"--no-tablespaces",
"--net-buffer-length=32768",
"--databases",
}
args = append(args, dbNames...)
writer.WriteLine(fmt.Sprintf("连接到 MySQL: %s:%d", task.Database.Host, task.Database.Port))
writer.WriteLine(fmt.Sprintf("备份数据库: %s", strings.Join(dbNames, ", ")))
stderrWriter := newLogLineWriter(writer, "mysqldump")
writer.WriteLine("开始执行 mysqldump")
if err := r.executor.Run(ctx, "mysqldump", args, CommandOptions{Stdout: file, Stderr: stderrWriter, Env: mysqlEnv(task.Database.Password)}); err != nil {
return nil, fmt.Errorf("run mysqldump: %w: %s", err, stderrWriter.collected())
}
info, err := file.Stat()
if err != nil {
return nil, fmt.Errorf("stat mysql dump file: %w", err)
}
writer.WriteLine(fmt.Sprintf("MySQL 导出完成(文件大小: %s", formatFileSize(info.Size())))
return &RunResult{ArtifactPath: artifactPath, FileName: fileName, TempDir: tempDir, Size: info.Size(), StorageKey: BuildStorageKey("mysql", startedAt, fileName)}, nil
}
func (r *MySQLRunner) Restore(ctx context.Context, task TaskSpec, artifactPath string, writer LogWriter) error {
if _, err := r.executor.LookPath("mysql"); err != nil {
return fmt.Errorf("未找到 mysql 命令 (请确保服务器已安装 mysql-client 或 mariadb-client)")
}
input, err := os.Open(filepath.Clean(artifactPath))
if err != nil {
return fmt.Errorf("open mysql restore file: %w", err)
}
defer input.Close()
stderr := &bytes.Buffer{}
args := []string{"--host", task.Database.Host, "--port", strconv.Itoa(task.Database.Port), "--user", task.Database.User}
writer.WriteLine("开始执行 mysql 恢复")
if err := r.executor.Run(ctx, "mysql", args, CommandOptions{Stdin: input, Stderr: stderr, Env: mysqlEnv(task.Database.Password)}); err != nil {
return fmt.Errorf("run mysql restore: %w: %s", err, strings.TrimSpace(stderr.String()))
}
writer.WriteLine("MySQL 恢复完成")
return nil
}
func mysqlEnv(password string) []string {
if strings.TrimSpace(password) == "" {
return nil
}
return []string{"MYSQL_PWD=" + password}
}
// logLineWriter streams each line of output to a LogWriter in real-time.
type logLineWriter struct {
writer LogWriter
prefix string
buf bytes.Buffer
}
func newLogLineWriter(w LogWriter, prefix string) *logLineWriter {
return &logLineWriter{writer: w, prefix: prefix}
}
func (w *logLineWriter) Write(p []byte) (int, error) {
n := len(p)
w.buf.Write(p)
scanner := bufio.NewScanner(strings.NewReader(w.buf.String()))
var remaining string
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line != "" {
w.writer.WriteLine(fmt.Sprintf("[%s] %s", w.prefix, line))
}
}
// Keep any partial last line (no newline yet)
lastNl := bytes.LastIndexByte(p, '\n')
if lastNl >= 0 {
remaining = w.buf.String()[w.buf.Len()-(len(p)-lastNl-1):]
w.buf.Reset()
w.buf.WriteString(remaining)
}
return n, nil
}
func (w *logLineWriter) collected() string {
return strings.TrimSpace(w.buf.String())
}
func formatFileSize(size int64) string {
const (
KB = 1024
MB = KB * 1024
GB = MB * 1024
)
switch {
case size >= GB:
return fmt.Sprintf("%.2f GB", float64(size)/float64(GB))
case size >= MB:
return fmt.Sprintf("%.2f MB", float64(size)/float64(MB))
case size >= KB:
return fmt.Sprintf("%.2f KB", float64(size)/float64(KB))
default:
return fmt.Sprintf("%d B", size)
}
}

View File

@@ -0,0 +1,171 @@
//go:build ignore
package backup
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"strings"
)
type PostgreSQLRunner struct {
executor CommandExecutor
}
func NewPostgreSQLRunner(executor CommandExecutor) *PostgreSQLRunner {
if executor == nil {
executor = NewOSCommandExecutor()
}
return &PostgreSQLRunner{executor: executor}
}
func (r *PostgreSQLRunner) Type() string {
return "postgresql"
}
func (r *PostgreSQLRunner) Run(ctx context.Context, spec TaskSpec, logger LogSink) (*Result, error) {
if _, err := r.executor.LookPath("pg_dump"); err != nil {
return nil, fmt.Errorf("pg_dump is required: %w", err)
}
databases := splitDatabaseNames(spec.DBName)
if len(databases) == 0 {
return nil, fmt.Errorf("postgresql database name is required")
}
tempDir, err := CreateTaskTempDir(spec.TaskName, spec.StartedAt)
if err != nil {
return nil, err
}
if len(databases) == 1 {
return r.dumpSingleDatabase(ctx, spec, databases[0], tempDir, logger)
}
multiDumpDir := filepath.Join(tempDir, "postgres-dumps")
if err := os.MkdirAll(multiDumpDir, 0o755); err != nil {
return nil, fmt.Errorf("create postgres multi dump directory: %w", err)
}
for _, databaseName := range databases {
if _, err := r.dumpDatabaseToFile(ctx, spec, databaseName, filepath.Join(multiDumpDir, sanitizeDumpName(databaseName)+".sql"), logger); err != nil {
return nil, err
}
}
fileName := BuildArtifactName(spec.TaskName, spec.StartedAt, "tar.gz")
artifactPath := filepath.Join(tempDir, fileName)
size, err := CreateTarGz(ctx, multiDumpDir, nil, artifactPath, logger)
if err != nil {
return nil, err
}
return &Result{ArtifactPath: artifactPath, FileName: fileName, Size: size, StorageKey: BuildStorageKey("postgresql", spec.StartedAt, fileName)}, nil
}
func (r *PostgreSQLRunner) Restore(ctx context.Context, spec TaskSpec, artifactPath string, logger LogSink) error {
if _, err := r.executor.LookPath("psql"); err != nil {
return fmt.Errorf("psql is required: %w", err)
}
databases := splitDatabaseNames(spec.DBName)
if len(databases) == 0 {
return fmt.Errorf("postgresql database name is required")
}
if strings.HasSuffix(strings.ToLower(artifactPath), ".tar.gz") {
restoreDir, err := CreateTaskTempDir(spec.TaskName+"-restore", spec.StartedAt)
if err != nil {
return err
}
if err := ExtractTarGz(ctx, artifactPath, restoreDir, logger); err != nil {
return err
}
for _, databaseName := range databases {
filePath := filepath.Join(restoreDir, filepath.Base(restoreDir), sanitizeDumpName(databaseName)+".sql")
if _, err := os.Stat(filePath); err != nil {
fallback := filepath.Join(restoreDir, "postgres-dumps", sanitizeDumpName(databaseName)+".sql")
filePath = fallback
}
if err := r.restoreDatabaseFromFile(ctx, spec, databaseName, filePath, logger); err != nil {
return err
}
}
return nil
}
return r.restoreDatabaseFromFile(ctx, spec, databases[0], artifactPath, logger)
}
func (r *PostgreSQLRunner) dumpSingleDatabase(ctx context.Context, spec TaskSpec, databaseName string, tempDir string, logger LogSink) (*Result, error) {
fileName := BuildArtifactName(spec.TaskName, spec.StartedAt, "sql")
artifactPath := filepath.Join(tempDir, fileName)
size, err := r.dumpDatabaseToFile(ctx, spec, databaseName, artifactPath, logger)
if err != nil {
return nil, err
}
return &Result{ArtifactPath: artifactPath, FileName: fileName, Size: size, StorageKey: BuildStorageKey("postgresql", spec.StartedAt, fileName)}, nil
}
func (r *PostgreSQLRunner) dumpDatabaseToFile(ctx context.Context, spec TaskSpec, databaseName string, artifactPath string, logger LogSink) (int64, error) {
output, err := os.Create(filepath.Clean(artifactPath))
if err != nil {
return 0, fmt.Errorf("create postgres dump file: %w", err)
}
defer output.Close()
stderr := &bytes.Buffer{}
args := []string{"-h", spec.DBHost, "-p", fmt.Sprintf("%d", spec.DBPort), "-U", spec.DBUser, "-d", databaseName, "--no-owner", "--no-privileges"}
if logger != nil {
logger.Infof("开始执行 pg_dump%s", databaseName)
}
if err := r.executor.Run(ctx, "pg_dump", args, postgresEnv(spec.DBPassword), nil, output, stderr); err != nil {
return 0, fmt.Errorf("run pg_dump: %w: %s", err, strings.TrimSpace(stderr.String()))
}
info, err := output.Stat()
if err != nil {
return 0, fmt.Errorf("stat postgres dump file: %w", err)
}
return info.Size(), nil
}
func (r *PostgreSQLRunner) restoreDatabaseFromFile(ctx context.Context, spec TaskSpec, databaseName string, artifactPath string, logger LogSink) error {
input, err := os.Open(filepath.Clean(artifactPath))
if err != nil {
return fmt.Errorf("open postgres restore file: %w", err)
}
defer input.Close()
stderr := &bytes.Buffer{}
args := []string{"-h", spec.DBHost, "-p", fmt.Sprintf("%d", spec.DBPort), "-U", spec.DBUser, "-d", databaseName}
if logger != nil {
logger.Infof("开始执行 psql 恢复:%s", databaseName)
}
if err := r.executor.Run(ctx, "psql", args, postgresEnv(spec.DBPassword), input, nil, stderr); err != nil {
return fmt.Errorf("run psql restore: %w: %s", err, strings.TrimSpace(stderr.String()))
}
return nil
}
func postgresEnv(password string) map[string]string {
if strings.TrimSpace(password) == "" {
return nil
}
return map[string]string{"PGPASSWORD": password}
}
func splitDatabaseNames(value string) []string {
parts := strings.Split(value, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
continue
}
result = append(result, trimmed)
}
return result
}
func sanitizeDumpName(value string) string {
trimmed := strings.TrimSpace(strings.ToLower(value))
trimmed = strings.ReplaceAll(trimmed, " ", "-")
trimmed = strings.ReplaceAll(trimmed, "/", "-")
trimmed = strings.ReplaceAll(trimmed, "\\", "-")
trimmed = strings.Trim(trimmed, "-._")
if trimmed == "" {
return "database"
}
return trimmed
}

View File

@@ -0,0 +1,80 @@
package backup
import (
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
type PostgreSQLRunner struct {
executor CommandExecutor
}
func NewPostgreSQLRunner(executor CommandExecutor) *PostgreSQLRunner {
if executor == nil {
executor = NewOSCommandExecutor()
}
return &PostgreSQLRunner{executor: executor}
}
func (r *PostgreSQLRunner) Type() string {
return "postgresql"
}
func (r *PostgreSQLRunner) Run(ctx context.Context, task TaskSpec, writer LogWriter) (*RunResult, error) {
if _, err := r.executor.LookPath("pg_dump"); err != nil {
return nil, fmt.Errorf("未找到 pg_dump 命令 (请确保服务器已安装 postgresql-client)")
}
tempDir, artifactPath, err := createTempArtifact(task.TempDir, task.Name, "sql")
if err != nil {
return nil, err
}
file, err := os.Create(artifactPath)
if err != nil {
return nil, fmt.Errorf("create postgresql dump file: %w", err)
}
defer file.Close()
dbNames := normalizeDatabaseNames(task.Database.Names)
if len(dbNames) == 0 {
return nil, fmt.Errorf("postgresql database names are required")
}
writer.WriteLine(fmt.Sprintf("连接到 PostgreSQL: %s:%d", task.Database.Host, task.Database.Port))
writer.WriteLine(fmt.Sprintf("备份数据库: %s", strings.Join(dbNames, ", ")))
stderrWriter := newLogLineWriter(writer, "pg_dump")
for index, name := range dbNames {
args := []string{"--clean", "--if-exists", "--create", "--format=plain", "-h", task.Database.Host, "-p", strconv.Itoa(task.Database.Port), "-U", task.Database.User, "--dbname", name}
writer.WriteLine(fmt.Sprintf("开始导出数据库 [%d/%d]: %s", index+1, len(dbNames), name))
if err := r.executor.Run(ctx, "pg_dump", args, CommandOptions{Stdout: file, Stderr: stderrWriter, Env: append(os.Environ(), "PGPASSWORD="+task.Database.Password)}); err != nil {
return nil, fmt.Errorf("run pg_dump for %s: %w", name, err)
}
writer.WriteLine(fmt.Sprintf("数据库 %s 导出完成", name))
if index < len(dbNames)-1 {
if _, err := file.WriteString("\n\n"); err != nil {
return nil, fmt.Errorf("write dump separator: %w", err)
}
}
}
info, _ := file.Stat()
sizeStr := "未知"
if info != nil {
sizeStr = formatFileSize(info.Size())
}
writer.WriteLine(fmt.Sprintf("PostgreSQL 导出完成(文件大小: %s", sizeStr))
return &RunResult{ArtifactPath: artifactPath, FileName: filepath.Base(artifactPath), TempDir: tempDir}, nil
}
func (r *PostgreSQLRunner) Restore(ctx context.Context, task TaskSpec, artifactPath string, writer LogWriter) error {
if _, err := r.executor.LookPath("psql"); err != nil {
return fmt.Errorf("未找到 psql 命令 (请确保服务器已安装 postgresql-client)")
}
writer.WriteLine("开始执行 psql 恢复")
args := []string{"-h", task.Database.Host, "-p", strconv.Itoa(task.Database.Port), "-U", task.Database.User, "-d", "postgres", "-f", artifactPath}
if err := r.executor.Run(ctx, "psql", args, CommandOptions{Env: append(os.Environ(), "PGPASSWORD="+task.Database.Password)}); err != nil {
return fmt.Errorf("run psql restore: %w", err)
}
writer.WriteLine("PostgreSQL 恢复完成")
return nil
}

View File

@@ -0,0 +1,62 @@
package backup
import (
"fmt"
"sort"
"strings"
"sync"
)
type Registry struct {
mu sync.RWMutex
runners map[string]BackupRunner
}
func NewRegistry(runners ...BackupRunner) *Registry {
registry := &Registry{runners: make(map[string]BackupRunner)}
for _, runner := range runners {
registry.Register(runner)
}
return registry
}
func (r *Registry) Register(runner BackupRunner) {
if runner == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
if r.runners == nil {
r.runners = make(map[string]BackupRunner)
}
r.runners[normalizeTaskType(runner.Type())] = runner
}
func (r *Registry) Runner(taskType string) (BackupRunner, error) {
r.mu.RLock()
defer r.mu.RUnlock()
runner, ok := r.runners[normalizeTaskType(taskType)]
if !ok {
return nil, fmt.Errorf("unsupported backup task type: %s", taskType)
}
return runner, nil
}
func (r *Registry) Types() []string {
r.mu.RLock()
defer r.mu.RUnlock()
items := make([]string, 0, len(r.runners))
for key := range r.runners {
items = append(items, key)
}
sort.Strings(items)
return items
}
func normalizeTaskType(value string) string {
normalized := strings.TrimSpace(strings.ToLower(value))
if normalized == "pgsql" {
return "postgresql"
}
return normalized
}

View File

@@ -0,0 +1,23 @@
package backup
import (
"context"
"testing"
)
type stubRunner struct{ taskType string }
func (r stubRunner) Type() string { return r.taskType }
func (r stubRunner) Run(context.Context, TaskSpec, LogWriter) (*RunResult, error) { return nil, nil }
func (r stubRunner) Restore(context.Context, TaskSpec, string, LogWriter) error { return nil }
func TestRegistryResolvesNormalizedType(t *testing.T) {
registry := NewRegistry(stubRunner{taskType: "postgresql"})
runner, err := registry.Runner("pgsql")
if err != nil {
t.Fatalf("Runner returned error: %v", err)
}
if runner.Type() != "postgresql" {
t.Fatalf("unexpected runner type: %s", runner.Type())
}
}

View File

@@ -0,0 +1,82 @@
package retention
import (
"context"
"fmt"
"strings"
"time"
"backupx/server/internal/model"
"backupx/server/internal/repository"
"backupx/server/internal/storage"
)
type CleanupResult struct {
DeletedRecords int
DeletedObjects int
Warnings []string
}
type Service struct {
records repository.BackupRecordRepository
now func() time.Time
}
func NewService(records repository.BackupRecordRepository) *Service {
return &Service{records: records, now: func() time.Time { return time.Now().UTC() }}
}
func (s *Service) Cleanup(ctx context.Context, task *model.BackupTask, provider storage.StorageProvider) (*CleanupResult, error) {
if task == nil {
return nil, fmt.Errorf("backup task is required")
}
records, err := s.records.ListSuccessfulByTask(ctx, task.ID)
if err != nil {
return nil, fmt.Errorf("list successful records: %w", err)
}
candidates := selectRecordsToDelete(records, task.RetentionDays, task.MaxBackups, s.now())
result := &CleanupResult{}
for _, record := range candidates {
if strings.TrimSpace(record.StoragePath) != "" {
if provider == nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("record %d missing storage provider for cleanup", record.ID))
continue
}
if err := provider.Delete(ctx, record.StoragePath); err != nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("delete storage object %s failed: %v", record.StoragePath, err))
continue
}
result.DeletedObjects++
}
if err := s.records.Delete(ctx, record.ID); err != nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("delete backup record %d failed: %v", record.ID, err))
continue
}
result.DeletedRecords++
}
return result, nil
}
func selectRecordsToDelete(records []model.BackupRecord, retentionDays int, maxBackups int, now time.Time) []model.BackupRecord {
selected := make(map[uint]model.BackupRecord)
if maxBackups > 0 && len(records) > maxBackups {
for _, record := range records[maxBackups:] {
selected[record.ID] = record
}
}
if retentionDays > 0 {
cutoff := now.AddDate(0, 0, -retentionDays)
for _, record := range records {
if record.CompletedAt != nil && record.CompletedAt.Before(cutoff) {
selected[record.ID] = record
}
}
}
result := make([]model.BackupRecord, 0, len(selected))
for _, record := range records {
if selectedRecord, ok := selected[record.ID]; ok {
result = append(result, selectedRecord)
}
}
return result
}

View File

@@ -0,0 +1,115 @@
package retention
import (
"context"
"fmt"
"io"
"testing"
"time"
"backupx/server/internal/model"
"backupx/server/internal/repository"
"backupx/server/internal/storage"
)
type fakeRecordRepository struct {
records []model.BackupRecord
deleted []uint
deleteErrs map[uint]error
}
func (r *fakeRecordRepository) List(context.Context, repository.BackupRecordListOptions) ([]model.BackupRecord, error) {
return nil, nil
}
func (r *fakeRecordRepository) FindByID(context.Context, uint) (*model.BackupRecord, error) {
return nil, nil
}
func (r *fakeRecordRepository) Create(context.Context, *model.BackupRecord) error { return nil }
func (r *fakeRecordRepository) Update(context.Context, *model.BackupRecord) error { return nil }
func (r *fakeRecordRepository) Delete(_ context.Context, id uint) error {
if err := r.deleteErrs[id]; err != nil {
return err
}
r.deleted = append(r.deleted, id)
return nil
}
func (r *fakeRecordRepository) ListRecent(context.Context, int) ([]model.BackupRecord, error) {
return nil, nil
}
func (r *fakeRecordRepository) ListSuccessfulByTask(_ context.Context, _ uint) ([]model.BackupRecord, error) {
return r.records, nil
}
func (r *fakeRecordRepository) Count(context.Context) (int64, error) { return 0, nil }
func (r *fakeRecordRepository) CountSince(context.Context, time.Time) (int64, error) { return 0, nil }
func (r *fakeRecordRepository) CountSuccessSince(context.Context, time.Time) (int64, error) {
return 0, nil
}
func (r *fakeRecordRepository) SumFileSize(context.Context) (int64, error) { return 0, nil }
func (r *fakeRecordRepository) TimelineSince(context.Context, time.Time) ([]repository.BackupTimelinePoint, error) {
return nil, nil
}
func (r *fakeRecordRepository) StorageUsage(context.Context) ([]repository.BackupStorageUsageItem, error) {
return nil, nil
}
type fakeProvider struct {
deleted []string
failKey string
}
func (p *fakeProvider) Type() string { return storage.ProviderTypeLocalDisk }
func (p *fakeProvider) TestConnection(context.Context) error { return nil }
func (p *fakeProvider) Upload(context.Context, string, io.Reader, int64, map[string]string) error {
return nil
}
func (p *fakeProvider) Download(context.Context, string) (io.ReadCloser, error) { return nil, nil }
func (p *fakeProvider) Delete(_ context.Context, objectKey string) error {
if objectKey == p.failKey {
return fmt.Errorf("delete failed")
}
p.deleted = append(p.deleted, objectKey)
return nil
}
func (p *fakeProvider) List(context.Context, string) ([]storage.ObjectInfo, error) { return nil, nil }
func TestSelectRecordsToDelete(t *testing.T) {
now := time.Date(2026, 3, 7, 16, 0, 0, 0, time.UTC)
completedNew := now.Add(-24 * time.Hour)
completedOld := now.Add(-15 * 24 * time.Hour)
records := []model.BackupRecord{
{ID: 3, CompletedAt: &completedNew},
{ID: 2, CompletedAt: &completedNew},
{ID: 1, CompletedAt: &completedOld},
}
selected := selectRecordsToDelete(records, 7, 2, now)
if len(selected) != 1 || selected[0].ID != 1 {
t.Fatalf("unexpected selected records: %#v", selected)
}
}
func TestCleanupDeletesExpiredRecords(t *testing.T) {
now := time.Date(2026, 3, 7, 16, 0, 0, 0, time.UTC)
completedNew := now.Add(-24 * time.Hour)
completedOld := now.Add(-15 * 24 * time.Hour)
repo := &fakeRecordRepository{records: []model.BackupRecord{
{ID: 3, TaskID: 1, StoragePath: "records/3", CompletedAt: &completedNew},
{ID: 2, TaskID: 1, StoragePath: "records/2", CompletedAt: &completedNew},
{ID: 1, TaskID: 1, StoragePath: "records/1", CompletedAt: &completedOld},
}}
provider := &fakeProvider{}
service := NewService(repo)
service.now = func() time.Time { return now }
result, err := service.Cleanup(context.Background(), &model.BackupTask{ID: 1, RetentionDays: 7, MaxBackups: 2}, provider)
if err != nil {
t.Fatalf("Cleanup returned error: %v", err)
}
if result.DeletedRecords != 1 || result.DeletedObjects != 1 {
t.Fatalf("unexpected cleanup result: %#v", result)
}
if len(repo.deleted) != 1 || repo.deleted[0] != 1 {
t.Fatalf("unexpected deleted records: %#v", repo.deleted)
}
if len(provider.deleted) != 1 || provider.deleted[0] != "records/1" {
t.Fatalf("unexpected deleted objects: %#v", provider.deleted)
}
}

View File

@@ -0,0 +1,74 @@
package backup
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
type SQLiteRunner struct{}
func NewSQLiteRunner() *SQLiteRunner {
return &SQLiteRunner{}
}
func (r *SQLiteRunner) Type() string {
return "sqlite"
}
func (r *SQLiteRunner) Run(_ context.Context, task TaskSpec, writer LogWriter) (*RunResult, error) {
dbPath := filepath.Clean(strings.TrimSpace(task.Database.Path))
if dbPath == "" {
return nil, fmt.Errorf("sqlite database path is required")
}
if _, err := os.Stat(dbPath); err != nil {
return nil, fmt.Errorf("stat sqlite database: %w", err)
}
tempDir, artifactPath, err := createTempArtifact(task.TempDir, task.Name, strings.TrimPrefix(filepath.Ext(dbPath), "."))
if err != nil {
return nil, err
}
if filepath.Ext(artifactPath) == "." || filepath.Ext(artifactPath) == "" {
artifactPath += ".sqlite"
}
if err := copyFile(dbPath, artifactPath); err != nil {
return nil, err
}
writer.WriteLine("SQLite 备份文件已复制")
return &RunResult{ArtifactPath: artifactPath, FileName: filepath.Base(artifactPath), TempDir: tempDir}, nil
}
func (r *SQLiteRunner) Restore(_ context.Context, task TaskSpec, artifactPath string, writer LogWriter) error {
dbPath := filepath.Clean(strings.TrimSpace(task.Database.Path))
if dbPath == "" {
return fmt.Errorf("sqlite database path is required")
}
if err := copyFile(artifactPath, dbPath); err != nil {
return err
}
writer.WriteLine("SQLite 数据库已恢复")
return nil
}
func copyFile(sourcePath string, targetPath string) error {
source, err := os.Open(sourcePath)
if err != nil {
return fmt.Errorf("open source file: %w", err)
}
defer source.Close()
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return fmt.Errorf("create target directory: %w", err)
}
target, err := os.Create(targetPath)
if err != nil {
return fmt.Errorf("create target file: %w", err)
}
defer target.Close()
if _, err := io.Copy(target, source); err != nil {
return fmt.Errorf("copy file content: %w", err)
}
return nil
}

View File

@@ -0,0 +1,34 @@
package backup
import (
"context"
"os"
"path/filepath"
"testing"
)
func TestSQLiteRunnerRunAndRestore(t *testing.T) {
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "data.db")
if err := os.WriteFile(dbPath, []byte("sqlite-data"), 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
runner := NewSQLiteRunner()
result, err := runner.Run(context.Background(), TaskSpec{Name: "sqlite backup", Type: "sqlite", Database: DatabaseSpec{Path: dbPath}}, NopLogWriter{})
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
if err := os.WriteFile(dbPath, []byte("mutated"), 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
if err := runner.Restore(context.Background(), TaskSpec{Name: "sqlite backup", Type: "sqlite", Database: DatabaseSpec{Path: dbPath}}, result.ArtifactPath, NopLogWriter{}); err != nil {
t.Fatalf("Restore returned error: %v", err)
}
content, err := os.ReadFile(dbPath)
if err != nil {
t.Fatalf("ReadFile returned error: %v", err)
}
if string(content) != "sqlite-data" {
t.Fatalf("unexpected restored content: %s", string(content))
}
}

View File

@@ -0,0 +1,64 @@
package backup
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
var fileNameCleaner = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
func EnsureTempRoot() (string, error) {
root := filepath.Join(os.TempDir(), "backupx")
if err := os.MkdirAll(root, 0o755); err != nil {
return "", fmt.Errorf("create backup temp root: %w", err)
}
return root, nil
}
func CreateTaskTempDir(taskName string, startedAt time.Time) (string, error) {
root, err := EnsureTempRoot()
if err != nil {
return "", err
}
name := sanitizeTaskName(taskName)
if name == "" {
name = "backup"
}
path := filepath.Join(root, fmt.Sprintf("%s_%s", name, startedAt.UTC().Format("20060102_150405")))
if err := os.MkdirAll(path, 0o755); err != nil {
return "", fmt.Errorf("create task temp dir: %w", err)
}
return path, nil
}
func BuildArtifactName(taskName string, startedAt time.Time, extension string) string {
name := sanitizeTaskName(taskName)
if name == "" {
name = "backup"
}
ext := strings.TrimSpace(extension)
if ext != "" && !strings.HasPrefix(ext, ".") {
ext = "." + ext
}
return fmt.Sprintf("%s_%s%s", name, startedAt.UTC().Format("20060102_150405"), ext)
}
func BuildStorageKey(backupType string, startedAt time.Time, fileName string) string {
typeName := strings.TrimSpace(strings.ToLower(backupType))
if typeName == "" {
typeName = "file"
}
return filepath.ToSlash(filepath.Join("BackupX", typeName, startedAt.UTC().Format("060102"), fileName))
}
func sanitizeTaskName(value string) string {
trimmed := strings.TrimSpace(strings.ToLower(value))
trimmed = strings.ReplaceAll(trimmed, " ", "-")
trimmed = fileNameCleaner.ReplaceAllString(trimmed, "-")
trimmed = strings.Trim(trimmed, "-._")
return trimmed
}

View File

@@ -0,0 +1,73 @@
package backup
import (
"context"
"time"
)
type DatabaseSpec struct {
Host string
Port int
User string
Password string
Names []string
Path string
}
type TaskSpec struct {
ID uint
Name string
Type string
SourcePath string
ExcludePatterns []string
Database DatabaseSpec
StorageTargetID uint
StorageTargetType string
Compression string
Encrypt bool
RetentionDays int
MaxBackups int
StartedAt time.Time
TempDir string
}
type RunResult struct {
ArtifactPath string
FileName string
TempDir string
Size int64
StorageKey string
}
type LogEvent struct {
RecordID uint `json:"recordId"`
Sequence int64 `json:"sequence"`
Level string `json:"level"`
Message string `json:"message"`
Timestamp time.Time `json:"timestamp"`
Completed bool `json:"completed"`
Status string `json:"status"`
}
type LogWriter interface {
WriteLine(message string)
}
type LogSink interface {
Infof(format string, args ...any)
Warnf(format string, args ...any)
Errorf(format string, args ...any)
}
type NopLogWriter struct{}
func (NopLogWriter) WriteLine(string) {}
func (NopLogWriter) Infof(string, ...any) {}
func (NopLogWriter) Warnf(string, ...any) {}
func (NopLogWriter) Errorf(string, ...any) {}
type BackupRunner interface {
Type() string
Run(ctx context.Context, task TaskSpec, writer LogWriter) (*RunResult, error)
Restore(ctx context.Context, task TaskSpec, artifactPath string, writer LogWriter) error
}

View File

@@ -0,0 +1,143 @@
package config
import (
"fmt"
"strings"
"time"
"github.com/spf13/viper"
)
type Config struct {
Server ServerConfig `mapstructure:"server"`
Database DatabaseConfig `mapstructure:"database"`
Security SecurityConfig `mapstructure:"security"`
Backup BackupConfig `mapstructure:"backup"`
Log LogConfig `mapstructure:"log"`
}
type ServerConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Mode string `mapstructure:"mode"`
}
type DatabaseConfig struct {
Path string `mapstructure:"path"`
}
type SecurityConfig struct {
JWTSecret string `mapstructure:"jwt_secret"`
JWTExpire string `mapstructure:"jwt_expire"`
EncryptionKey string `mapstructure:"encryption_key"`
}
type BackupConfig struct {
TempDir string `mapstructure:"temp_dir"`
MaxConcurrent int `mapstructure:"max_concurrent"`
}
type LogConfig struct {
Level string `mapstructure:"level"`
File string `mapstructure:"file"`
MaxSize int `mapstructure:"max_size"`
MaxBackups int `mapstructure:"max_backups"`
MaxAge int `mapstructure:"max_age"`
}
func Load(configPath string) (Config, error) {
v := viper.New()
applyDefaults(v)
v.SetConfigType("yaml")
v.SetEnvPrefix("BACKUPX")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
if configPath != "" {
v.SetConfigFile(configPath)
if err := v.ReadInConfig(); err != nil {
return Config{}, fmt.Errorf("read config: %w", err)
}
} else {
v.SetConfigName("config")
v.AddConfigPath(".")
v.AddConfigPath("./server")
v.AddConfigPath("/etc/backupx")
if err := v.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
return Config{}, fmt.Errorf("read config: %w", err)
}
}
}
var cfg Config
if err := v.Unmarshal(&cfg); err != nil {
return Config{}, fmt.Errorf("decode config: %w", err)
}
if cfg.Server.Host == "" {
cfg.Server.Host = "0.0.0.0"
}
if cfg.Server.Port == 0 {
cfg.Server.Port = 8340
}
if cfg.Server.Mode == "" {
cfg.Server.Mode = "release"
}
if cfg.Database.Path == "" {
cfg.Database.Path = "./data/backupx.db"
}
if cfg.Security.JWTExpire == "" {
cfg.Security.JWTExpire = "24h"
}
if cfg.Backup.TempDir == "" {
cfg.Backup.TempDir = "/tmp/backupx"
}
if cfg.Backup.MaxConcurrent <= 0 {
cfg.Backup.MaxConcurrent = 2
}
if cfg.Log.Level == "" {
cfg.Log.Level = "info"
}
if cfg.Log.File == "" {
cfg.Log.File = "./data/backupx.log"
}
if cfg.Log.MaxSize <= 0 {
cfg.Log.MaxSize = 100
}
if cfg.Log.MaxBackups <= 0 {
cfg.Log.MaxBackups = 3
}
if cfg.Log.MaxAge <= 0 {
cfg.Log.MaxAge = 30
}
return cfg, nil
}
func MustJWTDuration(cfg SecurityConfig) time.Duration {
duration, err := time.ParseDuration(cfg.JWTExpire)
if err != nil {
return 24 * time.Hour
}
return duration
}
func (c Config) Address() string {
return fmt.Sprintf("%s:%d", c.Server.Host, c.Server.Port)
}
func applyDefaults(v *viper.Viper) {
v.SetDefault("server.host", "0.0.0.0")
v.SetDefault("server.port", 8340)
v.SetDefault("server.mode", "release")
v.SetDefault("database.path", "./data/backupx.db")
v.SetDefault("security.jwt_expire", "24h")
v.SetDefault("backup.temp_dir", "/tmp/backupx")
v.SetDefault("backup.max_concurrent", 2)
v.SetDefault("log.level", "info")
v.SetDefault("log.file", "./data/backupx.log")
v.SetDefault("log.max_size", 100)
v.SetDefault("log.max_backups", 3)
v.SetDefault("log.max_age", 30)
}

View File

@@ -0,0 +1,20 @@
package config
import "testing"
func TestLoadUsesDefaultsWithoutConfigFile(t *testing.T) {
cfg, err := Load("")
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if cfg.Server.Host != "0.0.0.0" {
t.Fatalf("expected default host, got %s", cfg.Server.Host)
}
if cfg.Server.Port != 8340 {
t.Fatalf("expected default port, got %d", cfg.Server.Port)
}
if cfg.Database.Path != "./data/backupx.db" {
t.Fatalf("expected default database path, got %s", cfg.Database.Path)
}
}

View File

@@ -0,0 +1,32 @@
package database
import (
"fmt"
"os"
"path/filepath"
"backupx/server/internal/config"
"backupx/server/internal/model"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
func Open(cfg config.DatabaseConfig, logger *zap.Logger) (*gorm.DB, error) {
if err := os.MkdirAll(filepath.Dir(cfg.Path), 0o755); err != nil {
return nil, fmt.Errorf("create database dir: %w", err)
}
db, err := gorm.Open(sqlite.Open(cfg.Path), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
if err != nil {
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 {
return nil, fmt.Errorf("migrate schema: %w", err)
}
logger.Info("database initialized", zap.String("path", cfg.Path))
return db, nil
}

View File

@@ -0,0 +1,91 @@
package http
import (
"backupx/server/internal/apperror"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
type AuthHandler struct {
authService *service.AuthService
}
func NewAuthHandler(authService *service.AuthService) *AuthHandler {
return &AuthHandler{authService: authService}
}
func (h *AuthHandler) SetupStatus(c *gin.Context) {
initialized, err := h.authService.SetupStatus(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"initialized": initialized})
}
func (h *AuthHandler) Setup(c *gin.Context) {
var input service.SetupInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_SETUP_INVALID", "初始化参数不合法", err))
return
}
payload, err := h.authService.Setup(c.Request.Context(), input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, payload)
}
func (h *AuthHandler) Login(c *gin.Context) {
var input service.LoginInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_LOGIN_INVALID", "登录参数不合法", err))
return
}
payload, err := h.authService.Login(c.Request.Context(), input, ClientKey(c))
if err != nil {
response.Error(c, err)
return
}
response.Success(c, payload)
}
func (h *AuthHandler) Profile(c *gin.Context) {
subjectValue, _ := c.Get(contextUserSubjectKey)
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
return
}
user, err := h.authService.GetCurrentUser(c.Request.Context(), subject)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, user)
}
func (h *AuthHandler) ChangePassword(c *gin.Context) {
subjectValue, _ := c.Get(contextUserSubjectKey)
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
return
}
var input service.ChangePasswordInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_PASSWORD_INVALID", "参数不合法", err))
return
}
if err := h.authService.ChangePassword(c.Request.Context(), subject, input); err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"changed": true})
}
func (h *AuthHandler) Logout(c *gin.Context) {
response.Success(c, gin.H{"loggedOut": true})
}

View File

@@ -0,0 +1,189 @@
package http
import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"backupx/server/internal/apperror"
"backupx/server/internal/backup"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
type BackupRecordHandler struct {
service *service.BackupRecordService
}
func NewBackupRecordHandler(recordService *service.BackupRecordService) *BackupRecordHandler {
return &BackupRecordHandler{service: recordService}
}
func (h *BackupRecordHandler) List(c *gin.Context) {
filter, err := buildRecordFilter(c)
if err != nil {
response.Error(c, err)
return
}
items, err := h.service.List(c.Request.Context(), filter)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, items)
}
func (h *BackupRecordHandler) Get(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
item, err := h.service.Get(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
func (h *BackupRecordHandler) StreamLogs(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
detail, err := h.service.Get(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
events := detail.LogEvents
completed := detail.Status != "running"
channel, cancel, err := h.service.SubscribeLogs(c.Request.Context(), id, 64)
if err != nil {
response.Error(c, err)
return
}
defer cancel()
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
flusher, ok := c.Writer.(interface{ Flush() })
if !ok {
response.Error(c, apperror.Internal("BACKUP_RECORD_STREAM_UNSUPPORTED", "当前连接不支持日志流", nil))
return
}
for _, event := range events {
if err := writeSSEEvent(c.Writer, event); err != nil {
return
}
flusher.Flush()
}
if completed {
return
}
for {
select {
case <-c.Request.Context().Done():
return
case event, ok := <-channel:
if !ok {
return
}
if err := writeSSEEvent(c.Writer, event); err != nil {
return
}
flusher.Flush()
if event.Completed {
return
}
}
}
}
func (h *BackupRecordHandler) Download(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
result, err := h.service.Download(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
defer result.Reader.Close()
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", result.FileName))
c.Header("Content-Type", "application/octet-stream")
_, _ = io.Copy(c.Writer, result.Reader)
}
func (h *BackupRecordHandler) Restore(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
if err := h.service.Restore(c.Request.Context(), id); err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"restored": true})
}
func (h *BackupRecordHandler) Delete(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
if err := h.service.Delete(c.Request.Context(), id); err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"deleted": true})
}
func buildRecordFilter(c *gin.Context) (service.BackupRecordListInput, error) {
var filter service.BackupRecordListInput
if taskIDValue := strings.TrimSpace(c.Query("taskId")); taskIDValue != "" {
parsed, ok := parseUintString(taskIDValue)
if !ok {
return filter, apperror.BadRequest("BACKUP_RECORD_FILTER_INVALID", "taskId 不合法", nil)
}
filter.TaskID = &parsed
}
filter.Status = strings.TrimSpace(c.Query("status"))
if dateFrom := strings.TrimSpace(c.Query("dateFrom")); dateFrom != "" {
parsed, err := time.Parse(time.RFC3339, dateFrom)
if err != nil {
return filter, apperror.BadRequest("BACKUP_RECORD_FILTER_INVALID", "dateFrom 必须为 RFC3339 时间格式", err)
}
filter.DateFrom = &parsed
}
if dateTo := strings.TrimSpace(c.Query("dateTo")); dateTo != "" {
parsed, err := time.Parse(time.RFC3339, dateTo)
if err != nil {
return filter, apperror.BadRequest("BACKUP_RECORD_FILTER_INVALID", "dateTo 必须为 RFC3339 时间格式", err)
}
filter.DateTo = &parsed
}
return filter, nil
}
func writeSSEEvent(writer io.Writer, event backup.LogEvent) error {
payload, err := json.Marshal(event)
if err != nil {
return err
}
_, err = fmt.Fprintf(writer, "event: log\ndata: %s\n\n", payload)
return err
}
func parseUintString(value string) (uint, bool) {
parsed, err := strconv.ParseUint(strings.TrimSpace(value), 10, 64)
if err != nil {
return 0, false
}
return uint(parsed), true
}

View File

@@ -0,0 +1,28 @@
package http
import (
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
type BackupRunHandler struct {
service *service.BackupExecutionService
}
func NewBackupRunHandler(executionService *service.BackupExecutionService) *BackupRunHandler {
return &BackupRunHandler{service: executionService}
}
func (h *BackupRunHandler) Run(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
record, err := h.service.RunTaskByID(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, record)
}

View File

@@ -0,0 +1,109 @@
package http
import (
"backupx/server/internal/apperror"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
type BackupTaskHandler struct {
service *service.BackupTaskService
}
func NewBackupTaskHandler(taskService *service.BackupTaskService) *BackupTaskHandler {
return &BackupTaskHandler{service: taskService}
}
func (h *BackupTaskHandler) List(c *gin.Context) {
items, err := h.service.List(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, items)
}
func (h *BackupTaskHandler) Get(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
item, err := h.service.Get(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
func (h *BackupTaskHandler) Create(c *gin.Context) {
var input service.BackupTaskUpsertInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("BACKUP_TASK_INVALID", "备份任务参数不合法", err))
return
}
item, err := h.service.Create(c.Request.Context(), input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
func (h *BackupTaskHandler) Update(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
var input service.BackupTaskUpsertInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("BACKUP_TASK_INVALID", "备份任务参数不合法", err))
return
}
item, err := h.service.Update(c.Request.Context(), id, input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
func (h *BackupTaskHandler) Delete(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
if err := h.service.Delete(c.Request.Context(), id); err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"deleted": true})
}
func (h *BackupTaskHandler) Toggle(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
var input service.BackupTaskToggleInput
if err := c.ShouldBindJSON(&input); err != nil && err.Error() != "EOF" {
response.Error(c, apperror.BadRequest("BACKUP_TASK_TOGGLE_INVALID", "备份任务启停参数不合法", err))
return
}
current, err := h.service.Get(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
enabled := !current.Enabled
if input.Enabled != nil {
enabled = *input.Enabled
}
item, err := h.service.Toggle(c.Request.Context(), id, enabled)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}

View File

@@ -0,0 +1,3 @@
package http
const contextUserSubjectKey = "userSubject"

View File

@@ -0,0 +1,46 @@
package http
import (
"strconv"
"strings"
"backupx/server/internal/apperror"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
type DashboardHandler struct {
service *service.DashboardService
}
func NewDashboardHandler(dashboardService *service.DashboardService) *DashboardHandler {
return &DashboardHandler{service: dashboardService}
}
func (h *DashboardHandler) Stats(c *gin.Context) {
payload, err := h.service.Stats(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, payload)
}
func (h *DashboardHandler) Timeline(c *gin.Context) {
days := 30
if value := strings.TrimSpace(c.Query("days")); value != "" {
parsed, err := strconv.Atoi(value)
if err != nil {
response.Error(c, apperror.BadRequest("DASHBOARD_TIMELINE_INVALID", "days 必须为整数", err))
return
}
days = parsed
}
payload, err := h.service.Timeline(c.Request.Context(), days)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, payload)
}

View File

@@ -0,0 +1,57 @@
package http
import (
stdhttp "net/http"
"strings"
"backupx/server/internal/apperror"
"backupx/server/internal/security"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
// CORSMiddleware handles Cross-Origin Resource Sharing for the API.
func CORSMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization")
c.Header("Access-Control-Max-Age", "86400")
if c.Request.Method == stdhttp.MethodOptions {
c.AbortWithStatus(stdhttp.StatusNoContent)
return
}
c.Next()
}
}
func AuthMiddleware(jwtManager *security.JWTManager) gin.HandlerFunc {
return func(c *gin.Context) {
header := strings.TrimSpace(c.GetHeader("Authorization"))
if !strings.HasPrefix(header, "Bearer ") {
response.Error(c, apperror.Unauthorized("AUTH_REQUIRED", "请先登录", nil))
c.Abort()
return
}
tokenString := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
claims, err := jwtManager.Parse(tokenString)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_TOKEN", "登录状态已失效,请重新登录", err))
c.Abort()
return
}
c.Set(contextUserSubjectKey, claims.Subject)
c.Next()
}
}
func ClientKey(c *gin.Context) string {
ip := strings.TrimSpace(c.ClientIP())
if ip == "" {
return "unknown"
}
return ip
}

View File

@@ -0,0 +1,101 @@
package http
import (
stdhttp "net/http"
"strconv"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
type NodeHandler struct {
service *service.NodeService
}
func NewNodeHandler(service *service.NodeService) *NodeHandler {
return &NodeHandler{service: service}
}
func (h *NodeHandler) List(c *gin.Context) {
items, err := h.service.List(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, items)
}
func (h *NodeHandler) Get(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
response.Error(c, err)
return
}
item, err := h.service.Get(c.Request.Context(), uint(id))
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
func (h *NodeHandler) Create(c *gin.Context) {
var input service.NodeCreateInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(stdhttp.StatusBadRequest, gin.H{"code": "INVALID_INPUT", "message": err.Error()})
return
}
token, err := h.service.Create(c.Request.Context(), input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"token": token})
}
func (h *NodeHandler) Delete(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
response.Error(c, err)
return
}
if err := h.service.Delete(c.Request.Context(), uint(id)); err != nil {
response.Error(c, err)
return
}
response.Success(c, nil)
}
func (h *NodeHandler) ListDirectory(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
response.Error(c, err)
return
}
path := c.DefaultQuery("path", "/")
entries, err := h.service.ListDirectory(c.Request.Context(), uint(id), path)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, entries)
}
func (h *NodeHandler) Heartbeat(c *gin.Context) {
var input struct {
Token string `json:"token" binding:"required"`
Hostname string `json:"hostname"`
IPAddress string `json:"ipAddress"`
AgentVersion string `json:"agentVersion"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(stdhttp.StatusBadRequest, gin.H{"code": "INVALID_INPUT", "message": err.Error()})
return
}
if err := h.service.Heartbeat(c.Request.Context(), input.Token, input.Hostname, input.IPAddress, input.AgentVersion); err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"status": "ok"})
}

View File

@@ -0,0 +1,107 @@
package http
import (
"backupx/server/internal/apperror"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
type NotificationHandler struct {
service *service.NotificationService
}
func NewNotificationHandler(notificationService *service.NotificationService) *NotificationHandler {
return &NotificationHandler{service: notificationService}
}
func (h *NotificationHandler) List(c *gin.Context) {
items, err := h.service.List(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, items)
}
func (h *NotificationHandler) Get(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
item, err := h.service.Get(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
func (h *NotificationHandler) Create(c *gin.Context) {
var input service.NotificationUpsertInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("NOTIFICATION_INVALID", "通知配置参数不合法", err))
return
}
item, err := h.service.Create(c.Request.Context(), input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
func (h *NotificationHandler) Update(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
var input service.NotificationUpsertInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("NOTIFICATION_INVALID", "通知配置参数不合法", err))
return
}
item, err := h.service.Update(c.Request.Context(), id, input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
func (h *NotificationHandler) Delete(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
if err := h.service.Delete(c.Request.Context(), id); err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"deleted": true})
}
func (h *NotificationHandler) Test(c *gin.Context) {
var input service.NotificationUpsertInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("NOTIFICATION_INVALID", "通知配置参数不合法", err))
return
}
if err := h.service.Test(c.Request.Context(), input); err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"success": true})
}
func (h *NotificationHandler) TestSaved(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
if err := h.service.TestSaved(c.Request.Context(), id); err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"success": true})
}

View File

@@ -0,0 +1,152 @@
package http
import (
"errors"
stdhttp "net/http"
"backupx/server/internal/apperror"
"backupx/server/internal/config"
"backupx/server/internal/repository"
"backupx/server/internal/security"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
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
}
func NewRouter(deps RouterDependencies) *gin.Engine {
gin.SetMode(deps.Config.Server.Mode)
engine := gin.New()
engine.Use(gin.Recovery())
engine.Use(CORSMiddleware())
engine.Use(requestLogger(deps.Logger))
authHandler := NewAuthHandler(deps.AuthService)
systemHandler := NewSystemHandler(deps.SystemService)
storageTargetHandler := NewStorageTargetHandler(deps.StorageTargetService)
backupTaskHandler := NewBackupTaskHandler(deps.BackupTaskService)
backupRunHandler := NewBackupRunHandler(deps.BackupExecutionService)
backupRecordHandler := NewBackupRecordHandler(deps.BackupRecordService)
notificationHandler := NewNotificationHandler(deps.NotificationService)
dashboardHandler := NewDashboardHandler(deps.DashboardService)
settingsHandler := NewSettingsHandler(deps.SettingsService)
api := engine.Group("/api")
{
auth := api.Group("/auth")
{
auth.GET("/setup/status", authHandler.SetupStatus)
auth.POST("/setup", authHandler.Setup)
auth.POST("/login", authHandler.Login)
auth.POST("/logout", AuthMiddleware(deps.JWTManager), authHandler.Logout)
auth.GET("/profile", AuthMiddleware(deps.JWTManager), authHandler.Profile)
auth.PUT("/password", AuthMiddleware(deps.JWTManager), authHandler.ChangePassword)
}
system := api.Group("/system")
system.Use(AuthMiddleware(deps.JWTManager))
system.GET("/info", systemHandler.Info)
storageTargets := api.Group("/storage-targets")
storageTargets.Use(AuthMiddleware(deps.JWTManager))
storageTargets.GET("", storageTargetHandler.List)
storageTargets.GET("/:id", storageTargetHandler.Get)
storageTargets.POST("", storageTargetHandler.Create)
storageTargets.PUT("/:id", storageTargetHandler.Update)
storageTargets.DELETE("/:id", storageTargetHandler.Delete)
storageTargets.POST("/test", storageTargetHandler.TestConnection)
storageTargets.POST("/:id/test", storageTargetHandler.TestSavedConnection)
storageTargets.GET("/:id/usage", storageTargetHandler.GetUsage)
storageTargets.POST("/google-drive/auth-url", storageTargetHandler.StartGoogleDriveOAuth)
storageTargets.POST("/google-drive/complete", storageTargetHandler.CompleteGoogleDriveOAuth)
storageTargets.GET("/google-drive/callback", storageTargetHandler.HandleGoogleDriveCallback)
storageTargets.GET("/:id/google-drive/profile", storageTargetHandler.GoogleDriveProfile)
backupTasks := api.Group("/backup/tasks")
backupTasks.Use(AuthMiddleware(deps.JWTManager))
backupTasks.GET("", backupTaskHandler.List)
backupTasks.GET("/:id", backupTaskHandler.Get)
backupTasks.POST("", backupTaskHandler.Create)
backupTasks.PUT("/:id", backupTaskHandler.Update)
backupTasks.DELETE("/:id", backupTaskHandler.Delete)
backupTasks.PUT("/:id/toggle", backupTaskHandler.Toggle)
backupTasks.POST("/:id/run", backupRunHandler.Run)
backupRecords := api.Group("/backup/records")
backupRecords.Use(AuthMiddleware(deps.JWTManager))
backupRecords.GET("", backupRecordHandler.List)
backupRecords.GET("/:id", backupRecordHandler.Get)
backupRecords.GET("/:id/logs/stream", backupRecordHandler.StreamLogs)
backupRecords.GET("/:id/download", backupRecordHandler.Download)
backupRecords.POST("/:id/restore", backupRecordHandler.Restore)
backupRecords.DELETE("/:id", backupRecordHandler.Delete)
dashboard := api.Group("/dashboard")
dashboard.Use(AuthMiddleware(deps.JWTManager))
dashboard.GET("/stats", dashboardHandler.Stats)
dashboard.GET("/timeline", dashboardHandler.Timeline)
notifications := api.Group("/notifications")
notifications.Use(AuthMiddleware(deps.JWTManager))
notifications.GET("", notificationHandler.List)
notifications.GET("/:id", notificationHandler.Get)
notifications.POST("", notificationHandler.Create)
notifications.PUT("/:id", notificationHandler.Update)
notifications.DELETE("/:id", notificationHandler.Delete)
notifications.POST("/test", notificationHandler.Test)
notifications.POST("/:id/test", notificationHandler.TestSaved)
settings := api.Group("/settings")
settings.Use(AuthMiddleware(deps.JWTManager))
settings.GET("", settingsHandler.Get)
settings.PUT("", settingsHandler.Update)
nodeHandler := NewNodeHandler(deps.NodeService)
nodes := api.Group("/nodes")
nodes.Use(AuthMiddleware(deps.JWTManager))
nodes.GET("", nodeHandler.List)
nodes.GET("/:id", nodeHandler.Get)
nodes.POST("", nodeHandler.Create)
nodes.DELETE("/:id", nodeHandler.Delete)
nodes.GET("/:id/fs/list", nodeHandler.ListDirectory)
// Agent heartbeat (public, token-authenticated)
api.POST("/agent/heartbeat", nodeHandler.Heartbeat)
}
engine.NoRoute(func(c *gin.Context) {
response.Error(c, apperror.New(stdhttp.StatusNotFound, "NOT_FOUND", "接口不存在", errors.New("route not found")))
})
return engine
}
func requestLogger(logger *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
logger.Info("http request",
zap.String("method", c.Request.Method),
zap.String("path", c.Request.URL.Path),
zap.Int("status", c.Writer.Status()),
zap.String("client_ip", c.ClientIP()),
)
}
}

View File

@@ -0,0 +1,94 @@
package http
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"backupx/server/internal/config"
"backupx/server/internal/database"
"backupx/server/internal/logger"
"backupx/server/internal/repository"
"backupx/server/internal/security"
"backupx/server/internal/service"
)
func TestSetupLoginAndProfileFlow(t *testing.T) {
tempDir := t.TempDir()
cfg := config.Config{
Server: config.ServerConfig{Host: "127.0.0.1", Port: 8340, Mode: "test"},
Database: config.DatabaseConfig{Path: filepath.Join(tempDir, "backupx.db")},
Security: config.SecurityConfig{JWTExpire: "24h"},
Log: config.LogConfig{Level: "error"},
}
log, err := logger.New(cfg.Log)
if err != nil {
t.Fatalf("logger.New error: %v", err)
}
db, err := database.Open(cfg.Database, log)
if err != nil {
t.Fatalf("database.Open error: %v", err)
}
userRepo := repository.NewUserRepository(db)
systemConfigRepo := repository.NewSystemConfigRepository(db)
resolved, err := service.ResolveSecurity(context.Background(), cfg.Security, systemConfigRepo)
if err != nil {
t.Fatalf("ResolveSecurity error: %v", err)
}
jwtManager := security.NewJWTManager(resolved.JWTSecret, time.Hour)
authService := service.NewAuthService(userRepo, systemConfigRepo, jwtManager, security.NewLoginRateLimiter(5, time.Minute))
systemService := service.NewSystemService(cfg, "test", time.Now().UTC())
router := NewRouter(RouterDependencies{
Config: cfg,
Version: "test",
Logger: log,
AuthService: authService,
SystemService: systemService,
JWTManager: jwtManager,
UserRepository: userRepo,
SystemConfigRepo: systemConfigRepo,
})
setupBody, _ := json.Marshal(map[string]string{
"username": "admin",
"password": "password-123",
"displayName": "Admin",
})
setupRequest := httptest.NewRequest(http.MethodPost, "/api/auth/setup", bytes.NewBuffer(setupBody))
setupRequest.Header.Set("Content-Type", "application/json")
setupRecorder := httptest.NewRecorder()
router.ServeHTTP(setupRecorder, setupRequest)
if setupRecorder.Code != http.StatusOK {
t.Fatalf("expected setup 200, got %d", setupRecorder.Code)
}
var setupResponse struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}
if err := json.Unmarshal(setupRecorder.Body.Bytes(), &setupResponse); err != nil {
t.Fatalf("unmarshal setup response: %v", err)
}
if setupResponse.Data.Token == "" {
t.Fatalf("expected token in setup response")
}
profileRequest := httptest.NewRequest(http.MethodGet, "/api/auth/profile", nil)
profileRequest.Header.Set("Authorization", "Bearer "+setupResponse.Data.Token)
profileRecorder := httptest.NewRecorder()
router.ServeHTTP(profileRecorder, profileRequest)
if profileRecorder.Code != http.StatusOK {
t.Fatalf("expected profile 200, got %d", profileRecorder.Code)
}
}

View File

@@ -0,0 +1,39 @@
package http
import (
"backupx/server/internal/apperror"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
type SettingsHandler struct {
settingsService *service.SettingsService
}
func NewSettingsHandler(settingsService *service.SettingsService) *SettingsHandler {
return &SettingsHandler{settingsService: settingsService}
}
func (h *SettingsHandler) Get(c *gin.Context) {
settings, err := h.settingsService.GetAll(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, settings)
}
func (h *SettingsHandler) Update(c *gin.Context) {
var input map[string]string
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("SETTINGS_INVALID", "设置参数不合法", err))
return
}
settings, err := h.settingsService.Update(c.Request.Context(), input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, settings)
}

View File

@@ -0,0 +1,244 @@
package http
import (
"fmt"
"strconv"
"strings"
"backupx/server/internal/apperror"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
type StorageTargetHandler struct {
service *service.StorageTargetService
}
type storageTargetGoogleDriveAuthRequest struct {
TargetID *uint `json:"targetId"`
Name string `json:"name"`
Type string `json:"type"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
Config map[string]any `json:"config"`
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
FolderID string `json:"folderId"`
}
func NewStorageTargetHandler(service *service.StorageTargetService) *StorageTargetHandler {
return &StorageTargetHandler{service: service}
}
func (h *StorageTargetHandler) List(c *gin.Context) {
items, err := h.service.List(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, items)
}
func (h *StorageTargetHandler) Get(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
item, err := h.service.Get(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
func (h *StorageTargetHandler) Create(c *gin.Context) {
var input service.StorageTargetUpsertInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("STORAGE_TARGET_INVALID", "存储目标参数不合法", err))
return
}
item, err := h.service.Create(c.Request.Context(), input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
func (h *StorageTargetHandler) Update(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
var input service.StorageTargetUpsertInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("STORAGE_TARGET_INVALID", "存储目标参数不合法", err))
return
}
item, err := h.service.Update(c.Request.Context(), id, input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
func (h *StorageTargetHandler) Delete(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
if err := h.service.Delete(c.Request.Context(), id); err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"deleted": true})
}
func (h *StorageTargetHandler) TestConnection(c *gin.Context) {
var payload service.StorageTargetUpsertInput
if err := c.ShouldBindJSON(&payload); err != nil {
response.Error(c, apperror.BadRequest("STORAGE_TARGET_TEST_INVALID", "测试连接参数不合法", err))
return
}
if err := h.service.TestConnection(c.Request.Context(), service.StorageTargetTestInput{Payload: payload}); err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"success": true, "message": "连接成功"})
}
func (h *StorageTargetHandler) TestSavedConnection(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
if err := h.service.TestConnection(c.Request.Context(), service.StorageTargetTestInput{TargetID: &id}); err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"success": true, "message": "连接成功"})
}
func (h *StorageTargetHandler) StartGoogleDriveOAuth(c *gin.Context) {
var request storageTargetGoogleDriveAuthRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.Error(c, apperror.BadRequest("STORAGE_GOOGLE_OAUTH_INVALID", "Google Drive 授权参数不合法", err))
return
}
input := service.GoogleDriveAuthStartInput{
TargetID: request.TargetID,
Name: strings.TrimSpace(request.Name),
Description: strings.TrimSpace(request.Description),
Enabled: request.Enabled,
ClientID: firstNonEmpty(asString(request.Config["clientId"]), request.ClientID),
ClientSecret: firstNonEmpty(asString(request.Config["clientSecret"]), request.ClientSecret),
FolderID: firstNonEmpty(asString(request.Config["folderId"]), request.FolderID),
}
result, err := h.service.StartGoogleDriveOAuth(c.Request.Context(), input, requestOrigin(c))
if err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"authUrl": result.AuthorizationURL})
}
func (h *StorageTargetHandler) CompleteGoogleDriveOAuth(c *gin.Context) {
var input service.GoogleDriveAuthCompleteInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("STORAGE_GOOGLE_OAUTH_INVALID", "Google Drive 回调参数不合法", err))
return
}
item, err := h.service.CompleteGoogleDriveOAuth(c.Request.Context(), input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
func (h *StorageTargetHandler) HandleGoogleDriveCallback(c *gin.Context) {
if queryError := strings.TrimSpace(c.Query("error")); queryError != "" {
response.Success(c, gin.H{"success": false, "message": queryError})
return
}
input := service.GoogleDriveAuthCompleteInput{State: strings.TrimSpace(c.Query("state")), Code: strings.TrimSpace(c.Query("code"))}
if input.State == "" || input.Code == "" {
response.Error(c, apperror.BadRequest("STORAGE_GOOGLE_OAUTH_INVALID", "Google Drive 回调参数不合法", nil))
return
}
item, err := h.service.CompleteGoogleDriveOAuth(c.Request.Context(), input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"success": true, "message": "Google Drive 授权成功", "target": item})
}
func (h *StorageTargetHandler) GoogleDriveProfile(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
profile, err := h.service.GoogleDriveProfile(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, profile)
}
func parseUintParam(c *gin.Context, key string) (uint, bool) {
value := strings.TrimSpace(c.Param(key))
parsed, err := strconv.ParseUint(value, 10, 64)
if err != nil {
response.Error(c, apperror.BadRequest("INVALID_ID", fmt.Sprintf("参数 %s 不合法", key), err))
return 0, false
}
return uint(parsed), true
}
func requestOrigin(c *gin.Context) string {
origin := strings.TrimSpace(c.GetHeader("Origin"))
if origin != "" {
return origin
}
scheme := strings.TrimSpace(c.GetHeader("X-Forwarded-Proto"))
if scheme == "" {
if c.Request.TLS != nil {
scheme = "https"
} else {
scheme = "http"
}
}
return fmt.Sprintf("%s://%s", scheme, c.Request.Host)
}
func asString(value any) string {
text, _ := value.(string)
return strings.TrimSpace(text)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func (h *StorageTargetHandler) GetUsage(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
usage, err := h.service.GetUsage(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, usage)
}

View File

@@ -0,0 +1,19 @@
package http
import (
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
type SystemHandler struct {
systemService *service.SystemService
}
func NewSystemHandler(systemService *service.SystemService) *SystemHandler {
return &SystemHandler{systemService: systemService}
}
func (h *SystemHandler) Info(c *gin.Context) {
response.Success(c, h.systemService.GetInfo(c.Request.Context()))
}

View File

@@ -0,0 +1,98 @@
//go:build ignore
package httpapi
import (
"net/http"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
type authHandler struct {
service *service.AuthService
logger *zap.Logger
}
type setupRequest struct {
Username string `json:"username" binding:"required,min=3,max=64"`
Password string `json:"password" binding:"required,min=8,max=128"`
DisplayName string `json:"displayName" binding:"required,min=1,max=128"`
}
type loginRequest struct {
Username string `json:"username" binding:"required,min=3,max=64"`
Password string `json:"password" binding:"required,min=8,max=128"`
}
func newAuthHandler(service *service.AuthService, logger *zap.Logger) *authHandler {
return &authHandler{service: service, logger: logger}
}
func (h *authHandler) registerRoutes(router gin.IRouter, protected gin.IRouter) {
router.GET("/auth/setup/status", h.getSetupStatus)
router.POST("/auth/setup", h.setup)
router.POST("/auth/login", h.login)
protected.GET("/auth/profile", h.profile)
}
func (h *authHandler) getSetupStatus(c *gin.Context) {
initialized, err := h.service.GetSetupStatus(c.Request.Context())
if err != nil {
writeError(c, h.logger, err)
return
}
response.Success(c, gin.H{"initialized": initialized})
}
func (h *authHandler) setup(c *gin.Context) {
payload, err := bindJSON[setupRequest](c, h.logger)
if err != nil {
writeError(c, h.logger, err)
return
}
result, err := h.service.Setup(c.Request.Context(), service.SetupInput{
Username: payload.Username,
Password: payload.Password,
DisplayName: payload.DisplayName,
})
if err != nil {
writeError(c, h.logger, err)
return
}
c.JSON(http.StatusCreated, response.Envelope{Code: "OK", Message: "success", Data: result})
}
func (h *authHandler) login(c *gin.Context) {
payload, err := bindJSON[loginRequest](c, h.logger)
if err != nil {
writeError(c, h.logger, err)
return
}
result, err := h.service.Login(c.Request.Context(), service.LoginInput{
Username: payload.Username,
Password: payload.Password,
RemoteAddr: c.ClientIP(),
})
if err != nil {
writeError(c, h.logger, err)
return
}
response.Success(c, result)
}
func (h *authHandler) profile(c *gin.Context) {
userID, err := getUserID(c)
if err != nil {
response.Error(c, http.StatusUnauthorized, "AUTH_UNAUTHORIZED", "认证信息无效")
return
}
result, err := h.service.GetCurrentUser(c.Request.Context(), userID)
if err != nil {
writeError(c, h.logger, err)
return
}
response.Success(c, result)
}

View File

@@ -0,0 +1,23 @@
//go:build ignore
package httpapi
import (
"fmt"
"github.com/gin-gonic/gin"
)
const claimsContextKey = "authClaims"
func getUserID(c *gin.Context) (uint, error) {
value, ok := c.Get(claimsContextKey)
if !ok {
return 0, fmt.Errorf("missing auth claims")
}
claims, ok := value.(AuthClaims)
if !ok {
return 0, fmt.Errorf("invalid auth claims")
}
return claims.UserID, nil
}

View File

@@ -0,0 +1,92 @@
//go:build ignore
package httpapi
import (
"errors"
"fmt"
"net/http"
"strings"
"backupx/server/internal/apperror"
"backupx/server/internal/security"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
type AuthClaims struct {
UserID uint
Username string
Role string
}
func Recovery(logger *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if recovered := recover(); recovered != nil {
logger.Error("panic recovered", zap.Any("panic", recovered), zap.String("path", c.Request.URL.Path))
response.Error(c, http.StatusInternalServerError, "INTERNAL_ERROR", "服务器内部错误")
c.Abort()
}
}()
c.Next()
}
}
func RequestLogger(logger *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
logger.Info("http request",
zap.String("method", c.Request.Method),
zap.String("path", c.Request.URL.Path),
zap.Int("status", c.Writer.Status()),
zap.String("client_ip", c.ClientIP()),
)
}
}
func AuthMiddleware(jwtManager *security.JWTManager) gin.HandlerFunc {
return func(c *gin.Context) {
authorization := strings.TrimSpace(c.GetHeader("Authorization"))
if authorization == "" || !strings.HasPrefix(strings.ToLower(authorization), "bearer ") {
response.Error(c, http.StatusUnauthorized, "AUTH_UNAUTHORIZED", "缺少有效的认证令牌")
c.Abort()
return
}
tokenValue := strings.TrimSpace(strings.TrimPrefix(authorization, "Bearer"))
if tokenValue == authorization {
tokenValue = strings.TrimSpace(strings.TrimPrefix(authorization, "bearer"))
}
claims, err := jwtManager.Parse(tokenValue)
if err != nil {
response.Error(c, http.StatusUnauthorized, "AUTH_UNAUTHORIZED", "认证令牌无效或已过期")
c.Abort()
return
}
c.Set(claimsContextKey, AuthClaims{UserID: claims.UserID, Username: claims.Username, Role: claims.Role})
c.Next()
}
}
func writeError(c *gin.Context, logger *zap.Logger, err error) {
var appErr *apperror.AppError
if errors.As(err, &appErr) {
if appErr.Err != nil {
logger.Warn("request failed", zap.String("code", appErr.Code), zap.Error(appErr.Err))
}
response.Error(c, appErr.Status, appErr.Code, appErr.Message)
return
}
logger.Error("unexpected error", zap.Error(err))
response.Error(c, http.StatusInternalServerError, "INTERNAL_ERROR", "服务器内部错误")
}
func bindJSON[T any](c *gin.Context, logger *zap.Logger) (*T, error) {
var payload T
if err := c.ShouldBindJSON(&payload); err != nil {
logger.Warn("bind json failed", zap.Error(err))
return nil, apperror.Wrap(http.StatusBadRequest, "INVALID_REQUEST", fmt.Sprintf("请求参数错误: %v", err), err)
}
return &payload, nil
}

View File

@@ -0,0 +1,38 @@
//go:build ignore
package httpapi
import (
"backupx/server/internal/security"
"backupx/server/internal/service"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
type Dependencies struct {
Logger *zap.Logger
AuthService *service.AuthService
SystemService *service.SystemService
JWTManager *security.JWTManager
Mode string
}
func NewRouter(deps Dependencies) *gin.Engine {
gin.SetMode(deps.Mode)
router := gin.New()
router.Use(Recovery(deps.Logger), RequestLogger(deps.Logger))
api := router.Group("/api")
authHandler := newAuthHandler(deps.AuthService, deps.Logger)
systemHandler := newSystemHandler(deps.SystemService)
protected := api.Group("")
protected.Use(AuthMiddleware(deps.JWTManager))
authHandler.registerRoutes(api, protected)
systemHandler.registerRoutes(protected)
api.GET("/healthz", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})
return router
}

View File

@@ -0,0 +1,96 @@
//go:build ignore
package httpapi
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"backupx/server/internal/config"
"backupx/server/internal/database"
"backupx/server/internal/logger"
"backupx/server/internal/repository"
"backupx/server/internal/security"
"backupx/server/internal/service"
)
func TestSetupLoginProfileAndSystemInfo(t *testing.T) {
tmpDir := t.TempDir()
cfg := config.Config{
Server: config.ServerConfig{Mode: "test"},
Database: config.DatabaseConfig{Path: filepath.Join(tmpDir, "backupx.db")},
Security: config.SecurityConfig{JWTSecret: "test-jwt-secret", JWTExpire: "1h", EncryptionKey: "test-encryption-key"},
Log: config.LogConfig{Level: "error"},
}
log, err := logger.New(cfg.Log)
if err != nil {
t.Fatalf("logger.New() error = %v", err)
}
db, err := database.Open(cfg.Database, log)
if err != nil {
t.Fatalf("database.Open() error = %v", err)
}
jwtManager := security.NewJWTManager(cfg.Security.JWTSecret, time.Hour)
authService := service.NewAuthService(repository.NewUserRepository(db), jwtManager, security.NewLoginLimiter(5, time.Minute))
systemService := service.NewSystemService(cfg, "test", time.Now().Add(-time.Minute))
router := NewRouter(Dependencies{Logger: log, AuthService: authService, SystemService: systemService, JWTManager: jwtManager, Mode: "test"})
setupBody := map[string]string{"username": "admin", "password": "super-secret", "displayName": "管理员"}
setupResp := performJSONRequest(t, router, http.MethodPost, "/api/auth/setup", setupBody, "")
if setupResp.Code != http.StatusCreated {
t.Fatalf("unexpected setup status: %d body=%s", setupResp.Code, setupResp.Body.String())
}
var setupPayload struct {
Code string `json:"code"`
Data struct {
Token string `json:"token"`
} `json:"data"`
}
if err := json.Unmarshal(setupResp.Body.Bytes(), &setupPayload); err != nil {
t.Fatalf("decode setup response: %v", err)
}
if setupPayload.Data.Token == "" {
t.Fatal("expected token in setup response")
}
profileResp := performJSONRequest(t, router, http.MethodGet, "/api/auth/profile", nil, setupPayload.Data.Token)
if profileResp.Code != http.StatusOK {
t.Fatalf("unexpected profile status: %d body=%s", profileResp.Code, profileResp.Body.String())
}
loginBody := map[string]string{"username": "admin", "password": "super-secret"}
loginResp := performJSONRequest(t, router, http.MethodPost, "/api/auth/login", loginBody, "")
if loginResp.Code != http.StatusOK {
t.Fatalf("unexpected login status: %d body=%s", loginResp.Code, loginResp.Body.String())
}
systemResp := performJSONRequest(t, router, http.MethodGet, "/api/system/info", nil, setupPayload.Data.Token)
if systemResp.Code != http.StatusOK {
t.Fatalf("unexpected system info status: %d body=%s", systemResp.Code, systemResp.Body.String())
}
}
func performJSONRequest(t *testing.T, handler http.Handler, method string, path string, payload any, token string) *httptest.ResponseRecorder {
t.Helper()
var body []byte
if payload != nil {
encoded, err := json.Marshal(payload)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
body = encoded
}
request := httptest.NewRequest(method, path, bytes.NewReader(body))
request.Header.Set("Content-Type", "application/json")
if token != "" {
request.Header.Set("Authorization", "Bearer "+token)
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
return response
}

View File

@@ -0,0 +1,25 @@
//go:build ignore
package httpapi
import (
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
type systemHandler struct {
service *service.SystemService
}
func newSystemHandler(service *service.SystemService) *systemHandler {
return &systemHandler{service: service}
}
func (h *systemHandler) registerRoutes(protected gin.IRouter) {
protected.GET("/system/info", h.info)
}
func (h *systemHandler) info(c *gin.Context) {
response.Success(c, h.service.GetInfo())
}

View File

@@ -0,0 +1,53 @@
package logger
import (
"fmt"
"os"
"path/filepath"
"strings"
"backupx/server/internal/config"
"github.com/natefinch/lumberjack"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
func New(cfg config.LogConfig) (*zap.Logger, error) {
level := parseLevel(cfg.Level)
encoderCfg := zap.NewProductionEncoderConfig()
encoderCfg.TimeKey = "time"
encoderCfg.EncodeTime = zapcore.ISO8601TimeEncoder
encoder := zapcore.NewJSONEncoder(encoderCfg)
writers := []zapcore.WriteSyncer{zapcore.AddSync(os.Stdout)}
if cfg.File != "" {
if err := os.MkdirAll(filepath.Dir(cfg.File), 0o755); err != nil {
return nil, fmt.Errorf("create log dir: %w", err)
}
rotator := &lumberjack.Logger{
Filename: cfg.File,
MaxSize: cfg.MaxSize,
MaxBackups: cfg.MaxBackups,
MaxAge: cfg.MaxAge,
LocalTime: false,
Compress: true,
}
writers = append(writers, zapcore.AddSync(rotator))
}
core := zapcore.NewCore(encoder, zapcore.NewMultiWriteSyncer(writers...), level)
return zap.New(core, zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel)), nil
}
func parseLevel(value string) zapcore.Level {
switch strings.ToLower(strings.TrimSpace(value)) {
case "debug":
return zapcore.DebugLevel
case "warn":
return zapcore.WarnLevel
case "error":
return zapcore.ErrorLevel
default:
return zapcore.InfoLevel
}
}

View File

@@ -0,0 +1,32 @@
package model
import "time"
const (
BackupRecordStatusRunning = "running"
BackupRecordStatusSuccess = "success"
BackupRecordStatusFailed = "failed"
)
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"`
}
func (BackupRecord) TableName() string {
return "backup_records"
}

View File

@@ -0,0 +1,50 @@
package model
import "time"
const (
BackupTaskTypeFile = "file"
BackupTaskTypeMySQL = "mysql"
BackupTaskTypeSQLite = "sqlite"
BackupTaskTypePostgreSQL = "postgresql"
)
const (
BackupTaskStatusIdle = "idle"
BackupTaskStatusRunning = "running"
BackupTaskStatusSuccess = "success"
BackupTaskStatusFailed = "failed"
)
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"`
}
func (BackupTask) TableName() string {
return "backup_tasks"
}

View File

@@ -0,0 +1,30 @@
package model
import "time"
const (
NodeStatusOnline = "online"
NodeStatusOffline = "offline"
)
// Node represents a managed server node in the cluster.
// The default "local" node is auto-created for single-machine backward compatibility.
type Node struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:128;uniqueIndex;not null" json:"name"`
Hostname string `gorm:"size:255" json:"hostname"`
IPAddress string `gorm:"column:ip_address;size:64" json:"ipAddress"`
Token string `gorm:"size:128;uniqueIndex;not null" json:"-"`
Status string `gorm:"size:20;not null;default:'offline'" json:"status"`
IsLocal bool `gorm:"not null;default:false" json:"isLocal"`
OS string `gorm:"size:64" json:"os"`
Arch string `gorm:"size:32" json:"arch"`
AgentVer string `gorm:"column:agent_version;size:32" json:"agentVersion"`
LastSeen time.Time `gorm:"column:last_seen" json:"lastSeen"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (Node) TableName() string {
return "nodes"
}

View File

@@ -0,0 +1,19 @@
package model
import "time"
type Notification struct {
ID uint `gorm:"primaryKey" json:"id"`
Type string `gorm:"size:20;index;not null" json:"type"`
Name string `gorm:"size:100;uniqueIndex;not null" json:"name"`
ConfigCiphertext string `gorm:"column:config_ciphertext;type:text;not null" json:"-"`
Enabled bool `gorm:"not null;default:true" json:"enabled"`
OnSuccess bool `gorm:"column:on_success;not null;default:false" json:"onSuccess"`
OnFailure bool `gorm:"column:on_failure;not null;default:true" json:"onFailure"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (Notification) TableName() string {
return "notifications"
}

View File

@@ -0,0 +1,19 @@
package model
import "time"
type OAuthSession struct {
ID uint `gorm:"primaryKey" json:"id"`
ProviderType string `gorm:"column:provider_type;size:32;index;not null" json:"providerType"`
State string `gorm:"size:255;uniqueIndex;not null" json:"state"`
PayloadCiphertext string `gorm:"column:payload_ciphertext;type:text;not null" json:"-"`
TargetID *uint `gorm:"column:target_id" json:"targetId,omitempty"`
ExpiresAt time.Time `gorm:"column:expires_at;index;not null" json:"expiresAt"`
UsedAt *time.Time `gorm:"column:used_at" json:"usedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (OAuthSession) TableName() string {
return "oauth_sessions"
}

View File

@@ -0,0 +1,22 @@
package model
import "time"
type StorageTarget struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:128;uniqueIndex;not null" json:"name"`
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"`
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"`
LastTestStatus string `gorm:"column:last_test_status;size:32;not null;default:'unknown'" json:"lastTestStatus"`
LastTestMessage string `gorm:"column:last_test_message;size:512" json:"lastTestMessage"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (StorageTarget) TableName() string {
return "storage_targets"
}

View File

@@ -0,0 +1,16 @@
package model
import "time"
type SystemConfig struct {
ID uint `gorm:"primaryKey" json:"id"`
Key string `gorm:"size:128;uniqueIndex;not null" json:"key"`
Value string `gorm:"type:text;not null" json:"value"`
Encrypted bool `gorm:"not null;default:false" json:"encrypted"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (SystemConfig) TableName() string {
return "system_configs"
}

View File

@@ -0,0 +1,18 @@
package model
import "time"
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"size:64;uniqueIndex;not null" json:"username"`
PasswordHash string `gorm:"column:password_hash;not null" json:"-"`
DisplayName string `gorm:"size:128;not null" json:"displayName"`
Email string `gorm:"size:255" json:"email"`
Role string `gorm:"size:32;not null;default:admin" json:"role"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (User) TableName() string {
return "users"
}

View File

@@ -0,0 +1,88 @@
package notify
import (
"context"
"crypto/tls"
"fmt"
"net/smtp"
"strconv"
"strings"
)
type EmailNotifier struct{}
func NewEmailNotifier() *EmailNotifier { return &EmailNotifier{} }
func (n *EmailNotifier) Type() string { return "email" }
func (n *EmailNotifier) SensitiveFields() []string { return []string{"password"} }
func (n *EmailNotifier) Validate(config map[string]any) error {
host := strings.TrimSpace(asString(config["host"]))
port := asInt(config["port"])
from := strings.TrimSpace(asString(config["from"]))
to := strings.TrimSpace(asString(config["to"]))
if host == "" || port <= 0 || from == "" || to == "" {
return fmt.Errorf("email host/port/from/to are required")
}
return nil
}
func (n *EmailNotifier) Send(_ context.Context, config map[string]any, message Message) error {
if err := n.Validate(config); err != nil {
return err
}
host := strings.TrimSpace(asString(config["host"]))
port := asInt(config["port"])
username := strings.TrimSpace(asString(config["username"]))
password := strings.TrimSpace(asString(config["password"]))
from := strings.TrimSpace(asString(config["from"]))
toList := splitCommaValues(asString(config["to"]))
address := host + ":" + strconv.Itoa(port)
headers := []string{"From: " + from, "To: " + strings.Join(toList, ", "), "Subject: " + message.Title, "MIME-Version: 1.0", "Content-Type: text/plain; charset=UTF-8", "", message.Body}
var auth smtp.Auth
if username != "" {
auth = smtp.PlainAuth("", username, password, host)
}
rawMessage := []byte(strings.Join(headers, "\r\n"))
if port == 465 {
tlsConfig := &tls.Config{ServerName: host}
conn, err := tls.Dial("tcp", address, tlsConfig)
if err != nil {
return fmt.Errorf("dial tls for smtp port 465 failed: %w", err)
}
client, err := smtp.NewClient(conn, host)
if err != nil {
return fmt.Errorf("create smtp client over tls failed: %w", err)
}
defer client.Close()
if auth != nil {
if ok, _ := client.Extension("AUTH"); ok {
if err = client.Auth(auth); err != nil {
return fmt.Errorf("smtp auth failed: %w", err)
}
}
}
if err = client.Mail(from); err != nil {
return fmt.Errorf("smtp mail from failed: %w", err)
}
for _, toAddr := range toList {
if err = client.Rcpt(toAddr); err != nil {
return fmt.Errorf("smtp rcpt failed for %s: %w", toAddr, err)
}
}
writer, err := client.Data()
if err != nil {
return fmt.Errorf("smtp data failed: %w", err)
}
if _, err = writer.Write(rawMessage); err != nil {
return fmt.Errorf("smtp write message failed: %w", err)
}
if err = writer.Close(); err != nil {
return fmt.Errorf("smtp data close failed: %w", err)
}
return client.Quit()
}
return smtp.SendMail(address, auth, from, toList, rawMessage)
}

View File

@@ -0,0 +1,49 @@
package notify
import (
"fmt"
"strconv"
"strings"
)
func asString(value any) string {
text, _ := value.(string)
return strings.TrimSpace(text)
}
func asInt(value any) int {
switch actual := value.(type) {
case int:
return actual
case int64:
return int(actual)
case float64:
return int(actual)
case string:
parsed, _ := strconv.Atoi(strings.TrimSpace(actual))
return parsed
default:
return 0
}
}
func splitCommaValues(value string) []string {
items := strings.Split(value, ",")
result := make([]string, 0, len(items))
for _, item := range items {
trimmed := strings.TrimSpace(item)
if trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
func validateRequiredConfig(config map[string]any, fields ...string) error {
for _, field := range fields {
if strings.TrimSpace(asString(config[field])) == "" {
return fmt.Errorf("%s is required", field)
}
}
return nil
}

View File

@@ -0,0 +1,75 @@
package notify
import (
"context"
"fmt"
"sort"
"sync"
)
type Registry struct {
mu sync.RWMutex
notifiers map[string]Notifier
}
func NewRegistry(notifiers ...Notifier) *Registry {
registry := &Registry{notifiers: make(map[string]Notifier)}
for _, notifier := range notifiers {
registry.Register(notifier)
}
return registry
}
func (r *Registry) Register(notifier Notifier) {
if notifier == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
if r.notifiers == nil {
r.notifiers = make(map[string]Notifier)
}
r.notifiers[notifier.Type()] = notifier
}
func (r *Registry) Types() []string {
r.mu.RLock()
defer r.mu.RUnlock()
items := make([]string, 0, len(r.notifiers))
for key := range r.notifiers {
items = append(items, key)
}
sort.Strings(items)
return items
}
func (r *Registry) SensitiveFields(notificationType string) []string {
notifier, ok := r.Notifier(notificationType)
if !ok {
return nil
}
return notifier.SensitiveFields()
}
func (r *Registry) Validate(notificationType string, config map[string]any) error {
notifier, ok := r.Notifier(notificationType)
if !ok {
return fmt.Errorf("unsupported notification type: %s", notificationType)
}
return notifier.Validate(config)
}
func (r *Registry) Send(ctx context.Context, notificationType string, config map[string]any, message Message) error {
notifier, ok := r.Notifier(notificationType)
if !ok {
return fmt.Errorf("unsupported notification type: %s", notificationType)
}
return notifier.Send(ctx, config, message)
}
func (r *Registry) Notifier(notificationType string) (Notifier, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
notifier, ok := r.notifiers[notificationType]
return notifier, ok
}

View File

@@ -0,0 +1,54 @@
package notify
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
type TelegramNotifier struct {
client *http.Client
}
func NewTelegramNotifier() *TelegramNotifier {
return &TelegramNotifier{client: &http.Client{Timeout: 10 * time.Second}}
}
func (n *TelegramNotifier) Type() string { return "telegram" }
func (n *TelegramNotifier) SensitiveFields() []string { return []string{"botToken"} }
func (n *TelegramNotifier) Validate(config map[string]any) error {
if strings.TrimSpace(asString(config["botToken"])) == "" || strings.TrimSpace(asString(config["chatId"])) == "" {
return fmt.Errorf("telegram botToken/chatId are required")
}
return nil
}
func (n *TelegramNotifier) Send(ctx context.Context, config map[string]any, message Message) error {
if err := n.Validate(config); err != nil {
return err
}
botToken := strings.TrimSpace(asString(config["botToken"]))
chatID := strings.TrimSpace(asString(config["chatId"]))
payload, err := json.Marshal(map[string]any{"chat_id": chatID, "text": message.Title + "\n\n" + message.Body})
if err != nil {
return fmt.Errorf("marshal telegram payload: %w", err)
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.telegram.org/bot"+botToken+"/sendMessage", bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("create telegram request: %w", err)
}
request.Header.Set("Content-Type", "application/json")
response, err := n.client.Do(request)
if err != nil {
return fmt.Errorf("send telegram request: %w", err)
}
defer response.Body.Close()
if response.StatusCode >= http.StatusBadRequest {
return fmt.Errorf("telegram response status: %s", response.Status)
}
return nil
}

View File

@@ -0,0 +1,16 @@
package notify
import "context"
type Message struct {
Title string `json:"title"`
Body string `json:"body"`
Fields map[string]any `json:"fields,omitempty"`
}
type Notifier interface {
Type() string
SensitiveFields() []string
Validate(config map[string]any) error
Send(ctx context.Context, config map[string]any, message Message) error
}

View File

@@ -0,0 +1,55 @@
package notify
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
type WebhookNotifier struct {
client *http.Client
}
func NewWebhookNotifier() *WebhookNotifier {
return &WebhookNotifier{client: &http.Client{Timeout: 10 * time.Second}}
}
func (n *WebhookNotifier) Type() string { return "webhook" }
func (n *WebhookNotifier) SensitiveFields() []string { return []string{"secret"} }
func (n *WebhookNotifier) Validate(config map[string]any) error {
if strings.TrimSpace(asString(config["url"])) == "" {
return fmt.Errorf("webhook url is required")
}
return nil
}
func (n *WebhookNotifier) Send(ctx context.Context, config map[string]any, message Message) error {
if err := n.Validate(config); err != nil {
return err
}
body, err := json.Marshal(map[string]any{"title": message.Title, "body": message.Body, "fields": message.Fields})
if err != nil {
return fmt.Errorf("marshal webhook payload: %w", err)
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimSpace(asString(config["url"])), bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create webhook request: %w", err)
}
request.Header.Set("Content-Type", "application/json")
if secret := strings.TrimSpace(asString(config["secret"])); secret != "" {
request.Header.Set("X-BackupX-Secret", secret)
}
response, err := n.client.Do(request)
if err != nil {
return fmt.Errorf("send webhook request: %w", err)
}
defer response.Body.Close()
if response.StatusCode >= http.StatusBadRequest {
return fmt.Errorf("webhook response status: %s", response.Status)
}
return nil
}

View File

@@ -0,0 +1,183 @@
package repository
import (
"context"
"errors"
"time"
"backupx/server/internal/model"
"gorm.io/gorm"
)
type BackupRecordListOptions struct {
TaskID *uint
Status string
DateFrom *time.Time
DateTo *time.Time
Limit int
Offset int
}
type BackupTimelinePoint struct {
Date string `json:"date"`
Total int64 `json:"total"`
Success int64 `json:"success"`
Failed int64 `json:"failed"`
}
type BackupStorageUsageItem struct {
StorageTargetID uint `json:"storageTargetId"`
TotalSize int64 `json:"totalSize"`
}
type BackupRecordRepository interface {
List(context.Context, BackupRecordListOptions) ([]model.BackupRecord, error)
FindByID(context.Context, uint) (*model.BackupRecord, error)
Create(context.Context, *model.BackupRecord) error
Update(context.Context, *model.BackupRecord) error
Delete(context.Context, uint) error
ListRecent(context.Context, int) ([]model.BackupRecord, error)
ListSuccessfulByTask(context.Context, uint) ([]model.BackupRecord, error)
Count(context.Context) (int64, error)
CountSince(context.Context, time.Time) (int64, error)
CountSuccessSince(context.Context, time.Time) (int64, error)
SumFileSize(context.Context) (int64, error)
TimelineSince(context.Context, time.Time) ([]BackupTimelinePoint, error)
StorageUsage(context.Context) ([]BackupStorageUsageItem, error)
}
type GormBackupRecordRepository struct {
db *gorm.DB
}
func NewBackupRecordRepository(db *gorm.DB) *GormBackupRecordRepository {
return &GormBackupRecordRepository{db: db}
}
func (r *GormBackupRecordRepository) List(ctx context.Context, options BackupRecordListOptions) ([]model.BackupRecord, error) {
query := r.db.WithContext(ctx).Model(&model.BackupRecord{}).Preload("Task").Preload("Task.StorageTarget").Order("started_at desc")
if options.TaskID != nil {
query = query.Where("task_id = ?", *options.TaskID)
}
if options.Status != "" {
query = query.Where("status = ?", options.Status)
}
if options.DateFrom != nil {
query = query.Where("started_at >= ?", options.DateFrom.UTC())
}
if options.DateTo != nil {
query = query.Where("started_at <= ?", options.DateTo.UTC())
}
if options.Limit > 0 {
query = query.Limit(options.Limit)
}
if options.Offset > 0 {
query = query.Offset(options.Offset)
}
var items []model.BackupRecord
if err := query.Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func (r *GormBackupRecordRepository) FindByID(ctx context.Context, id uint) (*model.BackupRecord, error) {
var item model.BackupRecord
if err := r.db.WithContext(ctx).Preload("Task").Preload("Task.StorageTarget").First(&item, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}
func (r *GormBackupRecordRepository) Create(ctx context.Context, item *model.BackupRecord) error {
return r.db.WithContext(ctx).Create(item).Error
}
func (r *GormBackupRecordRepository) Update(ctx context.Context, item *model.BackupRecord) error {
return r.db.WithContext(ctx).Save(item).Error
}
func (r *GormBackupRecordRepository) Delete(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).Delete(&model.BackupRecord{}, id).Error
}
func (r *GormBackupRecordRepository) ListRecent(ctx context.Context, limit int) ([]model.BackupRecord, error) {
if limit <= 0 {
limit = 10
}
var items []model.BackupRecord
if err := r.db.WithContext(ctx).Preload("Task").Preload("Task.StorageTarget").Order("started_at desc").Limit(limit).Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func (r *GormBackupRecordRepository) ListSuccessfulByTask(ctx context.Context, taskID uint) ([]model.BackupRecord, error) {
var items []model.BackupRecord
if err := r.db.WithContext(ctx).Where("task_id = ? AND status = ?", taskID, "success").Order("completed_at desc, id desc").Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func (r *GormBackupRecordRepository) Count(ctx context.Context) (int64, error) {
var count int64
if err := r.db.WithContext(ctx).Model(&model.BackupRecord{}).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *GormBackupRecordRepository) CountSince(ctx context.Context, since time.Time) (int64, error) {
var count int64
if err := r.db.WithContext(ctx).Model(&model.BackupRecord{}).Where("started_at >= ?", since.UTC()).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *GormBackupRecordRepository) CountSuccessSince(ctx context.Context, since time.Time) (int64, error) {
var count int64
if err := r.db.WithContext(ctx).Model(&model.BackupRecord{}).Where("started_at >= ? AND status = ?", since.UTC(), "success").Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *GormBackupRecordRepository) SumFileSize(ctx context.Context) (int64, error) {
var sum int64
if err := r.db.WithContext(ctx).Model(&model.BackupRecord{}).Select("COALESCE(SUM(file_size), 0)").Scan(&sum).Error; err != nil {
return 0, err
}
return sum, nil
}
func (r *GormBackupRecordRepository) TimelineSince(ctx context.Context, since time.Time) ([]BackupTimelinePoint, error) {
var items []BackupTimelinePoint
query := `
SELECT
strftime('%Y-%m-%d', started_at) AS date,
COUNT(*) AS total,
SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) AS success,
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed
FROM backup_records
WHERE started_at >= ?
GROUP BY strftime('%Y-%m-%d', started_at)
ORDER BY date ASC
`
if err := r.db.WithContext(ctx).Raw(query, since.UTC()).Scan(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func (r *GormBackupRecordRepository) StorageUsage(ctx context.Context) ([]BackupStorageUsageItem, error) {
var items []BackupStorageUsageItem
if err := r.db.WithContext(ctx).Model(&model.BackupRecord{}).Select("storage_target_id, COALESCE(SUM(file_size), 0) AS total_size").Group("storage_target_id").Order("storage_target_id asc").Scan(&items).Error; err != nil {
return nil, err
}
return items, nil
}

View File

@@ -0,0 +1,115 @@
package repository
import (
"context"
"path/filepath"
"testing"
"time"
"backupx/server/internal/config"
"backupx/server/internal/database"
"backupx/server/internal/logger"
"backupx/server/internal/model"
)
func newBackupRecordTestRepository(t *testing.T) *GormBackupRecordRepository {
t.Helper()
log, err := logger.New(config.LogConfig{Level: "error"})
if err != nil {
t.Fatalf("logger.New returned error: %v", err)
}
db, err := database.Open(config.DatabaseConfig{Path: filepath.Join(t.TempDir(), "backupx.db")}, log)
if err != nil {
t.Fatalf("database.Open returned error: %v", err)
}
storageTarget := &model.StorageTarget{Name: "local", Type: "local_disk", Enabled: true, ConfigCiphertext: "{}", ConfigVersion: 1, LastTestStatus: "unknown"}
if err := db.Create(storageTarget).Error; err != nil {
t.Fatalf("seed storage target error: %v", err)
}
task := &model.BackupTask{Name: "website", Type: "file", Enabled: true, SourcePath: "/srv/www/site", StorageTargetID: storageTarget.ID, RetentionDays: 30, Compression: "gzip", MaxBackups: 10, LastStatus: "idle"}
if err := db.Create(task).Error; err != nil {
t.Fatalf("seed backup task error: %v", err)
}
return NewBackupRecordRepository(db)
}
func TestBackupRecordRepositoryQueries(t *testing.T) {
ctx := context.Background()
repo := newBackupRecordTestRepository(t)
now := time.Now().UTC()
completedAt := now.Add(2 * time.Minute)
record := &model.BackupRecord{
TaskID: 1,
StorageTargetID: 1,
Status: "success",
FileName: "website.tar.gz",
FileSize: 1024,
StoragePath: "tasks/1/website.tar.gz",
DurationSeconds: 120,
LogContent: "done",
StartedAt: now,
CompletedAt: &completedAt,
}
if err := repo.Create(ctx, record); err != nil {
t.Fatalf("Create returned error: %v", err)
}
stored, err := repo.FindByID(ctx, record.ID)
if err != nil {
t.Fatalf("FindByID returned error: %v", err)
}
if stored == nil || stored.FileName != "website.tar.gz" {
t.Fatalf("unexpected stored record: %#v", stored)
}
listed, err := repo.List(ctx, BackupRecordListOptions{TaskID: &record.TaskID, Status: "success"})
if err != nil {
t.Fatalf("List returned error: %v", err)
}
if len(listed) != 1 {
t.Fatalf("expected one listed record, got %d", len(listed))
}
recent, err := repo.ListRecent(ctx, 5)
if err != nil {
t.Fatalf("ListRecent returned error: %v", err)
}
if len(recent) != 1 {
t.Fatalf("expected one recent record, got %d", len(recent))
}
total, err := repo.Count(ctx)
if err != nil {
t.Fatalf("Count returned error: %v", err)
}
if total != 1 {
t.Fatalf("expected total count 1, got %d", total)
}
successCount, err := repo.CountSuccessSince(ctx, now.Add(-time.Hour))
if err != nil {
t.Fatalf("CountSuccessSince returned error: %v", err)
}
if successCount != 1 {
t.Fatalf("expected success count 1, got %d", successCount)
}
sum, err := repo.SumFileSize(ctx)
if err != nil {
t.Fatalf("SumFileSize returned error: %v", err)
}
if sum != 1024 {
t.Fatalf("expected file size sum 1024, got %d", sum)
}
timeline, err := repo.TimelineSince(ctx, now.Add(-time.Hour))
if err != nil {
t.Fatalf("TimelineSince returned error: %v", err)
}
if len(timeline) != 1 || timeline[0].Success != 1 {
t.Fatalf("unexpected timeline: %#v", timeline)
}
usage, err := repo.StorageUsage(ctx)
if err != nil {
t.Fatalf("StorageUsage returned error: %v", err)
}
if len(usage) != 1 || usage[0].TotalSize != 1024 {
t.Fatalf("unexpected usage: %#v", usage)
}
if err := repo.Delete(ctx, record.ID); err != nil {
t.Fatalf("Delete returned error: %v", err)
}
}

View File

@@ -0,0 +1,116 @@
package repository
import (
"context"
"errors"
"backupx/server/internal/model"
"gorm.io/gorm"
)
type BackupTaskListOptions struct {
Type string
Enabled *bool
}
type BackupTaskRepository interface {
List(context.Context, BackupTaskListOptions) ([]model.BackupTask, error)
FindByID(context.Context, uint) (*model.BackupTask, error)
FindByName(context.Context, string) (*model.BackupTask, error)
ListSchedulable(context.Context) ([]model.BackupTask, error)
Count(context.Context) (int64, error)
CountEnabled(context.Context) (int64, error)
CountByStorageTargetID(context.Context, uint) (int64, error)
Create(context.Context, *model.BackupTask) error
Update(context.Context, *model.BackupTask) error
Delete(context.Context, uint) error
}
type GormBackupTaskRepository struct {
db *gorm.DB
}
func NewBackupTaskRepository(db *gorm.DB) *GormBackupTaskRepository {
return &GormBackupTaskRepository{db: db}
}
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")
if options.Type != "" {
query = query.Where("type = ?", options.Type)
}
if options.Enabled != nil {
query = query.Where("enabled = ?", *options.Enabled)
}
var items []model.BackupTask
if err := query.Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
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 errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}
func (r *GormBackupTaskRepository) FindByName(ctx context.Context, name string) (*model.BackupTask, error) {
var item model.BackupTask
if err := r.db.WithContext(ctx).Where("name = ?", name).First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}
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 {
return nil, err
}
return items, nil
}
func (r *GormBackupTaskRepository) Count(ctx context.Context) (int64, error) {
var count int64
if err := r.db.WithContext(ctx).Model(&model.BackupTask{}).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *GormBackupTaskRepository) CountEnabled(ctx context.Context) (int64, error) {
var count int64
if err := r.db.WithContext(ctx).Model(&model.BackupTask{}).Where("enabled = ?", true).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
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 {
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
}
func (r *GormBackupTaskRepository) Update(ctx context.Context, item *model.BackupTask) error {
return r.db.WithContext(ctx).Save(item).Error
}
func (r *GormBackupTaskRepository) Delete(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).Delete(&model.BackupTask{}, id).Error
}

View File

@@ -0,0 +1,94 @@
package repository
import (
"context"
"path/filepath"
"testing"
"backupx/server/internal/config"
"backupx/server/internal/database"
"backupx/server/internal/logger"
"backupx/server/internal/model"
)
func newBackupTaskTestRepository(t *testing.T) *GormBackupTaskRepository {
t.Helper()
log, err := logger.New(config.LogConfig{Level: "error"})
if err != nil {
t.Fatalf("logger.New returned error: %v", err)
}
db, err := database.Open(config.DatabaseConfig{Path: filepath.Join(t.TempDir(), "backupx.db")}, log)
if err != nil {
t.Fatalf("database.Open returned error: %v", err)
}
if err := db.Create(&model.StorageTarget{Name: "local", Type: "local_disk", Enabled: true, ConfigCiphertext: "{}", ConfigVersion: 1, LastTestStatus: "unknown"}).Error; err != nil {
t.Fatalf("seed storage target error: %v", err)
}
return NewBackupTaskRepository(db)
}
func TestBackupTaskRepositoryCRUD(t *testing.T) {
ctx := context.Background()
repo := newBackupTaskTestRepository(t)
task := &model.BackupTask{
Name: "website",
Type: "file",
Enabled: true,
SourcePath: "/srv/www/site",
StorageTargetID: 1,
RetentionDays: 30,
Compression: "gzip",
MaxBackups: 10,
LastStatus: "idle",
}
if err := repo.Create(ctx, task); err != nil {
t.Fatalf("Create returned error: %v", err)
}
stored, err := repo.FindByID(ctx, task.ID)
if err != nil {
t.Fatalf("FindByID returned error: %v", err)
}
if stored == nil || stored.Name != "website" {
t.Fatalf("unexpected stored task: %#v", stored)
}
stored.Enabled = false
stored.CronExpr = "0 3 * * *"
if err := repo.Update(ctx, stored); err != nil {
t.Fatalf("Update returned error: %v", err)
}
schedulable, err := repo.ListSchedulable(ctx)
if err != nil {
t.Fatalf("ListSchedulable returned error: %v", err)
}
if len(schedulable) != 0 {
t.Fatalf("expected disabled task not schedulable, got %d", len(schedulable))
}
stored.Enabled = true
if err := repo.Update(ctx, stored); err != nil {
t.Fatalf("Update returned error: %v", err)
}
schedulable, err = repo.ListSchedulable(ctx)
if err != nil {
t.Fatalf("ListSchedulable returned error: %v", err)
}
if len(schedulable) != 1 {
t.Fatalf("expected one schedulable task, got %d", len(schedulable))
}
count, err := repo.CountByStorageTargetID(ctx, 1)
if err != nil {
t.Fatalf("CountByStorageTargetID returned error: %v", err)
}
if count != 1 {
t.Fatalf("expected referenced task count 1, got %d", count)
}
if err := repo.Delete(ctx, task.ID); err != nil {
t.Fatalf("Delete returned error: %v", err)
}
deleted, err := repo.FindByID(ctx, task.ID)
if err != nil {
t.Fatalf("FindByID after delete returned error: %v", err)
}
if deleted != nil {
t.Fatalf("expected task deleted, got %#v", deleted)
}
}

View File

@@ -0,0 +1,80 @@
package repository
import (
"context"
"errors"
"backupx/server/internal/model"
"gorm.io/gorm"
)
type NodeRepository interface {
List(context.Context) ([]model.Node, error)
FindByID(context.Context, uint) (*model.Node, error)
FindByToken(context.Context, string) (*model.Node, error)
FindLocal(context.Context) (*model.Node, error)
Create(context.Context, *model.Node) error
Update(context.Context, *model.Node) error
Delete(context.Context, uint) error
}
type GormNodeRepository struct {
db *gorm.DB
}
func NewNodeRepository(db *gorm.DB) *GormNodeRepository {
return &GormNodeRepository{db: db}
}
func (r *GormNodeRepository) List(ctx context.Context) ([]model.Node, error) {
var items []model.Node
if err := r.db.WithContext(ctx).Order("is_local desc, updated_at desc").Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func (r *GormNodeRepository) FindByID(ctx context.Context, id uint) (*model.Node, error) {
var item model.Node
if err := r.db.WithContext(ctx).First(&item, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}
func (r *GormNodeRepository) FindByToken(ctx context.Context, token string) (*model.Node, error) {
var item model.Node
if err := r.db.WithContext(ctx).Where("token = ?", token).First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}
func (r *GormNodeRepository) FindLocal(ctx context.Context) (*model.Node, error) {
var item model.Node
if err := r.db.WithContext(ctx).Where("is_local = ?", true).First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}
func (r *GormNodeRepository) Create(ctx context.Context, item *model.Node) error {
return r.db.WithContext(ctx).Create(item).Error
}
func (r *GormNodeRepository) Update(ctx context.Context, item *model.Node) error {
return r.db.WithContext(ctx).Save(item).Error
}
func (r *GormNodeRepository) Delete(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).Delete(&model.Node{}, id).Error
}

View File

@@ -0,0 +1,83 @@
package repository
import (
"context"
"errors"
"backupx/server/internal/model"
"gorm.io/gorm"
)
type NotificationRepository interface {
List(context.Context) ([]model.Notification, error)
ListEnabledForEvent(context.Context, bool) ([]model.Notification, error)
FindByID(context.Context, uint) (*model.Notification, error)
FindByName(context.Context, string) (*model.Notification, error)
Create(context.Context, *model.Notification) error
Update(context.Context, *model.Notification) error
Delete(context.Context, uint) error
}
type GormNotificationRepository struct {
db *gorm.DB
}
func NewNotificationRepository(db *gorm.DB) *GormNotificationRepository {
return &GormNotificationRepository{db: db}
}
func (r *GormNotificationRepository) List(ctx context.Context) ([]model.Notification, error) {
var items []model.Notification
if err := r.db.WithContext(ctx).Order("updated_at desc").Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func (r *GormNotificationRepository) ListEnabledForEvent(ctx context.Context, success bool) ([]model.Notification, error) {
query := r.db.WithContext(ctx).Model(&model.Notification{}).Where("enabled = ?", true)
if success {
query = query.Where("on_success = ?", true)
} else {
query = query.Where("on_failure = ?", true)
}
var items []model.Notification
if err := query.Order("updated_at desc").Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func (r *GormNotificationRepository) FindByID(ctx context.Context, id uint) (*model.Notification, error) {
var item model.Notification
if err := r.db.WithContext(ctx).First(&item, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}
func (r *GormNotificationRepository) FindByName(ctx context.Context, name string) (*model.Notification, error) {
var item model.Notification
if err := r.db.WithContext(ctx).Where("name = ?", name).First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}
func (r *GormNotificationRepository) Create(ctx context.Context, item *model.Notification) error {
return r.db.WithContext(ctx).Create(item).Error
}
func (r *GormNotificationRepository) Update(ctx context.Context, item *model.Notification) error {
return r.db.WithContext(ctx).Save(item).Error
}
func (r *GormNotificationRepository) Delete(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).Delete(&model.Notification{}, id).Error
}

View File

@@ -0,0 +1,69 @@
package repository
import (
"context"
"path/filepath"
"testing"
"backupx/server/internal/config"
"backupx/server/internal/database"
"backupx/server/internal/logger"
"backupx/server/internal/model"
)
func newNotificationTestRepository(t *testing.T) *GormNotificationRepository {
t.Helper()
log, err := logger.New(config.LogConfig{Level: "error"})
if err != nil {
t.Fatalf("logger.New returned error: %v", err)
}
db, err := database.Open(config.DatabaseConfig{Path: filepath.Join(t.TempDir(), "backupx.db")}, log)
if err != nil {
t.Fatalf("database.Open returned error: %v", err)
}
return NewNotificationRepository(db)
}
func TestNotificationRepositoryCRUD(t *testing.T) {
ctx := context.Background()
repo := newNotificationTestRepository(t)
item := &model.Notification{
Type: "webhook",
Name: "ops-webhook",
ConfigCiphertext: "ciphertext",
Enabled: true,
OnSuccess: false,
OnFailure: true,
}
if err := repo.Create(ctx, item); err != nil {
t.Fatalf("Create returned error: %v", err)
}
stored, err := repo.FindByName(ctx, "ops-webhook")
if err != nil {
t.Fatalf("FindByName returned error: %v", err)
}
if stored == nil || stored.Name != "ops-webhook" {
t.Fatalf("unexpected notification: %#v", stored)
}
enabledForFailure, err := repo.ListEnabledForEvent(ctx, false)
if err != nil {
t.Fatalf("ListEnabledForEvent returned error: %v", err)
}
if len(enabledForFailure) != 1 {
t.Fatalf("expected one failure notification, got %d", len(enabledForFailure))
}
stored.OnSuccess = true
if err := repo.Update(ctx, stored); err != nil {
t.Fatalf("Update returned error: %v", err)
}
enabledForSuccess, err := repo.ListEnabledForEvent(ctx, true)
if err != nil {
t.Fatalf("ListEnabledForEvent returned error: %v", err)
}
if len(enabledForSuccess) != 1 {
t.Fatalf("expected one success notification, got %d", len(enabledForSuccess))
}
if err := repo.Delete(ctx, item.ID); err != nil {
t.Fatalf("Delete returned error: %v", err)
}
}

View File

@@ -0,0 +1,48 @@
package repository
import (
"context"
"errors"
"time"
"backupx/server/internal/model"
"gorm.io/gorm"
)
type OAuthSessionRepository interface {
Create(context.Context, *model.OAuthSession) error
Update(context.Context, *model.OAuthSession) error
FindByState(context.Context, string) (*model.OAuthSession, error)
DeleteExpired(context.Context, time.Time) error
}
type GormOAuthSessionRepository struct {
db *gorm.DB
}
func NewOAuthSessionRepository(db *gorm.DB) *GormOAuthSessionRepository {
return &GormOAuthSessionRepository{db: db}
}
func (r *GormOAuthSessionRepository) Create(ctx context.Context, item *model.OAuthSession) error {
return r.db.WithContext(ctx).Create(item).Error
}
func (r *GormOAuthSessionRepository) Update(ctx context.Context, item *model.OAuthSession) error {
return r.db.WithContext(ctx).Save(item).Error
}
func (r *GormOAuthSessionRepository) FindByState(ctx context.Context, state string) (*model.OAuthSession, error) {
var item model.OAuthSession
if err := r.db.WithContext(ctx).Where("state = ?", state).First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}
func (r *GormOAuthSessionRepository) DeleteExpired(ctx context.Context, before time.Time) error {
return r.db.WithContext(ctx).Where("expires_at <= ?", before).Delete(&model.OAuthSession{}).Error
}

View File

@@ -0,0 +1,73 @@
package repository
import (
"context"
"path/filepath"
"testing"
"time"
"backupx/server/internal/config"
"backupx/server/internal/database"
"backupx/server/internal/logger"
"backupx/server/internal/model"
)
func newOAuthSessionTestRepository(t *testing.T) *GormOAuthSessionRepository {
t.Helper()
log, err := logger.New(config.LogConfig{Level: "error"})
if err != nil {
t.Fatalf("logger.New returned error: %v", err)
}
db, err := database.Open(config.DatabaseConfig{Path: filepath.Join(t.TempDir(), "backupx.db")}, log)
if err != nil {
t.Fatalf("database.Open returned error: %v", err)
}
return NewOAuthSessionRepository(db)
}
func TestOAuthSessionRepositoryCRUDAndDeleteExpired(t *testing.T) {
ctx := context.Background()
repo := newOAuthSessionTestRepository(t)
expiresAt := time.Now().UTC().Add(5 * time.Minute)
session := &model.OAuthSession{
ProviderType: "google_drive",
State: "oauth-state",
PayloadCiphertext: "ciphertext",
ExpiresAt: expiresAt,
}
if err := repo.Create(ctx, session); err != nil {
t.Fatalf("Create returned error: %v", err)
}
stored, err := repo.FindByState(ctx, "oauth-state")
if err != nil {
t.Fatalf("FindByState returned error: %v", err)
}
if stored == nil || stored.State != "oauth-state" {
t.Fatalf("unexpected stored session: %#v", stored)
}
now := time.Now().UTC()
stored.UsedAt = &now
if err := repo.Update(ctx, stored); err != nil {
t.Fatalf("Update returned error: %v", err)
}
if err := repo.DeleteExpired(ctx, time.Now().UTC().Add(-time.Minute)); err != nil {
t.Fatalf("DeleteExpired returned error: %v", err)
}
stillThere, err := repo.FindByState(ctx, "oauth-state")
if err != nil {
t.Fatalf("FindByState after DeleteExpired returned error: %v", err)
}
if stillThere == nil {
t.Fatalf("expected unexpired session to remain")
}
if err := repo.DeleteExpired(ctx, time.Now().UTC().Add(10*time.Minute)); err != nil {
t.Fatalf("DeleteExpired returned error: %v", err)
}
deleted, err := repo.FindByState(ctx, "oauth-state")
if err != nil {
t.Fatalf("FindByState after expiration delete returned error: %v", err)
}
if deleted != nil {
t.Fatalf("expected session to be deleted, got %#v", deleted)
}
}

View File

@@ -0,0 +1,68 @@
package repository
import (
"context"
"errors"
"backupx/server/internal/model"
"gorm.io/gorm"
)
type StorageTargetRepository interface {
List(context.Context) ([]model.StorageTarget, error)
FindByID(context.Context, uint) (*model.StorageTarget, error)
FindByName(context.Context, string) (*model.StorageTarget, error)
Create(context.Context, *model.StorageTarget) error
Update(context.Context, *model.StorageTarget) error
Delete(context.Context, uint) error
}
type GormStorageTargetRepository struct {
db *gorm.DB
}
func NewStorageTargetRepository(db *gorm.DB) *GormStorageTargetRepository {
return &GormStorageTargetRepository{db: db}
}
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 {
return nil, err
}
return items, nil
}
func (r *GormStorageTargetRepository) FindByID(ctx context.Context, id uint) (*model.StorageTarget, error) {
var item model.StorageTarget
if err := r.db.WithContext(ctx).First(&item, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}
func (r *GormStorageTargetRepository) FindByName(ctx context.Context, name string) (*model.StorageTarget, error) {
var item model.StorageTarget
if err := r.db.WithContext(ctx).Where("name = ?", name).First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}
func (r *GormStorageTargetRepository) Create(ctx context.Context, item *model.StorageTarget) error {
return r.db.WithContext(ctx).Create(item).Error
}
func (r *GormStorageTargetRepository) Update(ctx context.Context, item *model.StorageTarget) error {
return r.db.WithContext(ctx).Save(item).Error
}
func (r *GormStorageTargetRepository) Delete(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).Delete(&model.StorageTarget{}, id).Error
}

View File

@@ -0,0 +1,81 @@
package repository
import (
"context"
"path/filepath"
"testing"
"backupx/server/internal/config"
"backupx/server/internal/database"
"backupx/server/internal/logger"
"backupx/server/internal/model"
)
func openTestDB(t *testing.T) context.Context {
t.Helper()
return context.Background()
}
func newStorageTestRepository(t *testing.T) *GormStorageTargetRepository {
t.Helper()
log, err := logger.New(config.LogConfig{Level: "error"})
if err != nil {
t.Fatalf("logger.New returned error: %v", err)
}
db, err := database.Open(config.DatabaseConfig{Path: filepath.Join(t.TempDir(), "backupx.db")}, log)
if err != nil {
t.Fatalf("database.Open returned error: %v", err)
}
return NewStorageTargetRepository(db)
}
func TestStorageTargetRepositoryCRUD(t *testing.T) {
ctx := openTestDB(t)
repo := newStorageTestRepository(t)
item := &model.StorageTarget{
Name: "local",
Type: "local_disk",
Enabled: true,
ConfigCiphertext: "ciphertext",
ConfigVersion: 1,
LastTestStatus: "unknown",
}
if err := repo.Create(ctx, item); err != nil {
t.Fatalf("Create returned error: %v", err)
}
stored, err := repo.FindByID(ctx, item.ID)
if err != nil {
t.Fatalf("FindByID returned error: %v", err)
}
if stored == nil || stored.Name != "local" {
t.Fatalf("unexpected stored target: %#v", stored)
}
byName, err := repo.FindByName(ctx, "local")
if err != nil {
t.Fatalf("FindByName returned error: %v", err)
}
if byName == nil || byName.ID != item.ID {
t.Fatalf("expected target lookup by name to match, got %#v", byName)
}
stored.Description = "updated"
if err := repo.Update(ctx, stored); err != nil {
t.Fatalf("Update returned error: %v", err)
}
items, err := repo.List(ctx)
if err != nil {
t.Fatalf("List returned error: %v", err)
}
if len(items) != 1 || items[0].Description != "updated" {
t.Fatalf("unexpected list result: %#v", items)
}
if err := repo.Delete(ctx, item.ID); err != nil {
t.Fatalf("Delete returned error: %v", err)
}
deleted, err := repo.FindByID(ctx, item.ID)
if err != nil {
t.Fatalf("FindByID after delete returned error: %v", err)
}
if deleted != nil {
t.Fatalf("expected target to be deleted, got %#v", deleted)
}
}

View File

@@ -0,0 +1,50 @@
package repository
import (
"context"
"errors"
"backupx/server/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type SystemConfigRepository interface {
GetByKey(context.Context, string) (*model.SystemConfig, error)
List(context.Context) ([]model.SystemConfig, error)
Upsert(context.Context, *model.SystemConfig) error
}
type GormSystemConfigRepository struct {
db *gorm.DB
}
func NewSystemConfigRepository(db *gorm.DB) *GormSystemConfigRepository {
return &GormSystemConfigRepository{db: db}
}
func (r *GormSystemConfigRepository) GetByKey(ctx context.Context, key string) (*model.SystemConfig, error) {
var item model.SystemConfig
if err := r.db.WithContext(ctx).Where("key = ?", key).First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &item, nil
}
func (r *GormSystemConfigRepository) List(ctx context.Context) ([]model.SystemConfig, error) {
var items []model.SystemConfig
if err := r.db.WithContext(ctx).Order("key ASC").Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func (r *GormSystemConfigRepository) Upsert(ctx context.Context, item *model.SystemConfig) error {
return r.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value", "encrypted", "updated_at"}),
}).Create(item).Error
}

View File

@@ -0,0 +1,63 @@
package repository
import (
"context"
"errors"
"backupx/server/internal/model"
"gorm.io/gorm"
)
type UserRepository interface {
Count(context.Context) (int64, error)
Create(context.Context, *model.User) error
Update(context.Context, *model.User) error
FindByUsername(context.Context, string) (*model.User, error)
FindByID(context.Context, uint) (*model.User, error)
}
type GormUserRepository struct {
db *gorm.DB
}
func NewUserRepository(db *gorm.DB) *GormUserRepository {
return &GormUserRepository{db: db}
}
func (r *GormUserRepository) Count(ctx context.Context) (int64, error) {
var count int64
if err := r.db.WithContext(ctx).Model(&model.User{}).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *GormUserRepository) Create(ctx context.Context, user *model.User) error {
return r.db.WithContext(ctx).Create(user).Error
}
func (r *GormUserRepository) Update(ctx context.Context, user *model.User) error {
return r.db.WithContext(ctx).Save(user).Error
}
func (r *GormUserRepository) FindByUsername(ctx context.Context, username string) (*model.User, error) {
var user model.User
if err := r.db.WithContext(ctx).Where("username = ?", username).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &user, nil
}
func (r *GormUserRepository) FindByID(ctx context.Context, id uint) (*model.User, error) {
var user model.User
if err := r.db.WithContext(ctx).First(&user, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &user, nil
}

View File

@@ -0,0 +1,109 @@
package scheduler
import (
"context"
"fmt"
"sync"
"time"
"backupx/server/internal/model"
"backupx/server/internal/repository"
servicepkg "backupx/server/internal/service"
"github.com/robfig/cron/v3"
"go.uber.org/zap"
)
type TaskRunner interface {
RunTaskByID(context.Context, uint) (*servicepkg.BackupRecordDetail, error)
}
type Service struct {
mu sync.Mutex
cron *cron.Cron
tasks repository.BackupTaskRepository
runner TaskRunner
logger *zap.Logger
entries map[uint]cron.EntryID
}
func NewService(tasks repository.BackupTaskRepository, runner TaskRunner, logger *zap.Logger) *Service {
parser := cron.NewParser(cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
return &Service{cron: cron.New(cron.WithParser(parser), cron.WithLocation(time.UTC)), tasks: tasks, runner: runner, logger: logger, entries: make(map[uint]cron.EntryID)}
}
func (s *Service) Start(ctx context.Context) error {
if err := s.Reload(ctx); err != nil {
return err
}
s.cron.Start()
return nil
}
func (s *Service) Stop(ctx context.Context) error {
stopCtx := s.cron.Stop()
select {
case <-stopCtx.Done():
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (s *Service) Reload(ctx context.Context) error {
items, err := s.tasks.ListSchedulable(ctx)
if err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
for taskID, entryID := range s.entries {
s.cron.Remove(entryID)
delete(s.entries, taskID)
}
for _, item := range items {
item := item
if err := s.syncTaskLocked(&item); err != nil {
return err
}
}
return nil
}
func (s *Service) SyncTask(_ context.Context, task *model.BackupTask) error {
s.mu.Lock()
defer s.mu.Unlock()
return s.syncTaskLocked(task)
}
func (s *Service) RemoveTask(_ context.Context, taskID uint) error {
s.mu.Lock()
defer s.mu.Unlock()
if entryID, ok := s.entries[taskID]; ok {
s.cron.Remove(entryID)
delete(s.entries, taskID)
}
return nil
}
func (s *Service) syncTaskLocked(task *model.BackupTask) error {
if task == nil {
return fmt.Errorf("task is required")
}
if entryID, ok := s.entries[task.ID]; ok {
s.cron.Remove(entryID)
delete(s.entries, task.ID)
}
if !task.Enabled || task.CronExpr == "" {
return nil
}
entryID, err := s.cron.AddFunc(task.CronExpr, func() {
if _, runErr := s.runner.RunTaskByID(context.Background(), task.ID); runErr != nil && s.logger != nil {
s.logger.Warn("scheduled backup run failed", zap.Uint("task_id", task.ID), zap.Error(runErr))
}
})
if err != nil {
return err
}
s.entries[task.ID] = entryID
return nil
}

View File

@@ -0,0 +1,58 @@
package scheduler
import (
"backupx/server/internal/repository"
servicepkg "backupx/server/internal/service"
"context"
"testing"
"time"
"backupx/server/internal/model"
)
type fakeTaskRepository struct {
items []model.BackupTask
}
func (r *fakeTaskRepository) List(context.Context, repository.BackupTaskListOptions) ([]model.BackupTask, error) {
return nil, nil
}
func (r *fakeTaskRepository) FindByID(context.Context, uint) (*model.BackupTask, error) {
return nil, nil
}
func (r *fakeTaskRepository) FindByName(context.Context, string) (*model.BackupTask, error) {
return nil, nil
}
func (r *fakeTaskRepository) ListSchedulable(context.Context) ([]model.BackupTask, error) {
return r.items, nil
}
func (r *fakeTaskRepository) Count(context.Context) (int64, error) { return 0, nil }
func (r *fakeTaskRepository) CountEnabled(context.Context) (int64, error) { return 0, nil }
func (r *fakeTaskRepository) CountByStorageTargetID(context.Context, uint) (int64, error) {
return 0, nil
}
func (r *fakeTaskRepository) Create(context.Context, *model.BackupTask) error { return nil }
func (r *fakeTaskRepository) Update(context.Context, *model.BackupTask) error { return nil }
func (r *fakeTaskRepository) Delete(context.Context, uint) error { return nil }
type fakeRunner struct{ taskIDs []uint }
func (r *fakeRunner) RunTaskByID(_ context.Context, id uint) (*servicepkg.BackupRecordDetail, error) {
r.taskIDs = append(r.taskIDs, id)
return nil, nil
}
func TestServiceSyncTaskAndTrigger(t *testing.T) {
repo := &fakeTaskRepository{}
runner := &fakeRunner{}
service := NewService(repo, runner, nil)
if err := service.SyncTask(context.Background(), &model.BackupTask{ID: 1, Enabled: true, CronExpr: "*/1 * * * * *"}); err != nil {
t.Fatalf("SyncTask returned error: %v", err)
}
service.cron.Start()
defer service.cron.Stop()
time.Sleep(1100 * time.Millisecond)
if len(runner.taskIDs) == 0 {
t.Fatalf("expected scheduled runner to be triggered")
}
}

View File

@@ -0,0 +1,60 @@
//go:build ignore
package security
import (
"fmt"
"time"
"backupx/server/internal/model"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
UserID uint `json:"userId"`
Username string `json:"username"`
Role string `json:"role"`
jwt.RegisteredClaims
}
type JWTManager struct {
secret []byte
duration time.Duration
}
func NewJWTManager(secret string, duration time.Duration) *JWTManager {
return &JWTManager{secret: []byte(secret), duration: duration}
}
func (m *JWTManager) IssueToken(user *model.User) (string, error) {
now := time.Now().UTC()
claims := Claims{
UserID: user.ID,
Username: user.Username,
Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{
Subject: fmt.Sprintf("%d", user.ID),
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(m.duration)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(m.secret)
}
func (m *JWTManager) Parse(tokenValue string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenValue, &Claims{}, func(token *jwt.Token) (any, error) {
if token.Method != jwt.SigningMethodHS256 {
return nil, fmt.Errorf("unexpected signing method")
}
return m.secret, nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token")
}
return claims, nil
}

View File

@@ -0,0 +1,25 @@
//go:build ignore
package security
import (
"testing"
"time"
"backupx/server/internal/model"
)
func TestJWTManagerIssueAndParse(t *testing.T) {
manager := NewJWTManager("test-secret", time.Hour)
token, err := manager.IssueToken(&model.User{ID: 7, Username: "admin", Role: "admin"})
if err != nil {
t.Fatalf("IssueToken() error = %v", err)
}
claims, err := manager.Parse(token)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if claims.UserID != 7 || claims.Username != "admin" {
t.Fatalf("unexpected claims: %+v", claims)
}
}

View File

@@ -0,0 +1,54 @@
//go:build ignore
package security
import (
"sync"
"time"
)
type limiterEntry struct {
Count int
ResetAt time.Time
}
type LoginLimiter struct {
mu sync.Mutex
window time.Duration
max int
records map[string]limiterEntry
}
func NewLoginLimiter(max int, window time.Duration) *LoginLimiter {
return &LoginLimiter{window: window, max: max, records: make(map[string]limiterEntry)}
}
func (l *LoginLimiter) Allow(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
entry, ok := l.records[key]
if !ok || time.Now().After(entry.ResetAt) {
delete(l.records, key)
return true
}
return entry.Count < l.max
}
func (l *LoginLimiter) RegisterFailure(key string) {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
entry, ok := l.records[key]
if !ok || now.After(entry.ResetAt) {
l.records[key] = limiterEntry{Count: 1, ResetAt: now.Add(l.window)}
return
}
entry.Count++
l.records[key] = entry
}
func (l *LoginLimiter) Reset(key string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.records, key)
}

View File

@@ -0,0 +1,17 @@
package security
import "golang.org/x/crypto/bcrypt"
const PasswordCost = 12
func HashPassword(password string) (string, error) {
hashed, err := bcrypt.GenerateFromPassword([]byte(password), PasswordCost)
if err != nil {
return "", err
}
return string(hashed), nil
}
func ComparePassword(hashedPassword, plainPassword string) error {
return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(plainPassword))
}

View File

@@ -0,0 +1,16 @@
package security
import "testing"
func TestHashAndComparePassword(t *testing.T) {
hash, err := HashPassword("super-secret-password")
if err != nil {
t.Fatalf("HashPassword returned error: %v", err)
}
if hash == "super-secret-password" {
t.Fatalf("expected hashed password to differ from plain text")
}
if err := ComparePassword(hash, "super-secret-password"); err != nil {
t.Fatalf("ComparePassword returned error: %v", err)
}
}

View File

@@ -0,0 +1,50 @@
package security
import (
"sync"
"time"
)
type rateEntry struct {
count int
windowEnd time.Time
}
type LoginRateLimiter struct {
limit int
window time.Duration
mu sync.Mutex
items map[string]rateEntry
}
func NewLoginRateLimiter(limit int, window time.Duration) *LoginRateLimiter {
return &LoginRateLimiter{
limit: limit,
window: window,
items: make(map[string]rateEntry),
}
}
func (r *LoginRateLimiter) Allow(key string) bool {
now := time.Now().UTC()
r.mu.Lock()
defer r.mu.Unlock()
entry, ok := r.items[key]
if !ok || now.After(entry.windowEnd) {
r.items[key] = rateEntry{count: 0, windowEnd: now.Add(r.window)}
entry = r.items[key]
}
if entry.count >= r.limit {
return false
}
entry.count++
r.items[key] = entry
return true
}
func (r *LoginRateLimiter) Reset(key string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.items, key)
}

View File

@@ -0,0 +1,14 @@
package security
import (
"crypto/rand"
"encoding/base64"
)
func GenerateSecret(bytesLength int) (string, error) {
buffer := make([]byte, bytesLength)
if _, err := rand.Read(buffer); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buffer), nil
}

View File

@@ -0,0 +1,93 @@
//go:build ignore
package security
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"backupx/server/internal/config"
)
type PersistedSecrets struct {
JWTSecret string `json:"jwtSecret"`
EncryptionKey string `json:"encryptionKey"`
}
func EnsureSecrets(cfg *config.Config) error {
if cfg.Security.JWTSecret != "" && cfg.Security.EncryptionKey != "" {
return nil
}
storePath := filepath.Join(filepath.Dir(cfg.Database.Path), "backupx.secrets.json")
current, err := loadSecrets(storePath)
if err != nil {
return err
}
if current == nil {
current = &PersistedSecrets{}
}
if current.JWTSecret == "" {
current.JWTSecret, err = randomHex(32)
if err != nil {
return err
}
}
if current.EncryptionKey == "" {
current.EncryptionKey, err = randomHex(32)
if err != nil {
return err
}
}
if err := saveSecrets(storePath, current); err != nil {
return err
}
if cfg.Security.JWTSecret == "" {
cfg.Security.JWTSecret = current.JWTSecret
}
if cfg.Security.EncryptionKey == "" {
cfg.Security.EncryptionKey = current.EncryptionKey
}
return nil
}
func loadSecrets(path string) (*PersistedSecrets, error) {
content, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read secrets: %w", err)
}
var secrets PersistedSecrets
if err := json.Unmarshal(content, &secrets); err != nil {
return nil, fmt.Errorf("decode secrets: %w", err)
}
return &secrets, nil
}
func saveSecrets(path string, secrets *PersistedSecrets) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("create secrets dir: %w", err)
}
content, err := json.MarshalIndent(secrets, "", " ")
if err != nil {
return fmt.Errorf("encode secrets: %w", err)
}
if err := os.WriteFile(path, content, 0o600); err != nil {
return fmt.Errorf("write secrets: %w", err)
}
return nil
}
func randomHex(size int) (string, error) {
bytes := make([]byte, size)
if _, err := rand.Read(bytes); err != nil {
return "", fmt.Errorf("generate random secret: %w", err)
}
return hex.EncodeToString(bytes), nil
}

View File

@@ -0,0 +1,57 @@
package security
import (
"fmt"
"strconv"
"time"
"backupx/server/internal/model"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
Username string `json:"username"`
Role string `json:"role"`
jwt.RegisteredClaims
}
type JWTManager struct {
secret []byte
expiry time.Duration
}
func NewJWTManager(secret string, expiry time.Duration) *JWTManager {
return &JWTManager{secret: []byte(secret), expiry: expiry}
}
func (m *JWTManager) Generate(user *model.User) (string, error) {
now := time.Now().UTC()
claims := Claims{
Username: user.Username,
Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{
Subject: strconv.FormatUint(uint64(user.ID), 10),
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(m.expiry)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(m.secret)
}
func (m *JWTManager) Parse(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
if token.Method != jwt.SigningMethodHS256 {
return nil, fmt.Errorf("unexpected signing method: %s", token.Method.Alg())
}
return m.secret, nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token claims")
}
return claims, nil
}

Some files were not shown because too many files have changed in this diff Show More