Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \
FROM debian:bookworm-slim AS runtime-deps

RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates tzdata \
&& apt-get install -y --no-install-recommends ca-certificates tzdata postgresql-client \
&& rm -rf /var/lib/apt/lists/*


Expand Down
1 change: 1 addition & 0 deletions backend/internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ func NewApp() (*App, error) {
adminService.SetPermissionGroupBillingPlanReferenceChecker(billingService)
adminHandler := adminhttp.NewHandler(adminService)
adminHandler.SetConversationExporter(conversationService)
adminHandler.SetDatabaseBackupManager(adminhttp.NewDatabaseBackupManager(db, cfg))
adminModule := adminhttp.NewModule(adminHandler)
userSettingsRepo := usersettingsrepo.NewRepo(db)
userSettingsService := usersettings.NewService(userSettingsRepo)
Expand Down
126 changes: 126 additions & 0 deletions backend/internal/transport/http/admin/database_backup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package admin

import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"

"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/config"
"gorm.io/gorm"
)

const maxDatabaseRestoreBytes int64 = 1024 * 1024 * 1024

var errDatabaseOperationBusy = errors.New("database backup or restore is already running")

type databaseBackupManager struct {
db *gorm.DB
cfg config.Config
mu sync.Mutex
}

type databaseBackupFile struct {
Path string
Name string
}

func NewDatabaseBackupManager(db *gorm.DB, cfg config.Config) *databaseBackupManager {
return &databaseBackupManager{db: db, cfg: cfg}
}

func (m *databaseBackupManager) driver() string {
driver := strings.ToLower(strings.TrimSpace(m.cfg.DatabaseDriver))
if driver == "" {
return "postgres"
}
return driver
}

func (m *databaseBackupManager) withLock(fn func() error) error {
if !m.mu.TryLock() {
return errDatabaseOperationBusy
}
defer m.mu.Unlock()
return fn()
}

func (m *databaseBackupManager) Backup(ctx context.Context) (databaseBackupFile, error) {
var result databaseBackupFile
err := m.withLock(func() error {
timestamp := time.Now().UTC().Format("20060102T150405Z")
switch m.driver() {
case "sqlite":
path, err := tempBackupPath("deeix-chat-"+timestamp+"-", ".sqlite3")
if err != nil {
return err
}
if err := m.backupSQLite(ctx, path); err != nil {
_ = os.Remove(path)
return err
}
result = databaseBackupFile{Path: path, Name: "deeix-chat-" + timestamp + ".sqlite3"}
return nil
case "postgres":
path, err := tempBackupPath("deeix-chat-"+timestamp+"-", ".dump")
if err != nil {
return err
}
cmd := exec.CommandContext(ctx, "pg_dump", "--format=custom", "--no-owner", "--no-privileges", "--file", path, m.cfg.PostgresDSN)
if output, err := cmd.CombinedOutput(); err != nil {
_ = os.Remove(path)
return fmt.Errorf("pg_dump failed: %w: %s", err, strings.TrimSpace(string(output)))
}
result = databaseBackupFile{Path: path, Name: "deeix-chat-" + timestamp + ".dump"}
return nil
default:
return fmt.Errorf("unsupported database driver %q", m.driver())
}
})
return result, err
}

func (m *databaseBackupManager) Restore(ctx context.Context, sourcePath string) error {
return m.withLock(func() error {
switch m.driver() {
case "sqlite":
return m.restoreSQLite(ctx, sourcePath)
case "postgres":
cmd := exec.CommandContext(ctx, "pg_restore", "--clean", "--if-exists", "--no-owner", "--no-privileges", "--single-transaction", "--dbname", m.cfg.PostgresDSN, sourcePath)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("pg_restore failed: %w: %s", err, strings.TrimSpace(string(output)))
}
return nil
default:
return fmt.Errorf("unsupported database driver %q", m.driver())
}
})
}

func tempBackupPath(pattern, suffix string) (string, error) {
file, err := os.CreateTemp("", pattern+"*"+suffix)
if err != nil {
return "", err
}
path := file.Name()
if err := file.Close(); err != nil {
_ = os.Remove(path)
return "", err
}
return path, nil
}

func (m *databaseBackupManager) backupSQLite(ctx context.Context, path string) error {
escaped := strings.ReplaceAll(filepath.ToSlash(path), "'", "''")
return m.db.WithContext(ctx).Exec("VACUUM INTO '" + escaped + "'").Error
}

func copyRestoreUpload(dst *os.File, src io.Reader) (int64, error) {
return io.Copy(dst, io.LimitReader(src, maxDatabaseRestoreBytes+1))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
//go:build cgo

package admin

import (
"context"
"database/sql"
"errors"
"fmt"
"path/filepath"
"time"

"github.com/mattn/go-sqlite3"
)

func (m *databaseBackupManager) restoreSQLite(ctx context.Context, sourcePath string) error {
source, err := sql.Open("sqlite3", "file:"+filepath.ToSlash(sourcePath)+"?mode=ro")
if err != nil {
return err
}
defer source.Close()

var integrity string
if err := source.QueryRowContext(ctx, "PRAGMA integrity_check").Scan(&integrity); err != nil {
return fmt.Errorf("validate sqlite backup: %w", err)
}
if integrity != "ok" {
return fmt.Errorf("invalid sqlite backup: integrity check returned %q", integrity)
}

destination, err := m.db.DB()
if err != nil {
return err
}
sourceConn, err := source.Conn(ctx)
if err != nil {
return err
}
defer sourceConn.Close()
destinationConn, err := destination.Conn(ctx)
if err != nil {
return err
}
defer destinationConn.Close()

return destinationConn.Raw(func(destinationDriver any) error {
dst, ok := destinationDriver.(*sqlite3.SQLiteConn)
if !ok {
return errors.New("unexpected sqlite destination driver")
}
return sourceConn.Raw(func(sourceDriver any) error {
src, ok := sourceDriver.(*sqlite3.SQLiteConn)
if !ok {
return errors.New("unexpected sqlite source driver")
}
backup, err := dst.Backup("main", src, "main")
if err != nil {
return err
}
defer backup.Close()
for {
done, err := backup.Step(256)
if err != nil {
return err
}
if done {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(10 * time.Millisecond):
}
}
})
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//go:build !cgo

package admin

import (
"context"
"errors"
)

func (m *databaseBackupManager) restoreSQLite(ctx context.Context, sourcePath string) error {
_ = ctx
_ = sourcePath
return errors.New("sqlite restore requires a CGO-enabled build with a C compiler")
}
5 changes: 5 additions & 0 deletions backend/internal/transport/http/admin/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ type conversationExportManifest struct {
type Handler struct {
service *appadmin.Service
conversationExport conversationExporter
databaseBackup *databaseBackupManager
}

// NewHandler 创建处理器。
Expand All @@ -53,6 +54,10 @@ func (h *Handler) SetConversationExporter(exporter conversationExporter) {
h.conversationExport = exporter
}

func (h *Handler) SetDatabaseBackupManager(manager *databaseBackupManager) {
h.databaseBackup = manager
}

// ListUsers godoc
// @Summary 管理员查询用户
// @Description 管理员分页查看所有用户,实现账户隔离管理
Expand Down
99 changes: 99 additions & 0 deletions backend/internal/transport/http/admin/handler_database_backup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package admin

import (
"errors"
"net/http"
"os"
"path/filepath"
"strings"

domainuser "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/user"
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/shared/response"
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/transport/http/middleware"
"github.com/gin-gonic/gin"
)

func (h *Handler) DatabaseBackupInfo(c *gin.Context) {
if !h.requireSuperAdmin(c) || h.databaseBackup == nil {
return
}
response.Success(c, gin.H{"driver": h.databaseBackup.driver(), "maxRestoreBytes": maxDatabaseRestoreBytes})
}

func (h *Handler) DownloadDatabaseBackup(c *gin.Context) {
if !h.requireSuperAdmin(c) || h.databaseBackup == nil {
return
}
backup, err := h.databaseBackup.Backup(c.Request.Context())
if err != nil {
h.handleDatabaseOperationError(c, err, "database backup failed")
return
}
defer os.Remove(backup.Path)
c.Header("Cache-Control", "no-store")
c.FileAttachment(backup.Path, backup.Name)
}

func (h *Handler) RestoreDatabaseBackup(c *gin.Context) {
if !h.requireSuperAdmin(c) || h.databaseBackup == nil {
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxDatabaseRestoreBytes+1024*1024)
file, err := c.FormFile("file")
if err != nil {
response.Error(c, http.StatusBadRequest, "database backup file is required")
return
}
if file.Size <= 0 || file.Size > maxDatabaseRestoreBytes {
response.Error(c, http.StatusBadRequest, "database backup file size is invalid")
return
}
expected := ".dump"
if h.databaseBackup.driver() == "sqlite" {
expected = ".sqlite3"
}
if strings.ToLower(filepath.Ext(file.Filename)) != expected {
response.Error(c, http.StatusBadRequest, "database backup file type does not match current driver")
return
}

upload, err := os.CreateTemp("", "deeix-chat-restore-*"+expected)
if err != nil {
response.Error(c, http.StatusInternalServerError, "prepare database restore failed")
return
}
path := upload.Name()
defer os.Remove(path)
source, err := file.Open()
var copied int64
if err == nil {
copied, err = copyRestoreUpload(upload, source)
_ = source.Close()
}
closeErr := upload.Close()
if err != nil || closeErr != nil || copied > maxDatabaseRestoreBytes {
response.Error(c, http.StatusBadRequest, "read database backup failed")
return
}
if err := h.databaseBackup.Restore(c.Request.Context(), path); err != nil {
h.handleDatabaseOperationError(c, err, "database restore failed")
return
}
response.Success(c, gin.H{"restored": true})
}

func (h *Handler) requireSuperAdmin(c *gin.Context) bool {
if middleware.MustUserRole(c) != string(domainuser.RoleSuperAdmin) {
response.Error(c, http.StatusForbidden, "superadmin permission required")
return false
}
return true
}

func (h *Handler) handleDatabaseOperationError(c *gin.Context, err error, fallback string) {
if errors.Is(err, errDatabaseOperationBusy) {
response.Error(c, http.StatusConflict, errDatabaseOperationBusy.Error())
return
}
response.Error(c, http.StatusInternalServerError, fallback)
}
3 changes: 3 additions & 0 deletions backend/internal/transport/http/admin/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import "github.com/gin-gonic/gin"

// RegisterRoutes 注册后台管理路由(由管理员中间件保护)。
func (m *Module) RegisterRoutes(adminGroup *gin.RouterGroup) {
adminGroup.GET("/database/backup-info", m.Handler.DatabaseBackupInfo)
adminGroup.GET("/database/backup", m.Handler.DownloadDatabaseBackup)
adminGroup.POST("/database/restore", m.Handler.RestoreDatabaseBackup)
adminGroup.POST("/users", m.Handler.CreateUser)
adminGroup.GET("/users", m.Handler.ListUsers)
adminGroup.POST("/users/import/openwebui", m.Handler.ImportOpenWebUIUsers)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import (
"github.com/gin-gonic/gin"
)

const defaultContentSecurityPolicy = "default-src 'self'; base-uri 'self'; object-src 'self' blob:; frame-ancestors 'none'; form-action 'self'; img-src 'self' data: blob:; media-src 'self' data: blob:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://challenges.cloudflare.com; connect-src 'self' http: https: ws: wss: blob:; worker-src 'self' blob:; frame-src 'self' blob: https://challenges.cloudflare.com"
const defaultContentSecurityPolicy = "default-src 'self'; base-uri 'self'; object-src 'self' blob:; frame-ancestors 'none'; form-action 'self'; img-src 'self' data: blob: https://worldloom.top https://cdn.ldstatic.com; media-src 'self' data: blob:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://challenges.cloudflare.com; connect-src 'self' http: https: ws: wss: blob:; worker-src 'self' blob:; frame-src 'self' blob: https://challenges.cloudflare.com"

// SecurityHeaders 为所有 HTTP 响应补充安全响应头;文件内容响应可在 handler 中覆盖为更严格策略。
func SecurityHeaders(env string) gin.HandlerFunc {
Expand Down
5 changes: 5 additions & 0 deletions frontend/app/(admin)/admin/database/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { AdminDatabasePage } from "@/features/admin/components/sections/database/admin-database";

export default function DatabasePage() {
return <AdminDatabasePage />;
}
Loading
Loading