mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-05-06 20:02:41 +08:00
first commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
web/node_modules/
|
||||
191
LICENSE
Normal file
191
LICENSE
Normal 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
30
Makefile
Normal 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
436
README.md
Normal 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
18
deploy/backupx.service
Normal 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
72
deploy/install.sh
Executable 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
24
deploy/nginx.conf
Normal 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
BIN
server/.DS_Store
vendored
Normal file
Binary file not shown.
14
server/Makefile
Normal file
14
server/Makefile
Normal 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 ./...
|
||||
50
server/cmd/backupx/main.go
Normal file
50
server/cmd/backupx/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
24
server/config.example.yaml
Normal file
24
server/config.example.yaml
Normal 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
96
server/go.mod
Normal 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
223
server/go.sum
Normal 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
188
server/internal/app/app.go
Normal 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)
|
||||
}
|
||||
55
server/internal/apperror/error.go
Normal file
55
server/internal/apperror/error.go
Normal 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)
|
||||
}
|
||||
189
server/internal/backup/archive.go
Normal file
189
server/internal/backup/archive.go
Normal 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
|
||||
}
|
||||
41
server/internal/backup/command.go
Normal file
41
server/internal/backup/command.go
Normal 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()
|
||||
}
|
||||
37
server/internal/backup/command_executor.go
Normal file
37
server/internal/backup/command_executor.go
Normal 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()
|
||||
}
|
||||
16
server/internal/backup/database_names.go
Normal file
16
server/internal/backup/database_names.go
Normal 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
|
||||
}
|
||||
106
server/internal/backup/database_runners_test.go
Normal file
106
server/internal/backup/database_runners_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
191
server/internal/backup/file_runner.go
Normal file
191
server/internal/backup/file_runner.go
Normal 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
|
||||
}
|
||||
69
server/internal/backup/file_runner_test.go
Normal file
69
server/internal/backup/file_runner_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
41
server/internal/backup/helpers.go
Normal file
41
server/internal/backup/helpers.go
Normal 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(), "_")
|
||||
}
|
||||
110
server/internal/backup/log_hub.go
Normal file
110
server/internal/backup/log_hub.go
Normal 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
|
||||
}
|
||||
26
server/internal/backup/log_hub_test.go
Normal file
26
server/internal/backup/log_hub_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
56
server/internal/backup/logger.go
Normal file
56
server/internal/backup/logger.go
Normal 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()
|
||||
}
|
||||
163
server/internal/backup/mysql_runner.go
Normal file
163
server/internal/backup/mysql_runner.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
171
server/internal/backup/postgres_runner.go
Normal file
171
server/internal/backup/postgres_runner.go
Normal 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
|
||||
}
|
||||
80
server/internal/backup/postgresql_runner.go
Normal file
80
server/internal/backup/postgresql_runner.go
Normal 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
|
||||
}
|
||||
62
server/internal/backup/registry.go
Normal file
62
server/internal/backup/registry.go
Normal 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
|
||||
}
|
||||
23
server/internal/backup/registry_test.go
Normal file
23
server/internal/backup/registry_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
82
server/internal/backup/retention/service.go
Normal file
82
server/internal/backup/retention/service.go
Normal 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
|
||||
}
|
||||
115
server/internal/backup/retention/service_test.go
Normal file
115
server/internal/backup/retention/service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
74
server/internal/backup/sqlite_runner.go
Normal file
74
server/internal/backup/sqlite_runner.go
Normal 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
|
||||
}
|
||||
34
server/internal/backup/sqlite_runner_test.go
Normal file
34
server/internal/backup/sqlite_runner_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
64
server/internal/backup/temp_files.go
Normal file
64
server/internal/backup/temp_files.go
Normal 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
|
||||
}
|
||||
73
server/internal/backup/types.go
Normal file
73
server/internal/backup/types.go
Normal 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
|
||||
}
|
||||
143
server/internal/config/config.go
Normal file
143
server/internal/config/config.go
Normal 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)
|
||||
}
|
||||
20
server/internal/config/config_test.go
Normal file
20
server/internal/config/config_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
32
server/internal/database/database.go
Normal file
32
server/internal/database/database.go
Normal 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
|
||||
}
|
||||
91
server/internal/http/auth_handler.go
Normal file
91
server/internal/http/auth_handler.go
Normal 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})
|
||||
}
|
||||
189
server/internal/http/backup_record_handler.go
Normal file
189
server/internal/http/backup_record_handler.go
Normal 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
|
||||
}
|
||||
28
server/internal/http/backup_run_handler.go
Normal file
28
server/internal/http/backup_run_handler.go
Normal 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)
|
||||
}
|
||||
109
server/internal/http/backup_task_handler.go
Normal file
109
server/internal/http/backup_task_handler.go
Normal 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)
|
||||
}
|
||||
3
server/internal/http/context.go
Normal file
3
server/internal/http/context.go
Normal file
@@ -0,0 +1,3 @@
|
||||
package http
|
||||
|
||||
const contextUserSubjectKey = "userSubject"
|
||||
46
server/internal/http/dashboard_handler.go
Normal file
46
server/internal/http/dashboard_handler.go
Normal 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)
|
||||
}
|
||||
57
server/internal/http/middleware.go
Normal file
57
server/internal/http/middleware.go
Normal 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
|
||||
}
|
||||
101
server/internal/http/node_handler.go
Normal file
101
server/internal/http/node_handler.go
Normal 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"})
|
||||
}
|
||||
107
server/internal/http/notification_handler.go
Normal file
107
server/internal/http/notification_handler.go
Normal 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})
|
||||
}
|
||||
152
server/internal/http/router.go
Normal file
152
server/internal/http/router.go
Normal 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()),
|
||||
)
|
||||
}
|
||||
}
|
||||
94
server/internal/http/router_test.go
Normal file
94
server/internal/http/router_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
39
server/internal/http/settings_handler.go
Normal file
39
server/internal/http/settings_handler.go
Normal 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)
|
||||
}
|
||||
244
server/internal/http/storage_target_handler.go
Normal file
244
server/internal/http/storage_target_handler.go
Normal 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)
|
||||
}
|
||||
19
server/internal/http/system_handler.go
Normal file
19
server/internal/http/system_handler.go
Normal 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()))
|
||||
}
|
||||
98
server/internal/httpapi/auth_handler.go
Normal file
98
server/internal/httpapi/auth_handler.go
Normal 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)
|
||||
}
|
||||
23
server/internal/httpapi/context.go
Normal file
23
server/internal/httpapi/context.go
Normal 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
|
||||
}
|
||||
92
server/internal/httpapi/middleware.go
Normal file
92
server/internal/httpapi/middleware.go
Normal 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
|
||||
}
|
||||
38
server/internal/httpapi/router.go
Normal file
38
server/internal/httpapi/router.go
Normal 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
|
||||
}
|
||||
96
server/internal/httpapi/router_test.go
Normal file
96
server/internal/httpapi/router_test.go
Normal 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
|
||||
}
|
||||
25
server/internal/httpapi/system_handler.go
Normal file
25
server/internal/httpapi/system_handler.go
Normal 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())
|
||||
}
|
||||
53
server/internal/logger/logger.go
Normal file
53
server/internal/logger/logger.go
Normal 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
|
||||
}
|
||||
}
|
||||
32
server/internal/model/backup_record.go
Normal file
32
server/internal/model/backup_record.go
Normal 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"
|
||||
}
|
||||
50
server/internal/model/backup_task.go
Normal file
50
server/internal/model/backup_task.go
Normal 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"
|
||||
}
|
||||
30
server/internal/model/node.go
Normal file
30
server/internal/model/node.go
Normal 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"
|
||||
}
|
||||
19
server/internal/model/notification.go
Normal file
19
server/internal/model/notification.go
Normal 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"
|
||||
}
|
||||
19
server/internal/model/oauth_session.go
Normal file
19
server/internal/model/oauth_session.go
Normal 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"
|
||||
}
|
||||
22
server/internal/model/storage_target.go
Normal file
22
server/internal/model/storage_target.go
Normal 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"
|
||||
}
|
||||
16
server/internal/model/system_config.go
Normal file
16
server/internal/model/system_config.go
Normal 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"
|
||||
}
|
||||
18
server/internal/model/user.go
Normal file
18
server/internal/model/user.go
Normal 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"
|
||||
}
|
||||
88
server/internal/notify/email.go
Normal file
88
server/internal/notify/email.go
Normal 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)
|
||||
}
|
||||
49
server/internal/notify/helpers.go
Normal file
49
server/internal/notify/helpers.go
Normal 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
|
||||
}
|
||||
75
server/internal/notify/registry.go
Normal file
75
server/internal/notify/registry.go
Normal 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
|
||||
}
|
||||
54
server/internal/notify/telegram.go
Normal file
54
server/internal/notify/telegram.go
Normal 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
|
||||
}
|
||||
16
server/internal/notify/types.go
Normal file
16
server/internal/notify/types.go
Normal 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
|
||||
}
|
||||
55
server/internal/notify/webhook.go
Normal file
55
server/internal/notify/webhook.go
Normal 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
|
||||
}
|
||||
183
server/internal/repository/backup_record_repository.go
Normal file
183
server/internal/repository/backup_record_repository.go
Normal 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
|
||||
}
|
||||
115
server/internal/repository/backup_record_repository_test.go
Normal file
115
server/internal/repository/backup_record_repository_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
116
server/internal/repository/backup_task_repository.go
Normal file
116
server/internal/repository/backup_task_repository.go
Normal 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
|
||||
}
|
||||
94
server/internal/repository/backup_task_repository_test.go
Normal file
94
server/internal/repository/backup_task_repository_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
80
server/internal/repository/node_repository.go
Normal file
80
server/internal/repository/node_repository.go
Normal 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
|
||||
}
|
||||
83
server/internal/repository/notification_repository.go
Normal file
83
server/internal/repository/notification_repository.go
Normal 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
|
||||
}
|
||||
69
server/internal/repository/notification_repository_test.go
Normal file
69
server/internal/repository/notification_repository_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
48
server/internal/repository/oauth_session_repository.go
Normal file
48
server/internal/repository/oauth_session_repository.go
Normal 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
|
||||
}
|
||||
73
server/internal/repository/oauth_session_repository_test.go
Normal file
73
server/internal/repository/oauth_session_repository_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
68
server/internal/repository/storage_target_repository.go
Normal file
68
server/internal/repository/storage_target_repository.go
Normal 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
|
||||
}
|
||||
81
server/internal/repository/storage_target_repository_test.go
Normal file
81
server/internal/repository/storage_target_repository_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
50
server/internal/repository/system_config_repository.go
Normal file
50
server/internal/repository/system_config_repository.go
Normal 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
|
||||
}
|
||||
63
server/internal/repository/user_repository.go
Normal file
63
server/internal/repository/user_repository.go
Normal 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
|
||||
}
|
||||
109
server/internal/scheduler/service.go
Normal file
109
server/internal/scheduler/service.go
Normal 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
|
||||
}
|
||||
58
server/internal/scheduler/service_test.go
Normal file
58
server/internal/scheduler/service_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
60
server/internal/security/jwt.go
Normal file
60
server/internal/security/jwt.go
Normal 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
|
||||
}
|
||||
25
server/internal/security/jwt_test.go
Normal file
25
server/internal/security/jwt_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
54
server/internal/security/limiter.go
Normal file
54
server/internal/security/limiter.go
Normal 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)
|
||||
}
|
||||
17
server/internal/security/password.go
Normal file
17
server/internal/security/password.go
Normal 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))
|
||||
}
|
||||
16
server/internal/security/password_test.go
Normal file
16
server/internal/security/password_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
50
server/internal/security/rate_limiter.go
Normal file
50
server/internal/security/rate_limiter.go
Normal 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)
|
||||
}
|
||||
14
server/internal/security/secret.go
Normal file
14
server/internal/security/secret.go
Normal 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
|
||||
}
|
||||
93
server/internal/security/secret_store.go
Normal file
93
server/internal/security/secret_store.go
Normal 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
|
||||
}
|
||||
57
server/internal/security/token.go
Normal file
57
server/internal/security/token.go
Normal 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
Reference in New Issue
Block a user