diff --git a/Dockerfile b/Dockerfile index feb58ad3..7cb1d3d2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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/* diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index f47228d1..d344aef4 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -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) diff --git a/backend/internal/transport/http/admin/database_backup.go b/backend/internal/transport/http/admin/database_backup.go new file mode 100644 index 00000000..134d170c --- /dev/null +++ b/backend/internal/transport/http/admin/database_backup.go @@ -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)) +} diff --git a/backend/internal/transport/http/admin/database_backup_sqlite_restore_cgo.go b/backend/internal/transport/http/admin/database_backup_sqlite_restore_cgo.go new file mode 100644 index 00000000..8930a48b --- /dev/null +++ b/backend/internal/transport/http/admin/database_backup_sqlite_restore_cgo.go @@ -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): + } + } + }) + }) +} diff --git a/backend/internal/transport/http/admin/database_backup_sqlite_restore_nocgo.go b/backend/internal/transport/http/admin/database_backup_sqlite_restore_nocgo.go new file mode 100644 index 00000000..48f657c0 --- /dev/null +++ b/backend/internal/transport/http/admin/database_backup_sqlite_restore_nocgo.go @@ -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") +} diff --git a/backend/internal/transport/http/admin/handler.go b/backend/internal/transport/http/admin/handler.go index 416d5689..d7e333c9 100644 --- a/backend/internal/transport/http/admin/handler.go +++ b/backend/internal/transport/http/admin/handler.go @@ -41,6 +41,7 @@ type conversationExportManifest struct { type Handler struct { service *appadmin.Service conversationExport conversationExporter + databaseBackup *databaseBackupManager } // NewHandler 创建处理器。 @@ -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 管理员分页查看所有用户,实现账户隔离管理 diff --git a/backend/internal/transport/http/admin/handler_database_backup.go b/backend/internal/transport/http/admin/handler_database_backup.go new file mode 100644 index 00000000..59a39009 --- /dev/null +++ b/backend/internal/transport/http/admin/handler_database_backup.go @@ -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) +} diff --git a/backend/internal/transport/http/admin/router.go b/backend/internal/transport/http/admin/router.go index 4a94e016..f315c11d 100644 --- a/backend/internal/transport/http/admin/router.go +++ b/backend/internal/transport/http/admin/router.go @@ -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) diff --git a/backend/internal/transport/http/middleware/security_headers.go b/backend/internal/transport/http/middleware/security_headers.go index 39850f1f..bec04620 100644 --- a/backend/internal/transport/http/middleware/security_headers.go +++ b/backend/internal/transport/http/middleware/security_headers.go @@ -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 { diff --git a/frontend/app/(admin)/admin/database/page.tsx b/frontend/app/(admin)/admin/database/page.tsx new file mode 100644 index 00000000..7e6a2972 --- /dev/null +++ b/frontend/app/(admin)/admin/database/page.tsx @@ -0,0 +1,5 @@ +import { AdminDatabasePage } from "@/features/admin/components/sections/database/admin-database"; + +export default function DatabasePage() { + return ; +} diff --git a/frontend/features/admin/api/database.ts b/frontend/features/admin/api/database.ts new file mode 100644 index 00000000..201855c7 --- /dev/null +++ b/frontend/features/admin/api/database.ts @@ -0,0 +1,28 @@ +import { authedFetch, authedRequest } from "@/shared/api/authed-client"; + +export type AdminDatabaseBackupInfo = { driver: "postgres" | "sqlite"; maxRestoreBytes: number }; + +export function getAdminDatabaseBackupInfo(accessToken: string): Promise { + return authedRequest("/api/v1/admin/database/backup-info", { accessToken }); +} + +export async function downloadAdminDatabaseBackup(accessToken: string): Promise { + const response = await authedFetch("/api/v1/admin/database/backup", { method: "GET", accessToken }); + const blob = await response.blob(); + const disposition = response.headers.get("content-disposition") || ""; + const filename = disposition.match(/filename="?([^";]+)"?/i)?.[1] || "deeix-chat-backup"; + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); +} + +export async function restoreAdminDatabaseBackup(accessToken: string, file: File): Promise { + const body = new FormData(); + body.append("file", file); + await authedFetch("/api/v1/admin/database/restore", { method: "POST", accessToken, body }); +} diff --git a/frontend/features/admin/api/index.ts b/frontend/features/admin/api/index.ts index c6bb0a9a..8e0343cb 100644 --- a/frontend/features/admin/api/index.ts +++ b/frontend/features/admin/api/index.ts @@ -3,6 +3,7 @@ export * from "./announcements"; export * from "./auth"; export * from "./audit"; export * from "./billing"; +export * from "./database"; export * from "./llm"; export * from "./mcp"; export * from "./permission-groups"; diff --git a/frontend/features/admin/components/admin-sidebar.tsx b/frontend/features/admin/components/admin-sidebar.tsx index 00e40d31..13442882 100644 --- a/frontend/features/admin/components/admin-sidebar.tsx +++ b/frontend/features/admin/components/admin-sidebar.tsx @@ -30,6 +30,7 @@ const ADMIN_SECTION_LABEL_KEYS: Record = { "login-settings": "sections.loginSettings", "conversation-settings": "sections.conversationSettings", "chat-files": "sections.chatFiles", + database: "sections.database", about: "sections.about", }; diff --git a/frontend/features/admin/components/sections/database/admin-database.tsx b/frontend/features/admin/components/sections/database/admin-database.tsx new file mode 100644 index 00000000..89bc6c6c --- /dev/null +++ b/frontend/features/admin/components/sections/database/admin-database.tsx @@ -0,0 +1,63 @@ +"use client"; + +import * as React from "react"; +import { DatabaseBackup, Download, RotateCcw, Upload } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; +import { downloadAdminDatabaseBackup, getAdminDatabaseBackupInfo, restoreAdminDatabaseBackup, type AdminDatabaseBackupInfo } from "@/features/admin/api"; +import { resolveAdminErrorMessage } from "@/features/admin/utils/admin-error"; +import { useAuthSession } from "@/shared/auth/auth-session-context"; + +export function AdminDatabasePage() { + const t = useTranslations("adminDatabase"); + const { accessToken, user } = useAuthSession(); + const inputRef = React.useRef(null); + const [info, setInfo] = React.useState(null); + const [selectedFile, setSelectedFile] = React.useState(null); + const [confirmOpen, setConfirmOpen] = React.useState(false); + const [downloading, setDownloading] = React.useState(false); + const [restoring, setRestoring] = React.useState(false); + + React.useEffect(() => { + if (!accessToken || user?.role !== "superadmin") return; + void getAdminDatabaseBackupInfo(accessToken).then(setInfo).catch(() => undefined); + }, [accessToken, user?.role]); + + if (user?.role !== "superadmin") return
{t("superadminOnly")}
; + + async function handleDownload() { + if (!accessToken || downloading) return; + setDownloading(true); + try { await downloadAdminDatabaseBackup(accessToken); toast.success(t("backup.success")); } + catch (error) { toast.error(resolveAdminErrorMessage(error, t("backup.failed"))); } + finally { setDownloading(false); } + } + + async function handleRestore() { + if (!accessToken || !selectedFile || restoring) return; + setRestoring(true); + try { + await restoreAdminDatabaseBackup(accessToken, selectedFile); + toast.success(t("restore.success")); + setSelectedFile(null); setConfirmOpen(false); + if (inputRef.current) inputRef.current.value = ""; + } catch (error) { toast.error(resolveAdminErrorMessage(error, t("restore.failed"))); } + finally { setRestoring(false); } + } + + const extension = info?.driver === "sqlite" ? ".sqlite3" : ".dump"; + return ( +
+

{t("title")}

{t("description")}

+
+
{t("backup.title")}{t("backup.description", { extension })}
+
{t("restore.title")}{t("restore.description", { extension })}
setSelectedFile(event.target.files?.[0] ?? null)} />
+
+ {t("restore.confirmTitle")}{t("restore.confirmDescription", { filename: selectedFile?.name ?? "" })}{t("cancel")} { event.preventDefault(); void handleRestore(); }}>{restoring ? t("restore.restoring") : t("restore.confirmAction")} +
+ ); +} diff --git a/frontend/features/admin/model/admin-sections.ts b/frontend/features/admin/model/admin-sections.ts index f1d80afb..a503192a 100644 --- a/frontend/features/admin/model/admin-sections.ts +++ b/frontend/features/admin/model/admin-sections.ts @@ -10,6 +10,7 @@ export const ADMIN_SECTIONS = [ { id: "login-settings", label: "Login & auth", href: "/login" }, { id: "conversation-settings", label: "Conversation", href: "/conversation" }, { id: "chat-files", label: "Files & retrieval", href: "/chat-files" }, + { id: "database", label: "Database", href: "/database" }, { id: "about", label: "About", href: "/about" }, ] as const; diff --git a/frontend/i18n/messages.ts b/frontend/i18n/messages.ts index 4aefbaec..97a92167 100644 --- a/frontend/i18n/messages.ts +++ b/frontend/i18n/messages.ts @@ -1,6 +1,7 @@ import enAdminAnnouncements from "@/i18n/messages/en-US/admin-announcements.json"; import enAdminBilling from "@/i18n/messages/en-US/admin-billing.json"; import enAdminConversation from "@/i18n/messages/en-US/admin-conversation.json"; +import enAdminDatabase from "@/i18n/messages/en-US/admin-database.json"; import enAdminFiles from "@/i18n/messages/en-US/admin-files.json"; import enAdminGroups from "@/i18n/messages/en-US/admin-groups.json"; import enAdminLogin from "@/i18n/messages/en-US/admin-login.json"; @@ -41,6 +42,7 @@ const ENGLISH_MESSAGES = { adminAnnouncements: enAdminAnnouncements, adminBilling: enAdminBilling, adminConversation: enAdminConversation, + adminDatabase: enAdminDatabase, adminFiles: enAdminFiles, adminGroups: enAdminGroups, adminLogin: enAdminLogin, @@ -121,6 +123,7 @@ export async function loadLocaleMessages(locale: AppLocale): Promise