2026-08-12 16:15:19 +08:00
package app
import (
"archive/tar"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
2026-08-12 16:59:20 +08:00
"net"
2026-08-12 16:15:19 +08:00
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"github.com/go-chi/chi/v5"
"golang.org/x/oauth2"
)
const backupTelegramLimit = 49 << 20
2026-08-12 18:41:49 +08:00
const googleDriveUploadChunkSize = 8 << 20
2026-08-12 16:15:19 +08:00
type backupJob struct {
Status string `json:"status"`
StartedAt time . Time `json:"startedAt"`
Error string `json:"error,omitempty"`
}
type backupItem struct {
Name string `json:"name"`
Size int64 `json:"size"`
CreatedAt time . Time `json:"createdAt"`
SHA256 string `json:"sha256,omitempty"`
}
2026-08-12 18:41:49 +08:00
type backupTransfer struct {
Provider string `json:"provider"`
Name string `json:"name"`
Status string `json:"status"`
Uploaded int64 `json:"uploaded"`
Total int64 `json:"total"`
StartedAt time . Time `json:"startedAt"`
FinishedAt time . Time `json:"finishedAt,omitempty"`
Error string `json:"error,omitempty"`
}
2026-08-12 16:15:19 +08:00
type backupListResponse struct {
Enabled bool `json:"enabled"`
TelegramSet bool `json:"telegramSet"`
TelegramLimit int64 `json:"telegramLimit"`
Job * backupJob `json:"job,omitempty"`
Items [] backupItem `json:"items"`
Schedule backupSchedule `json:"schedule"`
GoogleDrive googleDriveStatus `json:"googleDrive"`
2026-08-12 18:41:49 +08:00
Transfers [] backupTransfer `json:"transfers"`
2026-08-12 16:15:19 +08:00
}
type createBackupRequest struct {
Password string `json:"password"`
ConfirmPassword string `json:"confirmPassword"`
SendTelegram bool `json:"sendTelegram"`
UploadGoogleDrive bool `json:"uploadGoogleDrive"`
}
type backupSchedule struct {
Enabled bool `json:"enabled"`
Days int `json:"days"`
PasswordSet bool `json:"passwordSet"`
2026-08-12 17:58:48 +08:00
PasswordHint string `json:"passwordHint,omitempty"`
2026-08-12 16:15:19 +08:00
ServerIP string `json:"serverIp"`
ChatID string `json:"chatId"`
TelegramMode string `json:"telegramMode"`
TelegramEnabled bool `json:"telegramEnabled"`
GoogleDriveEnabled bool `json:"googleDriveEnabled"`
}
2026-08-12 16:59:20 +08:00
func detectPublicServerIP ( ctx context . Context , hostname string ) string {
hostname = strings . TrimSpace ( hostname )
if hostname == "" {
return ""
}
if ip := net . ParseIP ( hostname ); ip != nil {
if isPublicIP ( ip ) {
return ip . String ()
}
return ""
}
lookupCtx , cancel := context . WithTimeout ( ctx , 3 * time . Second )
defer cancel ()
addresses , err := net . DefaultResolver . LookupIPAddr ( lookupCtx , hostname )
if err != nil {
return ""
}
var ipv6 string
for _ , address := range addresses {
if ! isPublicIP ( address . IP ) {
continue
}
if address . IP . To4 () != nil {
return address . IP . String ()
}
if ipv6 == "" {
ipv6 = address . IP . String ()
}
}
return ipv6
}
func isPublicIP ( ip net . IP ) bool {
return ip != nil && ip . IsGlobalUnicast () && ! ip . IsPrivate () && ! ip . IsLoopback () && ! ip . IsLinkLocalUnicast ()
}
2026-08-12 16:15:19 +08:00
type updateBackupScheduleRequest struct {
Enabled bool `json:"enabled"`
Days int `json:"days"`
Password string `json:"password"`
ConfirmPassword string `json:"confirmPassword"`
ServerIP string `json:"serverIp"`
ChatID string `json:"chatId"`
TelegramMode string `json:"telegramMode"`
TelegramEnabled bool `json:"telegramEnabled"`
GoogleDriveEnabled bool `json:"googleDriveEnabled"`
GoogleClientID string `json:"googleClientId"`
GoogleClientSecret string `json:"googleClientSecret"`
GoogleFolderName string `json:"googleFolderName"`
}
2026-08-12 17:58:48 +08:00
type updateBackupPasswordRequest struct {
Password string `json:"password"`
ConfirmPassword string `json:"confirmPassword"`
}
2026-08-12 16:15:19 +08:00
type testBackupTelegramRequest struct {
Mode string `json:"mode"`
ChatID string `json:"chatId"`
}
type googleDriveStatus struct {
ClientID string `json:"clientId"`
ClientSecretSet bool `json:"clientSecretSet"`
Connected bool `json:"connected"`
FolderName string `json:"folderName"`
}
type googleDriveOAuthState struct {
ExpiresAt int64 `json:"expiresAt"`
Nonce string `json:"nonce"`
}
type googleDriveToken struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
Expiry time . Time `json:"expiry"`
}
func ( a * App ) requireSystemAdmin ( w http . ResponseWriter , r * http . Request ) bool {
user := currentUser ( r )
if user == nil || user . Role != "admin" {
respondError ( w , http . StatusForbidden , "system administrator required" )
return false
}
return true
}
func ( a * App ) handleListBackups ( w http . ResponseWriter , r * http . Request ) {
if ! a . requireSystemAdmin ( w , r ) {
return
}
items , err := a . listBackups ()
if err != nil {
respondError ( w , http . StatusInternalServerError , "无法读取备份列表" )
return
}
a . backupMu . Lock ()
job := a . backupJob
if job != nil {
copy := * job
job = & copy
}
2026-08-12 18:41:49 +08:00
transfers := make ([] backupTransfer , 0 , len ( a . backupTransfers ))
for _ , transfer := range a . backupTransfers {
copy := * transfer
transfers = append ( transfers , copy )
}
2026-08-12 16:15:19 +08:00
a . backupMu . Unlock ()
2026-08-12 18:41:49 +08:00
sort . Slice ( transfers , func ( i , j int ) bool { return transfers [ i ]. StartedAt . After ( transfers [ j ]. StartedAt ) })
2026-08-12 16:15:19 +08:00
schedule , _ := a . loadBackupSchedule ( r . Context ())
2026-08-12 16:59:20 +08:00
schedule . ServerIP = detectPublicServerIP ( r . Context (), a . config (). PublicHostname )
2026-08-12 16:15:19 +08:00
telegramToken , telegramDestination , _ := a . backupTelegramCredentials ( r . Context (), schedule )
respondJSON ( w , http . StatusOK , backupListResponse {
2026-08-12 16:59:20 +08:00
Enabled : a . backupAssetsAvailable (),
2026-08-12 16:15:19 +08:00
TelegramSet : strings . TrimSpace ( telegramToken ) != "" && validTelegramPrivateChatID ( telegramDestination ),
TelegramLimit : backupTelegramLimit , Job : job , Items : items , Schedule : schedule ,
2026-08-12 18:41:49 +08:00
GoogleDrive : a . loadGoogleDriveStatus ( r . Context ()), Transfers : transfers ,
2026-08-12 16:15:19 +08:00
})
}
func ( a * App ) handleCreateBackup ( w http . ResponseWriter , r * http . Request ) {
if ! a . requireSystemAdmin ( w , r ) {
return
}
var req createBackupRequest
if err := decodeJSON ( r , & req ); err != nil {
badRequest ( w , err )
return
}
2026-08-12 17:58:48 +08:00
a . backupMu . Lock ()
locked := true
defer func () {
if locked {
a . backupMu . Unlock ()
}
}()
if a . backupJob != nil && a . backupJob . Status == "running" {
respondError ( w , http . StatusConflict , "已有备份任务正在运行" )
2026-08-12 16:15:19 +08:00
return
}
2026-08-12 17:58:48 +08:00
password , err := a . savedBackupPassword ( r . Context ())
if err != nil && ! errors . Is ( err , sql . ErrNoRows ) {
respondError ( w , http . StatusInternalServerError , "无法读取已保存的备份密码" )
2026-08-12 16:15:19 +08:00
return
}
2026-08-12 17:58:48 +08:00
if password == "" {
if ! validBackupPassword ( req . Password ) {
badRequest ( w , errors . New ( "首次创建备份时,密码至少需要 8 个字符" ))
return
}
if req . Password != req . ConfirmPassword {
badRequest ( w , errors . New ( "两次输入的备份密码不一致" ))
return
}
}
2026-08-12 16:59:20 +08:00
if ! a . backupAssetsAvailable () {
2026-08-12 16:15:19 +08:00
respondError ( w , http . StatusServiceUnavailable , "当前部署尚未启用完整备份" )
return
}
2026-08-12 17:58:48 +08:00
if password == "" {
ciphertext , encryptErr := a . encryptBackupPassword ( req . Password )
if encryptErr != nil {
respondError ( w , http . StatusInternalServerError , "无法安全保存备份密码" )
return
}
now := a . now (). UTC (). Format ( time . RFC3339Nano )
if _ , err = a . db . ExecContext ( r . Context (), `INSERT INTO system_settings(key,value,updated_at) VALUES('backupPasswordCipher',?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at` , ciphertext , now ); err != nil {
respondError ( w , http . StatusInternalServerError , "无法保存备份密码" )
return
}
password = req . Password
2026-08-12 16:15:19 +08:00
}
a . backupJob = & backupJob { Status : "running" , StartedAt : a . now (). UTC ()}
a . backupMu . Unlock ()
2026-08-12 17:58:48 +08:00
locked = false
sendTelegram , uploadGoogleDrive := req . SendTelegram , req . UploadGoogleDrive
2026-08-12 16:15:19 +08:00
go func () {
ctx , cancel := context . WithTimeout ( context . Background (), 2 * time . Hour )
defer cancel ()
path , err := a . createDisasterBackup ( ctx , password )
password = ""
2026-08-12 18:41:49 +08:00
var deliveryMessages [] string
2026-08-12 16:15:19 +08:00
if err == nil {
var deliveryErrors [] error
2026-08-12 18:41:49 +08:00
if uploadGoogleDrive {
a . queueBackupTransfer ( "googleDrive" , path )
}
if sendTelegram {
a . queueBackupTransfer ( "telegram" , path )
}
2026-08-12 16:15:19 +08:00
if uploadGoogleDrive {
if driveErr := a . uploadBackupToGoogleDrive ( ctx , path ); driveErr != nil {
2026-08-12 18:41:49 +08:00
message := googleDriveUploadMessage ( driveErr )
a . finishBackupTransfer ( "googleDrive" , path , message )
deliveryMessages = append ( deliveryMessages , message )
2026-08-12 16:15:19 +08:00
deliveryErrors = append ( deliveryErrors , fmt . Errorf ( "google drive: %w" , driveErr ))
2026-08-12 18:41:49 +08:00
} else {
a . finishBackupTransfer ( "googleDrive" , path , "" )
2026-08-12 16:15:19 +08:00
}
}
if sendTelegram {
if telegramErr := a . sendBackupToTelegram ( ctx , path ); telegramErr != nil {
2026-08-12 18:41:49 +08:00
a . finishBackupTransfer ( "telegram" , path , telegramErr . Error ())
deliveryMessages = append ( deliveryMessages , "Telegram 发送失败:" + telegramErr . Error ())
2026-08-12 16:15:19 +08:00
deliveryErrors = append ( deliveryErrors , fmt . Errorf ( "telegram: %w" , telegramErr ))
2026-08-12 18:41:49 +08:00
} else {
a . finishBackupTransfer ( "telegram" , path , "" )
2026-08-12 16:15:19 +08:00
}
}
err = errors . Join ( deliveryErrors ... )
}
a . backupMu . Lock ()
if err != nil {
a . backupJob . Status = "failed"
2026-08-12 18:41:49 +08:00
if len ( deliveryMessages ) > 0 {
a . backupJob . Error = strings . Join ( deliveryMessages , "; " )
} else {
a . backupJob . Error = "本地备份创建失败,请检查服务器存储空间"
}
2026-08-12 16:15:19 +08:00
a . log . Error ( "create disaster backup" , "error" , err )
} else {
a . backupJob . Status = "success"
}
a . backupMu . Unlock ()
}()
respondJSON ( w , http . StatusAccepted , map [ string ] any { "ok" : true , "message" : "备份任务已开始" })
}
func ( a * App ) handleUpdateBackupSettings ( w http . ResponseWriter , r * http . Request ) {
if ! a . requireSystemAdmin ( w , r ) {
return
}
var req updateBackupScheduleRequest
if err := decodeJSON ( r , & req ); err != nil {
badRequest ( w , err )
return
}
if req . Days < 1 || req . Days > 365 {
badRequest ( w , errors . New ( "备份周期必须为 1 至 365 天" ))
return
}
var ciphertext string
_ = a . db . QueryRowContext ( r . Context (), `SELECT value FROM system_settings WHERE key='backupPasswordCipher'` ). Scan ( & ciphertext )
if req . Password != "" {
if ! validBackupPassword ( req . Password ) {
badRequest ( w , errors . New ( "备份密码至少需要 8 个字符" ))
return
}
if req . Password != req . ConfirmPassword {
badRequest ( w , errors . New ( "两次输入的备份密码不一致" ))
return
}
var err error
ciphertext , err = a . encryptBackupPassword ( req . Password )
if err != nil {
respondError ( w , 500 , "无法安全保存备份密码" )
return
}
}
if req . Enabled && ciphertext == "" {
badRequest ( w , errors . New ( "启用定时备份前请设置备份密码" ))
return
}
if req . Enabled && strings . TrimSpace ( a . config (). UpdateServiceToken ) == "" {
badRequest ( w , errors . New ( "当前部署缺少备份密码加密密钥,请先更新部署配置" ))
return
}
chatID := strings . TrimSpace ( req . ChatID )
if chatID != "" && ! validTelegramPrivateChatID ( chatID ) {
badRequest ( w , errors . New ( "备份 Telegram Chat ID 无效" ))
return
}
telegramMode := strings . TrimSpace ( req . TelegramMode )
if telegramMode != "custom" {
telegramMode = "system"
}
if telegramMode == "custom" && ! validTelegramPrivateChatID ( chatID ) {
badRequest ( w , errors . New ( "请选择备份群组并填写有效的 Chat ID" ))
return
}
if req . Enabled && req . TelegramEnabled {
cfg := a . config ()
destination := cfg . TelegramPrivateChatID
if telegramMode == "custom" {
destination = chatID
}
if cfg . TelegramBotToken == "" || ! validTelegramPrivateChatID ( destination ) {
badRequest ( w , errors . New ( "启用定时推送前请先在系统设置绑定 Telegram 机器人并配置接收位置" ))
return
}
}
if req . Enabled && req . GoogleDriveEnabled && ! a . loadGoogleDriveStatus ( r . Context ()). Connected {
badRequest ( w , errors . New ( "启用 Google 云端硬盘备份前请先完成授权" ))
return
}
secretCipher := ""
_ = a . db . QueryRowContext ( r . Context (), `SELECT value FROM system_settings WHERE key='backupGoogleClientSecretCipher'` ). Scan ( & secretCipher )
if strings . TrimSpace ( req . GoogleClientSecret ) != "" {
var err error
secretCipher , err = a . encryptBackupPassword ( strings . TrimSpace ( req . GoogleClientSecret ))
if err != nil {
respondError ( w , 500 , "无法安全保存 Google 客户端密钥" )
return
}
}
folderName := strings . TrimSpace ( req . GoogleFolderName )
if folderName == "" {
folderName = "NewSzxcn Backups"
}
values := map [ string ] string {
"backupScheduleEnabled" : fmt . Sprint ( req . Enabled ), "backupScheduleDays" : fmt . Sprint ( req . Days ),
2026-08-12 16:59:20 +08:00
"backupServerIp" : "" , "backupTelegramChatId" : chatID ,
2026-08-12 16:15:19 +08:00
"backupTelegramMode" : telegramMode ,
"backupPasswordCipher" : ciphertext , "backupTelegramEnabled" : fmt . Sprint ( req . TelegramEnabled ),
"backupGoogleDriveEnabled" : fmt . Sprint ( req . GoogleDriveEnabled ), "backupGoogleClientId" : strings . TrimSpace ( req . GoogleClientID ),
"backupGoogleClientSecretCipher" : secretCipher , "backupGoogleFolderName" : folderName ,
}
now := a . now (). UTC (). Format ( time . RFC3339Nano )
tx , err := a . db . BeginTx ( r . Context (), nil )
if err != nil {
respondError ( w , 500 , "保存失败" )
return
}
defer tx . Rollback ()
for key , value := range values {
if _ , err = tx . ExecContext ( r . Context (), `INSERT INTO system_settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at` , key , value , now ); err != nil {
respondError ( w , 500 , "保存失败" )
return
}
}
if err = tx . Commit (); err != nil {
respondError ( w , 500 , "保存失败" )
return
}
2026-08-12 17:58:48 +08:00
passwordHint := ""
if password , err := a . decryptBackupPassword ( ciphertext ); err == nil {
passwordHint = backupPasswordHint ( password )
}
respondJSON ( w , 200 , backupSchedule { Enabled : req . Enabled , Days : req . Days , PasswordSet : ciphertext != "" , PasswordHint : passwordHint , ServerIP : detectPublicServerIP ( r . Context (), a . config (). PublicHostname ), ChatID : chatID , TelegramMode : telegramMode , TelegramEnabled : req . TelegramEnabled , GoogleDriveEnabled : req . GoogleDriveEnabled })
}
func ( a * App ) handleUpdateBackupPassword ( w http . ResponseWriter , r * http . Request ) {
if ! a . requireSystemAdmin ( w , r ) {
return
}
var req updateBackupPasswordRequest
if err := decodeJSON ( r , & req ); err != nil {
badRequest ( w , err )
return
}
if ! validBackupPassword ( req . Password ) {
badRequest ( w , errors . New ( "备份密码至少需要 8 个字符" ))
return
}
if req . Password != req . ConfirmPassword {
badRequest ( w , errors . New ( "两次输入的备份密码不一致" ))
return
}
ciphertext , err := a . encryptBackupPassword ( req . Password )
if err != nil {
respondError ( w , http . StatusInternalServerError , "无法安全保存备份密码" )
return
}
now := a . now (). UTC (). Format ( time . RFC3339Nano )
if _ , err = a . db . ExecContext ( r . Context (), `INSERT INTO system_settings(key,value,updated_at) VALUES('backupPasswordCipher',?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at` , ciphertext , now ); err != nil {
respondError ( w , http . StatusInternalServerError , "无法保存备份密码" )
return
}
respondJSON ( w , http . StatusOK , map [ string ] any { "passwordSet" : true , "passwordHint" : backupPasswordHint ( req . Password )})
2026-08-12 16:15:19 +08:00
}
func validBackupPassword ( password string ) bool {
return len ( password ) >= 8 && len ( password ) <= 1024 && ! strings . ContainsAny ( password , "\r\n\x00" )
}
2026-08-12 17:58:48 +08:00
func backupPasswordHint ( password string ) string {
runes := [] rune ( password )
if len ( runes ) < 2 {
return ""
}
return string ( runes [ 0 ]) + strings . Repeat ( "•" , minimumInt ( len ( runes ) - 2 , 10 )) + string ( runes [ len ( runes ) - 1 ])
}
func ( a * App ) savedBackupPassword ( ctx context . Context ) ( string , error ) {
var ciphertext string
if err := a . db . QueryRowContext ( ctx , `SELECT value FROM system_settings WHERE key='backupPasswordCipher'` ). Scan ( & ciphertext ); err != nil {
return "" , err
}
if strings . TrimSpace ( ciphertext ) == "" {
return "" , sql . ErrNoRows
}
return a . decryptBackupPassword ( ciphertext )
}
2026-08-12 16:15:19 +08:00
func ( a * App ) handleTestBackupTelegram ( w http . ResponseWriter , r * http . Request ) {
if ! a . requireSystemAdmin ( w , r ) {
return
}
var req testBackupTelegramRequest
if err := decodeJSON ( r , & req ); err != nil {
badRequest ( w , err )
return
}
cfg := a . config ()
token , chatID := strings . TrimSpace ( cfg . TelegramBotToken ), strings . TrimSpace ( cfg . TelegramPrivateChatID )
if req . Mode == "custom" {
chatID = strings . TrimSpace ( req . ChatID )
}
if token == "" || ! validTelegramPrivateChatID ( chatID ) {
badRequest ( w , errors . New ( "请先完成 Telegram 机器人和 Chat ID 配置" ))
return
}
now := a . now (). Local (). Format ( "2006-01-02 15:04:05 MST" )
message := "<b>NewSzxcn 备份通知测试</b>\n\nTelegram 备份接收配置正常。\n\n<b>测试时间:</b>" + htmlEscape ( now )
if err := a . sendTelegramMessage ( r . Context (), token , chatID , message ); err != nil {
respondError ( w , http . StatusBadGateway , err . Error ())
return
}
respondJSON ( w , http . StatusOK , map [ string ] any { "ok" : true })
}
func ( a * App ) handleDiscoverBackupTelegramGroup ( w http . ResponseWriter , r * http . Request ) {
if ! a . requireSystemAdmin ( w , r ) {
return
}
var req telegramCredentialsRequest
if err := decodeJSON ( r , & req ); err != nil {
badRequest ( w , err )
return
}
token := strings . TrimSpace ( a . config (). TelegramBotToken )
code := strings . ToUpper ( strings . TrimSpace ( req . PairingCode ))
if token == "" || code == "" {
badRequest ( w , errors . New ( "请先生成 Telegram 群组查询码" ))
return
}
a . telegramPairMu . Lock ()
pairing , ok := a . telegramPairs [ code ]
a . telegramPairMu . Unlock ()
if ! ok || ! pairing . ExpiresAt . After ( a . now (). UTC ()) || pairing . TokenFingerprint != telegramTokenFingerprint ( token ) {
badRequest ( w , errors . New ( "Telegram 群组查询码无效或已过期,请重新生成" ))
return
}
groups , err := a . discoverTelegramGroups ( r . Context (), token , code )
if err != nil {
respondError ( w , http . StatusBadGateway , err . Error ())
return
}
a . telegramPairMu . Lock ()
delete ( a . telegramPairs , code )
a . telegramPairMu . Unlock ()
respondJSON ( w , http . StatusOK , map [ string ] any { "items" : groups })
}
func ( a * App ) googleDriveOAuthConfig ( ctx context . Context ) ( * oauth2 . Config , error ) {
values := map [ string ] string {}
rows , err := a . db . QueryContext ( ctx , `SELECT key,value FROM system_settings WHERE key IN ('backupGoogleClientId','backupGoogleClientSecretCipher')` )
if err != nil {
return nil , err
}
defer rows . Close ()
for rows . Next () {
var key , value string
if err := rows . Scan ( & key , & value ); err != nil {
return nil , err
}
values [ key ] = value
}
secret := ""
if values [ "backupGoogleClientSecretCipher" ] != "" {
secret , err = a . decryptBackupPassword ( values [ "backupGoogleClientSecretCipher" ])
if err != nil {
return nil , err
}
}
if strings . TrimSpace ( values [ "backupGoogleClientId" ]) == "" || secret == "" {
return nil , errors . New ( "请先填写并保存 Google OAuth 客户端 ID 和密钥" )
}
return & oauth2 . Config { ClientID : values [ "backupGoogleClientId" ], ClientSecret : secret , RedirectURL : strings . TrimRight ( a . config (). PublicBaseURL , "/" ) + "/api/admin/backups/google-drive/callback" , Scopes : [] string { "https://www.googleapis.com/auth/drive.file" }, Endpoint : oauth2 . Endpoint { AuthURL : "https://accounts.google.com/o/oauth2/v2/auth" , TokenURL : "https://oauth2.googleapis.com/token" }}, nil
}
func ( a * App ) handleGoogleDriveConnect ( w http . ResponseWriter , r * http . Request ) {
if ! a . requireSystemAdmin ( w , r ) {
return
}
conf , err := a . googleDriveOAuthConfig ( r . Context ())
if err != nil {
badRequest ( w , err )
return
}
raw , _ := json . Marshal ( googleDriveOAuthState { ExpiresAt : a . now (). Add ( 10 * time . Minute ). Unix (), Nonce : newID ( "drive" )})
state , err := a . encryptBackupPassword ( string ( raw ))
if err != nil {
respondError ( w , 500 , "无法创建授权请求" )
return
}
state = base64 . RawURLEncoding . EncodeToString ([] byte ( state ))
respondJSON ( w , 200 , map [ string ] string { "url" : conf . AuthCodeURL ( state , oauth2 . AccessTypeOffline , oauth2 . ApprovalForce )})
}
func ( a * App ) handleGoogleDriveCallback ( w http . ResponseWriter , r * http . Request ) {
if ! a . requireSystemAdmin ( w , r ) {
return
}
encoded , err := base64 . RawURLEncoding . DecodeString ( r . URL . Query (). Get ( "state" ))
if err != nil {
badRequest ( w , errors . New ( "Google 授权状态无效" ))
return
}
plain , err := a . decryptBackupPassword ( string ( encoded ))
if err != nil {
badRequest ( w , errors . New ( "Google 授权状态无效" ))
return
}
var state googleDriveOAuthState
if json . Unmarshal ([] byte ( plain ), & state ) != nil || state . ExpiresAt < a . now (). Unix () {
badRequest ( w , errors . New ( "Google 授权已过期,请重新连接" ))
return
}
if oauthErr := r . URL . Query (). Get ( "error" ); oauthErr != "" {
http . Redirect ( w , r , strings . TrimRight ( a . config (). PublicBaseURL , "/" ) + "/admin?section=backups&drive=error" , http . StatusFound )
return
}
conf , err := a . googleDriveOAuthConfig ( r . Context ())
if err != nil {
badRequest ( w , err )
return
}
ctx , cancel := context . WithTimeout ( r . Context (), 30 * time . Second )
defer cancel ()
token , err := conf . Exchange ( ctx , r . URL . Query (). Get ( "code" ))
if err != nil {
respondError ( w , 400 , "Google 授权交换失败" )
return
}
raw , _ := json . Marshal ( googleDriveToken { AccessToken : token . AccessToken , RefreshToken : token . RefreshToken , Expiry : token . Expiry })
ciphertext , err := a . encryptBackupPassword ( string ( raw ))
if err != nil {
respondError ( w , 500 , "无法保存 Google 授权" )
return
}
now := a . now (). UTC (). Format ( time . RFC3339Nano )
_ , err = a . db . ExecContext ( r . Context (), `INSERT INTO system_settings(key,value,updated_at) VALUES('backupGoogleTokenCipher',?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at` , ciphertext , now )
if err != nil {
respondError ( w , 500 , "无法保存 Google 授权" )
return
}
http . Redirect ( w , r , strings . TrimRight ( a . config (). PublicBaseURL , "/" ) + "/admin?section=backups&drive=connected" , http . StatusFound )
}
func ( a * App ) handleGoogleDriveDisconnect ( w http . ResponseWriter , r * http . Request ) {
if ! a . requireSystemAdmin ( w , r ) {
return
}
_ , err := a . db . ExecContext ( r . Context (), `DELETE FROM system_settings WHERE key IN ('backupGoogleTokenCipher','backupGoogleDriveEnabled')` )
if err != nil {
respondError ( w , 500 , "断开失败" )
return
}
respondJSON ( w , 200 , map [ string ] bool { "ok" : true })
}
func ( a * App ) loadGoogleDriveStatus ( ctx context . Context ) googleDriveStatus {
status := googleDriveStatus { FolderName : "NewSzxcn Backups" }
rows , err := a . db . QueryContext ( ctx , `SELECT key,value FROM system_settings WHERE key IN ('backupGoogleClientId','backupGoogleClientSecretCipher','backupGoogleTokenCipher','backupGoogleFolderName')` )
if err != nil {
return status
}
defer rows . Close ()
for rows . Next () {
var key , value string
if rows . Scan ( & key , & value ) != nil {
continue
}
switch key {
case "backupGoogleClientId" :
status . ClientID = value
case "backupGoogleClientSecretCipher" :
status . ClientSecretSet = value != ""
case "backupGoogleTokenCipher" :
status . Connected = value != ""
case "backupGoogleFolderName" :
if strings . TrimSpace ( value ) != "" {
status . FolderName = value
}
}
}
return status
}
func ( a * App ) createDisasterBackup ( ctx context . Context , password string ) ( string , error ) {
cfg := a . config ()
2026-08-12 16:59:20 +08:00
if ! a . backupAssetsAvailable () {
2026-08-12 16:15:19 +08:00
return "" , errors . New ( "backup directories are not configured" )
}
if err := os . MkdirAll ( cfg . BackupDir , 0 o700 ); err != nil {
return "" , err
}
work , err := os . MkdirTemp ( cfg . BackupDir , ".staging-" )
if err != nil {
return "" , err
}
defer os . RemoveAll ( work )
root := filepath . Join ( work , "newszxcn-backup" )
for _ , dir := range [] string { "data" , "mail" , "dkim" , "certs" } {
if err := os . MkdirAll ( filepath . Join ( root , dir ), 0 o700 ); err != nil {
return "" , err
}
}
quoted := strings . ReplaceAll ( filepath . Join ( root , "data" , "lanqin.db" ), "'" , "''" )
if _ , err := a . db . ExecContext ( ctx , "VACUUM INTO '" + quoted + "'" ); err != nil {
return "" , err
}
if err := copyTree ( cfg . DataDir , filepath . Join ( root , "data" ), map [ string ] bool { "lanqin.db" : true , "lanqin.db-wal" : true , "lanqin.db-shm" : true , "backups" : true , "disaster-backups" : true }); err != nil {
return "" , err
}
if cfg . MaildirRoot != "" {
if err := copyTree ( cfg . MaildirRoot , filepath . Join ( root , "mail" ), nil ); err != nil {
return "" , err
}
}
for _ , item := range [] struct { src , dst string }{{ "/var/lib/rspamd/dkim" , "dkim" }, { "/certs" , "certs" }} {
if err := copyTree ( item . src , filepath . Join ( root , item . dst ), nil ); err != nil && ! os . IsNotExist ( err ) {
return "" , err
}
}
2026-08-12 16:59:20 +08:00
if err := copyFile ( filepath . Join ( cfg . BackupSourceDir , "docker-compose.yml" ), filepath . Join ( root , "docker-compose.yml" )); err != nil {
return "" , err
}
if err := copyFile ( filepath . Join ( cfg . BackupSourceDir , ".env" ), filepath . Join ( root , ".env" )); err != nil {
if ! os . IsNotExist ( err ) {
return "" , err
}
if err := writeRuntimeBackupEnv ( filepath . Join ( root , ".env" )); err != nil {
2026-08-12 16:15:19 +08:00
return "" , err
}
}
manifest := map [ string ] any { "format" : 1 , "version" : cfg . AppVersion , "createdAt" : a . now (). UTC (), "hostname" : cfg . PublicHostname }
raw , _ := json . MarshalIndent ( manifest , "" , " " )
if err := os . WriteFile ( filepath . Join ( root , "manifest.json" ), raw , 0 o600 ); err != nil {
return "" , err
}
tarPath := filepath . Join ( work , "backup.tar" )
if err := writeTar ( tarPath , root ); err != nil {
return "" , err
}
zstPath := tarPath + ".zst"
if output , err := exec . CommandContext ( ctx , "zstd" , "-q" , "-T0" , "-10" , tarPath , "-o" , zstPath ). CombinedOutput (); err != nil {
return "" , fmt . Errorf ( "zstd: %w: %s" , err , output )
}
name := fmt . Sprintf ( "newszxcn-backup-%s-%s.tar.zst.enc" , a . now (). UTC (). Format ( "20060102-150405" ), strings . TrimPrefix ( cfg . AppVersion , "v" ))
outPath := filepath . Join ( cfg . BackupDir , name )
cmd := exec . CommandContext ( ctx , "openssl" , "enc" , "-aes-256-cbc" , "-salt" , "-pbkdf2" , "-iter" , "200000" , "-md" , "sha256" , "-in" , zstPath , "-out" , outPath , "-pass" , "stdin" )
cmd . Stdin = strings . NewReader ( password )
if output , err := cmd . CombinedOutput (); err != nil {
os . Remove ( outPath )
return "" , fmt . Errorf ( "openssl: %w: %s" , err , output )
}
if err := os . Chmod ( outPath , 0 o600 ); err != nil {
_ = os . Remove ( outPath )
return "" , err
}
sum , err := fileSHA256 ( outPath )
if err != nil {
_ = os . Remove ( outPath )
return "" , err
}
if err := os . WriteFile ( outPath + ".sha256" , [] byte ( sum + " " + name + "\n" ), 0 o600 ); err != nil {
_ = os . Remove ( outPath )
return "" , err
}
if err := a . pruneDisasterBackups ( 10 ); err != nil {
a . log . Warn ( "prune disaster backups" , "error" , err )
}
return outPath , nil
}
2026-08-12 16:59:20 +08:00
func ( a * App ) backupAssetsAvailable () bool {
cfg := a . config ()
if strings . TrimSpace ( cfg . BackupDir ) == "" || strings . TrimSpace ( cfg . BackupSourceDir ) == "" {
return false
}
info , err := os . Stat ( filepath . Join ( cfg . BackupSourceDir , "docker-compose.yml" ))
return err == nil && info . Mode (). IsRegular ()
}
func writeRuntimeBackupEnv ( path string ) error {
values := make ([] string , 0 )
containerOnly := map [ string ] bool {
2026-08-12 17:58:48 +08:00
"LANQIN_BACKUP_DIR" : true ,
"LANQIN_BACKUP_SOURCE_DIR" : true ,
2026-08-12 16:59:20 +08:00
"LANQIN_UPDATE_SERVICE_TOKEN" : true ,
"LANQIN_UPDATE_SERVICE_URL" : true ,
}
for _ , item := range os . Environ () {
key , value , found := strings . Cut ( item , "=" )
if ! found || containerOnly [ key ] || (! strings . HasPrefix ( key , "LANQIN_" ) && key != "TZ" ) {
continue
}
value = strings . ReplaceAll ( value , "\\" , "\\\\" )
value = strings . ReplaceAll ( value , "'" , "\\'" )
value = strings . ReplaceAll ( value , "\r" , "\\r" )
value = strings . ReplaceAll ( value , "\n" , "\\n" )
values = append ( values , key + "='" + value + "'" )
}
sort . Strings ( values )
return os . WriteFile ( path , [] byte ( strings . Join ( values , "\n" ) + "\n" ), 0 o600 )
}
2026-08-12 16:15:19 +08:00
func ( a * App ) handleDownloadBackup ( w http . ResponseWriter , r * http . Request ) {
if ! a . requireSystemAdmin ( w , r ) {
return
}
path , ok := a . backupPath ( chi . URLParam ( r , "name" ))
if ! ok {
respondError ( w , http . StatusNotFound , "备份不存在" )
return
}
w . Header (). Set ( "Content-Disposition" , fmt . Sprintf ( "attachment; filename=%q" , filepath . Base ( path )))
w . Header (). Set ( "Content-Type" , "application/octet-stream" )
http . ServeFile ( w , r , path )
}
func ( a * App ) handleVerifyBackup ( w http . ResponseWriter , r * http . Request ) {
if ! a . requireSystemAdmin ( w , r ) {
return
}
path , ok := a . backupPath ( chi . URLParam ( r , "name" ))
if ! ok {
respondError ( w , http . StatusNotFound , "备份不存在" )
return
}
actual , err := fileSHA256 ( path )
if err != nil {
respondError ( w , 500 , "校验失败" )
return
}
expectedRaw , err := os . ReadFile ( path + ".sha256" )
if err != nil {
respondError ( w , 500 , "校验文件缺失" )
return
}
expected := strings . Fields ( string ( expectedRaw ))
valid := len ( expected ) > 0 && expected [ 0 ] == actual
respondJSON ( w , 200 , map [ string ] any { "ok" : valid , "sha256" : actual })
}
func ( a * App ) handleDeleteBackup ( w http . ResponseWriter , r * http . Request ) {
if ! a . requireSystemAdmin ( w , r ) {
return
}
path , ok := a . backupPath ( chi . URLParam ( r , "name" ))
if ! ok {
respondError ( w , 404 , "备份不存在" )
return
}
if err := os . Remove ( path ); err != nil {
respondError ( w , 500 , "删除失败" )
return
}
_ = os . Remove ( path + ".sha256" )
2026-08-12 18:41:49 +08:00
a . backupMu . Lock ()
delete ( a . backupTransfers , backupTransferKey ( "telegram" , path ))
delete ( a . backupTransfers , backupTransferKey ( "googleDrive" , path ))
a . backupMu . Unlock ()
2026-08-12 16:15:19 +08:00
respondJSON ( w , 200 , map [ string ] any { "ok" : true })
}
func ( a * App ) handleSendBackupTelegram ( w http . ResponseWriter , r * http . Request ) {
if ! a . requireSystemAdmin ( w , r ) {
return
}
path , ok := a . backupPath ( chi . URLParam ( r , "name" ))
if ! ok {
respondError ( w , 404 , "备份不存在" )
return
}
2026-08-12 18:41:49 +08:00
if ! a . startBackupTransfer ( "telegram" , path ) {
respondError ( w , http . StatusConflict , "该备份正在发送到 Telegram" )
2026-08-12 16:15:19 +08:00
return
}
2026-08-12 18:41:49 +08:00
go func () {
ctx , cancel := context . WithTimeout ( context . Background (), 2 * time . Hour )
defer cancel ()
if err := a . sendBackupToTelegram ( ctx , path ); err != nil {
a . finishBackupTransfer ( "telegram" , path , err . Error ())
a . log . Error ( "send backup telegram" , "error" , err )
return
}
a . finishBackupTransfer ( "telegram" , path , "" )
}()
respondJSON ( w , http . StatusAccepted , map [ string ] any { "ok" : true })
2026-08-12 16:15:19 +08:00
}
func ( a * App ) handleSendBackupGoogleDrive ( w http . ResponseWriter , r * http . Request ) {
if ! a . requireSystemAdmin ( w , r ) {
return
}
path , ok := a . backupPath ( chi . URLParam ( r , "name" ))
if ! ok {
respondError ( w , 404 , "备份不存在" )
return
}
2026-08-12 18:41:49 +08:00
if ! a . startBackupTransfer ( "googleDrive" , path ) {
respondError ( w , http . StatusConflict , "该备份正在上传到 Google 云端硬盘" )
2026-08-12 16:15:19 +08:00
return
}
2026-08-12 18:41:49 +08:00
go func () {
ctx , cancel := context . WithTimeout ( context . Background (), 2 * time . Hour )
defer cancel ()
if err := a . uploadBackupToGoogleDrive ( ctx , path ); err != nil {
message := googleDriveUploadMessage ( err )
a . finishBackupTransfer ( "googleDrive" , path , message )
a . log . Error ( "upload backup to google drive" , "error" , err )
return
}
a . finishBackupTransfer ( "googleDrive" , path , "" )
}()
respondJSON ( w , http . StatusAccepted , map [ string ] bool { "ok" : true })
}
func backupTransferKey ( provider , path string ) string {
return provider + ":" + filepath . Base ( path )
}
func ( a * App ) startBackupTransfer ( provider , path string ) bool {
info , err := os . Stat ( path )
if err != nil {
return false
}
key := backupTransferKey ( provider , path )
a . backupMu . Lock ()
defer a . backupMu . Unlock ()
if transfer := a . backupTransfers [ key ]; transfer != nil && transfer . Status == "running" {
return false
}
a . backupTransfers [ key ] = & backupTransfer { Provider : provider , Name : filepath . Base ( path ), Status : "running" , Total : info . Size (), StartedAt : a . now (). UTC ()}
return true
}
func ( a * App ) ensureBackupTransfer ( provider , path string ) {
info , err := os . Stat ( path )
if err != nil {
return
}
key := backupTransferKey ( provider , path )
a . backupMu . Lock ()
defer a . backupMu . Unlock ()
if transfer := a . backupTransfers [ key ]; transfer == nil {
a . backupTransfers [ key ] = & backupTransfer { Provider : provider , Name : filepath . Base ( path ), Status : "running" , Total : info . Size (), StartedAt : a . now (). UTC ()}
} else if transfer . Status == "queued" {
transfer . Status = "running"
}
}
func ( a * App ) queueBackupTransfer ( provider , path string ) {
info , err := os . Stat ( path )
if err != nil {
return
}
a . backupMu . Lock ()
defer a . backupMu . Unlock ()
a . backupTransfers [ backupTransferKey ( provider , path )] = & backupTransfer { Provider : provider , Name : filepath . Base ( path ), Status : "queued" , Total : info . Size (), StartedAt : a . now (). UTC ()}
}
func ( a * App ) updateBackupTransfer ( provider , path string , uploaded int64 ) {
a . backupMu . Lock ()
defer a . backupMu . Unlock ()
if transfer := a . backupTransfers [ backupTransferKey ( provider , path )]; transfer != nil {
transfer . Uploaded = uploaded
}
}
func ( a * App ) finishBackupTransfer ( provider , path , message string ) {
a . backupMu . Lock ()
defer a . backupMu . Unlock ()
if transfer := a . backupTransfers [ backupTransferKey ( provider , path )]; transfer != nil {
transfer . FinishedAt = a . now (). UTC ()
if message == "" {
transfer . Status = "success"
transfer . Uploaded = transfer . Total
} else {
transfer . Status = "failed"
transfer . Error = message
}
}
2026-08-12 16:15:19 +08:00
}
func ( a * App ) googleDriveClient ( ctx context . Context ) ( * http . Client , error ) {
conf , err := a . googleDriveOAuthConfig ( ctx )
if err != nil {
return nil , err
}
var ciphertext string
if err := a . db . QueryRowContext ( ctx , `SELECT value FROM system_settings WHERE key='backupGoogleTokenCipher'` ). Scan ( & ciphertext ); err != nil {
return nil , errors . New ( "Google 云端硬盘尚未连接" )
}
plain , err := a . decryptBackupPassword ( ciphertext )
if err != nil {
return nil , err
}
var saved googleDriveToken
if err := json . Unmarshal ([] byte ( plain ), & saved ); err != nil {
return nil , err
}
original := & oauth2 . Token { AccessToken : saved . AccessToken , RefreshToken : saved . RefreshToken , Expiry : saved . Expiry , TokenType : "Bearer" }
refreshed , err := conf . TokenSource ( ctx , original ). Token ()
if err != nil {
return nil , err
}
if refreshed . AccessToken != original . AccessToken || ! refreshed . Expiry . Equal ( original . Expiry ) {
refreshToken := refreshed . RefreshToken
if refreshToken == "" {
refreshToken = saved . RefreshToken
}
raw , _ := json . Marshal ( googleDriveToken { AccessToken : refreshed . AccessToken , RefreshToken : refreshToken , Expiry : refreshed . Expiry })
ciphertext , encryptErr := a . encryptBackupPassword ( string ( raw ))
if encryptErr != nil {
return nil , encryptErr
}
now := a . now (). UTC (). Format ( time . RFC3339Nano )
if _ , err := a . db . ExecContext ( ctx , `INSERT INTO system_settings(key,value,updated_at) VALUES('backupGoogleTokenCipher',?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at` , ciphertext , now ); err != nil {
return nil , err
}
}
return oauth2 . NewClient ( ctx , oauth2 . StaticTokenSource ( refreshed )), nil
}
func ( a * App ) googleDriveFolderID ( ctx context . Context , client * http . Client , name string ) ( string , error ) {
escaped := strings . ReplaceAll ( name , "'" , "\\'" )
query := fmt . Sprintf ( "name = '%s' and mimeType = 'application/vnd.google-apps.folder' and trashed = false" , escaped )
req , _ := http . NewRequestWithContext ( ctx , http . MethodGet , "https://www.googleapis.com/drive/v3/files?spaces=drive&fields=files(id,name)&pageSize=1&q=" + url . QueryEscape ( query ), nil )
resp , err := client . Do ( req )
if err != nil {
return "" , err
}
defer resp . Body . Close ()
if resp . StatusCode < 200 || resp . StatusCode >= 300 {
raw , _ := io . ReadAll ( io . LimitReader ( resp . Body , 4096 ))
2026-08-12 18:41:49 +08:00
return "" , & googleDriveAPIError { Operation : "folder lookup" , StatusCode : resp . StatusCode , Body : string ( raw )}
2026-08-12 16:15:19 +08:00
}
var list struct {
Files [] struct {
ID string `json:"id"`
} `json:"files"`
}
if err := json . NewDecoder ( resp . Body ). Decode ( & list ); err != nil {
return "" , err
}
if len ( list . Files ) > 0 {
return list . Files [ 0 ]. ID , nil
}
body , _ := json . Marshal ( map [ string ] any { "name" : name , "mimeType" : "application/vnd.google-apps.folder" })
req , _ = http . NewRequestWithContext ( ctx , http . MethodPost , "https://www.googleapis.com/drive/v3/files?fields=id" , strings . NewReader ( string ( body )))
req . Header . Set ( "Content-Type" , "application/json" )
resp , err = client . Do ( req )
if err != nil {
return "" , err
}
defer resp . Body . Close ()
if resp . StatusCode < 200 || resp . StatusCode >= 300 {
raw , _ := io . ReadAll ( io . LimitReader ( resp . Body , 4096 ))
2026-08-12 18:41:49 +08:00
return "" , & googleDriveAPIError { Operation : "folder create" , StatusCode : resp . StatusCode , Body : string ( raw )}
2026-08-12 16:15:19 +08:00
}
var created struct {
ID string `json:"id"`
}
if err := json . NewDecoder ( resp . Body ). Decode ( & created ); err != nil {
return "" , err
}
if created . ID == "" {
return "" , errors . New ( "Google 云端硬盘未返回文件夹 ID" )
}
return created . ID , nil
}
func ( a * App ) uploadBackupToGoogleDrive ( ctx context . Context , path string ) error {
2026-08-12 18:41:49 +08:00
a . ensureBackupTransfer ( "googleDrive" , path )
2026-08-12 16:15:19 +08:00
client , err := a . googleDriveClient ( ctx )
if err != nil {
return err
}
folder := a . loadGoogleDriveStatus ( ctx ). FolderName
folderID , err := a . googleDriveFolderID ( ctx , client , folder )
if err != nil {
return err
}
2026-08-12 18:41:49 +08:00
req , size , err := newGoogleDriveResumableRequest ( ctx , path , folderID )
2026-08-12 16:15:19 +08:00
if err != nil {
return err
}
resp , err := client . Do ( req )
if err != nil {
return err
}
defer resp . Body . Close ()
if resp . StatusCode < 200 || resp . StatusCode >= 300 {
raw , _ := io . ReadAll ( io . LimitReader ( resp . Body , 4096 ))
2026-08-12 18:41:49 +08:00
return & googleDriveAPIError { Operation : "start upload" , StatusCode : resp . StatusCode , Body : string ( raw )}
2026-08-12 16:15:19 +08:00
}
2026-08-12 18:41:49 +08:00
location := strings . TrimSpace ( resp . Header . Get ( "Location" ))
if location == "" {
return errors . New ( "Google 云端硬盘未返回可恢复上传地址" )
}
return a . uploadGoogleDriveChunks ( ctx , client , location , path , size )
}
type googleDriveAPIError struct {
Operation string
StatusCode int
Body string
2026-08-12 16:15:19 +08:00
}
2026-08-12 18:41:49 +08:00
func ( e * googleDriveAPIError ) Error () string {
return fmt . Sprintf ( "google drive %s returned %d: %s" , e . Operation , e . StatusCode , e . Body )
}
func newGoogleDriveResumableRequest ( ctx context . Context , path , folderID string ) ( * http . Request , int64 , error ) {
info , err := os . Stat ( path )
if err != nil {
return nil , 0 , err
}
metadata , _ := json . Marshal ( map [ string ] any { "name" : filepath . Base ( path ), "parents" : [] string { folderID }})
req , err := http . NewRequestWithContext ( ctx , http . MethodPost , "https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable&fields=id,name" , strings . NewReader ( string ( metadata )))
if err != nil {
return nil , 0 , err
}
req . Header . Set ( "Content-Type" , "application/json; charset=UTF-8" )
req . Header . Set ( "X-Upload-Content-Type" , "application/octet-stream" )
req . Header . Set ( "X-Upload-Content-Length" , fmt . Sprint ( info . Size ()))
return req , info . Size (), nil
}
func ( a * App ) uploadGoogleDriveChunks ( ctx context . Context , client * http . Client , location , path string , size int64 ) error {
2026-08-12 16:15:19 +08:00
file , err := os . Open ( path )
if err != nil {
2026-08-12 18:41:49 +08:00
return err
2026-08-12 16:15:19 +08:00
}
2026-08-12 18:41:49 +08:00
defer file . Close ()
for offset := int64 ( 0 ); offset < size ; {
length := int64 ( googleDriveUploadChunkSize )
if remaining := size - offset ; remaining < length {
length = remaining
2026-08-12 16:15:19 +08:00
}
2026-08-12 18:41:49 +08:00
end := offset + length - 1
req , err := http . NewRequestWithContext ( ctx , http . MethodPut , location , io . NewSectionReader ( file , offset , length ))
if err != nil {
return err
2026-08-12 16:15:19 +08:00
}
2026-08-12 18:41:49 +08:00
req . ContentLength = length
req . Header . Set ( "Content-Type" , "application/octet-stream" )
req . Header . Set ( "Content-Range" , fmt . Sprintf ( "bytes %d-%d/%d" , offset , end , size ))
resp , err := client . Do ( req )
2026-08-12 16:15:19 +08:00
if err != nil {
2026-08-12 18:41:49 +08:00
return err
2026-08-12 16:15:19 +08:00
}
2026-08-12 18:41:49 +08:00
raw , _ := io . ReadAll ( io . LimitReader ( resp . Body , 4096 ))
_ = resp . Body . Close ()
if resp . StatusCode == http . StatusPermanentRedirect {
offset += length
a . updateBackupTransfer ( "googleDrive" , path , offset )
continue
}
if resp . StatusCode >= 200 && resp . StatusCode < 300 && end + 1 == size {
a . updateBackupTransfer ( "googleDrive" , path , size )
return nil
}
return & googleDriveAPIError { Operation : "upload chunk" , StatusCode : resp . StatusCode , Body : string ( raw )}
2026-08-12 16:15:19 +08:00
}
2026-08-12 18:41:49 +08:00
return errors . New ( "Google 云端硬盘不能上传空备份文件" )
}
func googleDriveUploadMessage ( err error ) string {
if errors . Is ( err , context . Canceled ) || errors . Is ( err , context . DeadlineExceeded ) {
return "Google 云端硬盘上传超时,请检查服务器网络后重试"
}
var apiErr * googleDriveAPIError
if errors . As ( err , & apiErr ) {
body := strings . ToLower ( apiErr . Body )
switch {
case apiErr . StatusCode == http . StatusUnauthorized || strings . Contains ( body , "invalid_grant" ):
return "Google 授权已失效,请打开配置,断开后重新连接"
case strings . Contains ( body , "storagequota" ) || strings . Contains ( body , "storage quota" ):
return "Google 云端硬盘空间不足,请清理空间后重试"
case apiErr . StatusCode == http . StatusTooManyRequests || strings . Contains ( body , "ratelimit" ):
return "Google 云端硬盘请求过于频繁,请稍后重试"
case apiErr . StatusCode == http . StatusForbidden :
return "Google 云端硬盘无上传权限,请确认 Drive API 已启用并重新连接"
}
}
message := strings . ToLower ( err . Error ())
if strings . Contains ( message , "oauth2" ) || strings . Contains ( message , "token" ) {
return "Google 授权已失效,请打开配置,断开后重新连接"
}
return "上传 Google 云端硬盘失败,请检查服务器网络或重新连接 Google 账号"
2026-08-12 16:15:19 +08:00
}
func ( a * App ) sendBackupToTelegram ( ctx context . Context , path string ) error {
2026-08-12 18:41:49 +08:00
a . ensureBackupTransfer ( "telegram" , path )
2026-08-12 16:15:19 +08:00
schedule , _ := a . loadBackupSchedule ( ctx )
token , chatID , err := a . backupTelegramCredentials ( ctx , schedule )
if err != nil {
return err
}
if token == "" || ! validTelegramPrivateChatID ( chatID ) {
return errors . New ( "请先在系统设置绑定 Telegram 机器人" )
}
info , err := os . Stat ( path )
if err != nil {
return err
}
if info . Size () > backupTelegramLimit {
return errors . New ( "备份超过 Telegram 发送上限,请下载后保存到其他存储" )
}
report , err := a . backupTelegramReport ( ctx , path , info )
if err != nil {
return err
}
if err := a . sendTelegramMessage ( ctx , token , chatID , report ); err != nil {
return err
}
return a . sendTelegramDocument ( ctx , token , chatID , path )
}
func ( a * App ) backupTelegramCredentials ( ctx context . Context , schedule backupSchedule ) ( string , string , error ) {
cfg := a . config ()
if schedule . TelegramMode != "custom" {
return strings . TrimSpace ( cfg . TelegramBotToken ), strings . TrimSpace ( cfg . TelegramPrivateChatID ), nil
}
return strings . TrimSpace ( cfg . TelegramBotToken ), strings . TrimSpace ( schedule . ChatID ), nil
}
func ( a * App ) backupTelegramReport ( ctx context . Context , path string , info os . FileInfo ) ( string , error ) {
cfg := a . config ()
sum , _ := fileSHA256 ( path )
domains , err := queryBackupStrings ( ctx , a . db , `SELECT name FROM domains ORDER BY name` )
if err != nil {
return "" , err
}
admins , err := queryBackupStrings ( ctx , a . db , `SELECT email FROM users WHERE role='admin' ORDER BY email` )
if err != nil {
return "" , err
}
users , err := queryBackupStrings ( ctx , a . db , `SELECT email FROM users WHERE role='user' ORDER BY email` )
if err != nil {
return "" , err
}
mailboxes , err := queryBackupStrings ( ctx , a . db , `SELECT address FROM mailboxes ORDER BY address` )
if err != nil {
return "" , err
}
list := func ( items [] string ) string {
if len ( items ) == 0 {
return "无"
}
total , suffix := len ( items ), ""
if total > 10 {
items = items [: 10 ]
suffix = fmt . Sprintf ( " 等 %d 个" , total )
}
for i := range items {
value , truncated := truncateRunes ( items [ i ], 80 )
if truncated {
value += "..."
}
items [ i ] = htmlEscape ( value )
}
return strings . Join ( items , "、" ) + suffix
}
2026-08-12 16:59:20 +08:00
serverIP := detectPublicServerIP ( ctx , cfg . PublicHostname )
2026-08-12 16:15:19 +08:00
if serverIP == "" {
2026-08-12 16:59:20 +08:00
serverIP = "未检测到"
2026-08-12 16:15:19 +08:00
}
return fmt . Sprintf ( "<b>%s 备份成功</b>\n\n<b>邮局域名:</b>%s\n<b>服务器 IP: </b>%s\n<b>系统版本:</b>%s\n\n<b>已有域名:</b>\n%s\n\n<b>管理员账号:</b>\n%s\n\n<b>普通用户账号:</b>\n%s\n\n<b>邮箱账号:</b>\n%s\n\n<b>备份文件:</b>%s\n<b>文件大小:</b>%s\n<b>SHA-256: </b><code>%s</code>\n\n<b>恢复教程:</b>\n1. 请不要解压、改名或修改压缩备份文件。\n2. 将原始附件上传到新服务器的 <code>/root/</code> 目录。\n3. 运行官方安装脚本,显示管理菜单后输入 2,选择“备份恢复”。\n4. 选择“本地上传”,系统会自动检测 /root/ 中的备份。\n5. 只有一份时自动选中;多份时显示 1、2、3 等序号。\n6. 输入对应序号,例如输入 1 恢复第 1 份。\n7. 输入备份密码后开始恢复。没有检测到文件时才手动输入路径。\n8. 恢复完成后,账号继续使用原登录密码。\n9. 以后需要管理系统时,可以直接输入 ns 打开管理菜单。\n\n<b>安全提示:</b>备份密码不会发送到 Telegram,请从 1Password 等独立位置取用。" , info . ModTime (). Local (). Format ( "2006-01-02" ), htmlEscape ( cfg . PublicHostname ), htmlEscape ( serverIP ), htmlEscape ( cfg . AppVersion ), list ( domains ), list ( admins ), list ( users ), list ( mailboxes ), htmlEscape ( filepath . Base ( path )), humanBackupBytes ( info . Size ()), sum ), nil
}
func queryBackupStrings ( ctx context . Context , db * sql . DB , query string ) ([] string , error ) {
rows , err := db . QueryContext ( ctx , query )
if err != nil {
return nil , err
}
defer rows . Close ()
var values [] string
for rows . Next () {
var value string
if err := rows . Scan ( & value ); err != nil {
return nil , err
}
values = append ( values , value )
}
return values , rows . Err ()
}
func humanBackupBytes ( value int64 ) string {
if value < 1024 {
return fmt . Sprintf ( "%d B" , value )
}
units := [] string { "KB" , "MB" , "GB" , "TB" }
size := float64 ( value )
unit := "B"
for _ , next := range units {
size /= 1024
unit = next
if size < 1024 {
break
}
}
return fmt . Sprintf ( "%.1f %s" , size , unit )
}
func ( a * App ) sendTelegramDocument ( ctx context . Context , token , chatID , path string ) error {
reader , pipeWriter := io . Pipe ()
writer := multipart . NewWriter ( pipeWriter )
go func () {
var writeErr error
defer func () { _ = writer . Close (); _ = pipeWriter . CloseWithError ( writeErr ) }()
if writeErr = writer . WriteField ( "chat_id" , chatID ); writeErr != nil {
return
}
if writeErr = writer . WriteField ( "caption" , "NewSzxcn 加密备份\n请将备份密码单独保管,不要发送到同一聊天。" ); writeErr != nil {
return
}
var part io . Writer
part , writeErr = writer . CreateFormFile ( "document" , filepath . Base ( path ))
if writeErr != nil {
return
}
var file * os . File
file , writeErr = os . Open ( path )
if writeErr != nil {
return
}
defer file . Close ()
2026-08-12 18:41:49 +08:00
_ , writeErr = io . Copy ( part , & backupProgressReader { reader : file , onProgress : func ( uploaded int64 ) { a . updateBackupTransfer ( "telegram" , path , uploaded ) }})
2026-08-12 16:15:19 +08:00
}()
endpoint := strings . TrimRight ( a . telegramURL , "/" ) + "/bot" + token + "/sendDocument"
req , err := http . NewRequestWithContext ( ctx , http . MethodPost , endpoint , reader )
if err != nil {
return err
}
req . Header . Set ( "Content-Type" , writer . FormDataContentType ())
resp , err := ( & http . Client { Timeout : 10 * time . Minute }). Do ( req )
if err != nil {
return err
}
defer resp . Body . Close ()
if resp . StatusCode < 200 || resp . StatusCode >= 300 {
raw , _ := io . ReadAll ( io . LimitReader ( resp . Body , 4096 ))
return fmt . Errorf ( "telegram %s: %s" , resp . Status , raw )
}
return nil
}
2026-08-12 18:41:49 +08:00
type backupProgressReader struct {
reader io . Reader
uploaded int64
onProgress func ( int64 )
}
func ( r * backupProgressReader ) Read ( p [] byte ) ( int , error ) {
n , err := r . reader . Read ( p )
if n > 0 {
r . uploaded += int64 ( n )
r . onProgress ( r . uploaded )
}
return n , err
}
2026-08-12 16:15:19 +08:00
func ( a * App ) listBackups () ([] backupItem , error ) {
dir := a . config (). BackupDir
if dir == "" {
return [] backupItem {}, nil
}
entries , err := os . ReadDir ( dir )
if os . IsNotExist ( err ) {
return [] backupItem {}, nil
}
if err != nil {
return nil , err
}
items := make ([] backupItem , 0 )
for _ , entry := range entries {
if entry . IsDir () || ! strings . HasSuffix ( entry . Name (), ".tar.zst.enc" ) {
continue
}
info , err := entry . Info ()
if err != nil {
continue
}
item := backupItem { Name : entry . Name (), Size : info . Size (), CreatedAt : info . ModTime (). UTC ()}
if raw , err := os . ReadFile ( filepath . Join ( dir , entry . Name () + ".sha256" )); err == nil {
fields := strings . Fields ( string ( raw ))
if len ( fields ) > 0 {
item . SHA256 = fields [ 0 ]
}
}
items = append ( items , item )
}
sort . Slice ( items , func ( i , j int ) bool { return items [ i ]. CreatedAt . After ( items [ j ]. CreatedAt ) })
return items , nil
}
func ( a * App ) pruneDisasterBackups ( keep int ) error {
items , err := a . listBackups ()
if err != nil {
return err
}
for _ , item := range items [ minimumInt ( keep , len ( items )):] {
path , ok := a . backupPath ( item . Name )
if ! ok {
continue
}
if err := os . Remove ( path ); err != nil {
return err
}
_ = os . Remove ( path + ".sha256" )
}
return nil
}
func minimumInt ( left , right int ) int {
if left < right {
return left
}
return right
}
func ( a * App ) loadBackupSchedule ( ctx context . Context ) ( backupSchedule , error ) {
result := backupSchedule { Days : 7 , TelegramMode : "system" , TelegramEnabled : true }
rows , err := a . db . QueryContext ( ctx , `SELECT key,value FROM system_settings WHERE key IN ('backupScheduleEnabled','backupScheduleDays','backupServerIp','backupTelegramChatId','backupTelegramMode','backupPasswordCipher','backupTelegramEnabled','backupGoogleDriveEnabled')` )
if err != nil {
return result , err
}
defer rows . Close ()
for rows . Next () {
var key , value string
if err := rows . Scan ( & key , & value ); err != nil {
return result , err
}
switch key {
case "backupScheduleEnabled" :
result . Enabled = value == "true"
case "backupScheduleDays" :
if _ , err := fmt . Sscan ( value , & result . Days ); err != nil || result . Days < 1 {
result . Days = 7
}
case "backupServerIp" :
result . ServerIP = value
case "backupTelegramChatId" :
result . ChatID = value
case "backupTelegramMode" :
if value == "custom" {
result . TelegramMode = "custom"
}
case "backupPasswordCipher" :
result . PasswordSet = value != ""
2026-08-12 17:58:48 +08:00
if value != "" {
if password , err := a . decryptBackupPassword ( value ); err == nil {
result . PasswordHint = backupPasswordHint ( password )
}
}
2026-08-12 16:15:19 +08:00
case "backupTelegramEnabled" :
result . TelegramEnabled = value == "true"
case "backupGoogleDriveEnabled" :
result . GoogleDriveEnabled = value == "true"
}
}
return result , rows . Err ()
}
func ( a * App ) backupScheduleWorker ( ctx context . Context ) {
ticker := time . NewTicker ( time . Hour )
defer ticker . Stop ()
a . runScheduledBackup ( ctx )
for {
select {
case <- ctx . Done ():
return
case <- ticker . C :
a . runScheduledBackup ( ctx )
}
}
}
func ( a * App ) runScheduledBackup ( ctx context . Context ) {
schedule , err := a . loadBackupSchedule ( ctx )
if err != nil || ! schedule . Enabled {
return
}
var last string
_ = a . db . QueryRowContext ( ctx , `SELECT value FROM system_settings WHERE key='backupScheduleLastRun'` ). Scan ( & last )
if parsed , err := time . Parse ( time . RFC3339Nano , last ); err == nil && a . now (). UTC (). Before ( parsed . Add ( time . Duration ( schedule . Days ) * 24 * time . Hour )) {
return
}
var ciphertext string
if err := a . db . QueryRowContext ( ctx , `SELECT value FROM system_settings WHERE key='backupPasswordCipher'` ). Scan ( & ciphertext ); err != nil {
return
}
password , err := a . decryptBackupPassword ( ciphertext )
if err != nil {
a . log . Error ( "decrypt scheduled backup password" , "error" , err )
return
}
a . backupMu . Lock ()
if a . backupJob != nil && a . backupJob . Status == "running" {
a . backupMu . Unlock ()
return
}
a . backupJob = & backupJob { Status : "running" , StartedAt : a . now (). UTC ()}
a . backupMu . Unlock ()
path , runErr := a . createDisasterBackup ( ctx , password )
password = ""
localBackupSucceeded := runErr == nil
2026-08-12 18:41:49 +08:00
var deliveryMessages [] string
2026-08-12 16:15:19 +08:00
if localBackupSucceeded {
now := a . now (). UTC (). Format ( time . RFC3339Nano )
_ , _ = a . db . ExecContext ( ctx , `INSERT INTO system_settings(key,value,updated_at) VALUES('backupScheduleLastRun',?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at` , now , now )
var deliveryErrors [] error
2026-08-12 18:41:49 +08:00
if schedule . GoogleDriveEnabled {
a . queueBackupTransfer ( "googleDrive" , path )
}
if schedule . TelegramEnabled {
a . queueBackupTransfer ( "telegram" , path )
}
2026-08-12 16:15:19 +08:00
if schedule . GoogleDriveEnabled {
if driveErr := a . uploadBackupToGoogleDrive ( ctx , path ); driveErr != nil {
2026-08-12 18:41:49 +08:00
message := googleDriveUploadMessage ( driveErr )
a . finishBackupTransfer ( "googleDrive" , path , message )
deliveryMessages = append ( deliveryMessages , message )
2026-08-12 16:15:19 +08:00
deliveryErrors = append ( deliveryErrors , fmt . Errorf ( "google drive: %w" , driveErr ))
2026-08-12 18:41:49 +08:00
} else {
a . finishBackupTransfer ( "googleDrive" , path , "" )
2026-08-12 16:15:19 +08:00
}
}
if schedule . TelegramEnabled {
if telegramErr := a . sendBackupToTelegram ( ctx , path ); telegramErr != nil {
2026-08-12 18:41:49 +08:00
a . finishBackupTransfer ( "telegram" , path , telegramErr . Error ())
deliveryMessages = append ( deliveryMessages , "Telegram 发送失败:" + telegramErr . Error ())
2026-08-12 16:15:19 +08:00
deliveryErrors = append ( deliveryErrors , fmt . Errorf ( "telegram: %w" , telegramErr ))
2026-08-12 18:41:49 +08:00
} else {
a . finishBackupTransfer ( "telegram" , path , "" )
2026-08-12 16:15:19 +08:00
}
}
runErr = errors . Join ( deliveryErrors ... )
}
status := "success"
publicError := ""
if runErr != nil {
status = "failed"
2026-08-12 18:41:49 +08:00
if len ( deliveryMessages ) > 0 {
publicError = strings . Join ( deliveryMessages , "; " )
} else {
publicError = "定时备份创建失败,请检查服务器存储空间"
}
2026-08-12 16:15:19 +08:00
a . log . Error ( "scheduled backup" , "error" , runErr )
}
a . backupMu . Lock ()
a . backupJob . Status , a . backupJob . Error = status , publicError
a . backupMu . Unlock ()
}
func ( a * App ) backupEncryptionKey () [] byte {
cfg := a . config ()
secret := strings . TrimSpace ( cfg . UpdateServiceToken )
if secret == "" {
secret = strings . TrimSpace ( cfg . ExternalIMAPSecretKey )
}
sum := sha256 . Sum256 ([] byte ( "newszxcn-backup-schedule:" + secret ))
return sum [:]
}
func ( a * App ) encryptBackupPassword ( password string ) ( string , error ) {
if strings . TrimSpace ( a . config (). UpdateServiceToken ) == "" && strings . TrimSpace ( a . config (). ExternalIMAPSecretKey ) == "" {
return "" , errors . New ( "backup encryption key is not configured" )
}
block , err := aes . NewCipher ( a . backupEncryptionKey ())
if err != nil {
return "" , err
}
gcm , err := cipher . NewGCM ( block )
if err != nil {
return "" , err
}
nonce := make ([] byte , gcm . NonceSize ())
if _ , err := rand . Read ( nonce ); err != nil {
return "" , err
}
return base64 . StdEncoding . EncodeToString ( append ( nonce , gcm . Seal ( nil , nonce , [] byte ( password ), nil ) ... )), nil
}
func ( a * App ) decryptBackupPassword ( value string ) ( string , error ) {
if strings . TrimSpace ( a . config (). UpdateServiceToken ) == "" && strings . TrimSpace ( a . config (). ExternalIMAPSecretKey ) == "" {
return "" , errors . New ( "backup encryption key is not configured" )
}
raw , err := base64 . StdEncoding . DecodeString ( value )
if err != nil {
return "" , err
}
block , err := aes . NewCipher ( a . backupEncryptionKey ())
if err != nil {
return "" , err
}
gcm , err := cipher . NewGCM ( block )
if err != nil {
return "" , err
}
if len ( raw ) < gcm . NonceSize () {
return "" , errors . New ( "invalid backup password ciphertext" )
}
plain , err := gcm . Open ( nil , raw [: gcm . NonceSize ()], raw [ gcm . NonceSize ():], nil )
if err != nil {
return "" , err
}
return string ( plain ), nil
}
func ( a * App ) backupPath ( name string ) ( string , bool ) {
if filepath . Base ( name ) != name || ! strings . HasPrefix ( name , "newszxcn-backup-" ) || ! strings . HasSuffix ( name , ".tar.zst.enc" ) {
return "" , false
}
path := filepath . Join ( a . config (). BackupDir , name )
info , err := os . Stat ( path )
return path , err == nil && ! info . IsDir ()
}
func copyTree ( src , dst string , skip map [ string ] bool ) error {
return filepath . Walk ( src , func ( path string , info os . FileInfo , err error ) error {
if err != nil {
return err
}
rel , err := filepath . Rel ( src , path )
if err != nil {
return err
}
if rel == "." {
return os . MkdirAll ( dst , info . Mode (). Perm ())
}
first := strings . Split ( rel , string ( os . PathSeparator ))[ 0 ]
if skip [ first ] {
if info . IsDir () {
return filepath . SkipDir
}
return nil
}
target := filepath . Join ( dst , rel )
if info . IsDir () {
return os . MkdirAll ( target , info . Mode (). Perm ())
}
if ! info . Mode (). IsRegular () {
return nil
}
return copyFile ( path , target )
})
}
func copyFile ( src , dst string ) error {
in , err := os . Open ( src )
if err != nil {
return err
}
defer in . Close ()
info , err := in . Stat ()
if err != nil {
return err
}
if err := os . MkdirAll ( filepath . Dir ( dst ), 0 o700 ); err != nil {
return err
}
out , err := os . OpenFile ( dst , os . O_CREATE | os . O_TRUNC | os . O_WRONLY , info . Mode (). Perm ())
if err != nil {
return err
}
_ , cpErr := io . Copy ( out , in )
closeErr := out . Close ()
if cpErr != nil {
return cpErr
}
return closeErr
}
func writeTar ( path , root string ) error {
out , err := os . Create ( path )
if err != nil {
return err
}
tw := tar . NewWriter ( out )
err = filepath . Walk ( root , func ( path string , info os . FileInfo , err error ) error {
if err != nil {
return err
}
rel , _ := filepath . Rel ( filepath . Dir ( root ), path )
header , err := tar . FileInfoHeader ( info , "" )
if err != nil {
return err
}
header . Name = filepath . ToSlash ( rel )
if err := tw . WriteHeader ( header ); err != nil {
return err
}
if info . Mode (). IsRegular () {
f , err := os . Open ( path )
if err != nil {
return err
}
_ , err = io . Copy ( tw , f )
f . Close ()
return err
}
return nil
})
closeErr := tw . Close ()
fileCloseErr := out . Close ()
if err != nil {
return err
}
if closeErr != nil {
return closeErr
}
return fileCloseErr
}
func fileSHA256 ( path string ) ( string , error ) {
f , err := os . Open ( path )
if err != nil {
return "" , err
}
defer f . Close ()
hash := sha256 . New ()
if _ , err := io . Copy ( hash , f ); err != nil {
return "" , err
}
return hex . EncodeToString ( hash . Sum ( nil )), nil
}