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/textproto"
"net/url"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"github.com/go-chi/chi/v5"
"golang.org/x/oauth2"
)
const backupTelegramLimit = 49 << 20
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"`
}
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"`
}
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
}
a . backupMu . Unlock ()
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 ,
GoogleDrive : a . loadGoogleDriveStatus ( r . Context ()),
})
}
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 = ""
if err == nil {
var deliveryErrors [] error
if uploadGoogleDrive {
if driveErr := a . uploadBackupToGoogleDrive ( ctx , path ); driveErr != nil {
deliveryErrors = append ( deliveryErrors , fmt . Errorf ( "google drive: %w" , driveErr ))
}
}
if sendTelegram {
if telegramErr := a . sendBackupToTelegram ( ctx , path ); telegramErr != nil {
deliveryErrors = append ( deliveryErrors , fmt . Errorf ( "telegram: %w" , telegramErr ))
}
}
err = errors . Join ( deliveryErrors ... )
}
a . backupMu . Lock ()
if err != nil {
a . backupJob . Status = "failed"
a . backupJob . Error = "本地备份或所选推送未全部完成,请查看服务日志"
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" )
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
}
if err := a . sendBackupToTelegram ( r . Context (), path ); err != nil {
a . log . Error ( "send backup telegram" , "error" , err )
respondError ( w , 502 , err . Error ())
return
}
respondJSON ( w , 200 , map [ string ] any { "ok" : true })
}
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
}
if err := a . uploadBackupToGoogleDrive ( r . Context (), path ); err != nil {
a . log . Error ( "upload backup to google drive" , "error" , err )
respondError ( w , 502 , "上传 Google 云端硬盘失败" )
return
}
respondJSON ( w , 200 , map [ string ] bool { "ok" : true })
}
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 ))
return "" , fmt . Errorf ( "drive folder lookup %s: %s" , resp . Status , raw )
}
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 ))
return "" , fmt . Errorf ( "drive folder create %s: %s" , resp . Status , raw )
}
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 {
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
}
req , err := newGoogleDriveUploadRequest ( ctx , path , folderID )
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 ))
return fmt . Errorf ( "drive upload %s: %s" , resp . Status , raw )
}
return nil
}
func newGoogleDriveUploadRequest ( ctx context . Context , path , folderID string ) ( * http . Request , error ) {
file , err := os . Open ( path )
if err != nil {
return nil , err
}
reader , writerSide := io . Pipe ()
mw := multipart . NewWriter ( writerSide )
go func () {
var writeErr error
defer func () { _ = file . Close (); _ = mw . Close (); _ = writerSide . CloseWithError ( writeErr ) }()
head := textproto . MIMEHeader {}
head . Set ( "Content-Type" , "application/json; charset=UTF-8" )
part , err := mw . CreatePart ( head )
if err != nil {
writeErr = err
return
}
metadata , _ := json . Marshal ( map [ string ] any { "name" : filepath . Base ( path ), "parents" : [] string { folderID }})
if _ , err = part . Write ( metadata ); err != nil {
writeErr = err
return
}
head = textproto . MIMEHeader {}
head . Set ( "Content-Type" , "application/octet-stream" )
part , err = mw . CreatePart ( head )
if err != nil {
writeErr = err
return
}
_ , writeErr = io . Copy ( part , file )
}()
req , err := http . NewRequestWithContext ( ctx , http . MethodPost , "https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id,name" , reader )
if err != nil {
_ = file . Close ()
_ = writerSide . CloseWithError ( err )
return nil , err
}
req . Header . Set ( "Content-Type" , "multipart/related; boundary=" + mw . Boundary ())
return req , nil
}
func ( a * App ) sendBackupToTelegram ( ctx context . Context , path string ) error {
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 ()
_ , writeErr = io . Copy ( part , file )
}()
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
}
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
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
if schedule . GoogleDriveEnabled {
if driveErr := a . uploadBackupToGoogleDrive ( ctx , path ); driveErr != nil {
deliveryErrors = append ( deliveryErrors , fmt . Errorf ( "google drive: %w" , driveErr ))
}
}
if schedule . TelegramEnabled {
if telegramErr := a . sendBackupToTelegram ( ctx , path ); telegramErr != nil {
deliveryErrors = append ( deliveryErrors , fmt . Errorf ( "telegram: %w" , telegramErr ))
}
}
runErr = errors . Join ( deliveryErrors ... )
}
status := "success"
publicError := ""
if runErr != nil {
status = "failed"
publicError = "定时备份或云端推送失败,请查看服务日志"
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
}