mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-08-24 21:17:31 +00:00
287 lines
8.3 KiB
Go
287 lines
8.3 KiB
Go
package service
|
|
|
|
import (
|
|
"archive/zip"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"gorm.io/gorm"
|
|
|
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
|
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
|
)
|
|
|
|
// ExportService handles exporting Pocket ID data into a ZIP archive.
|
|
type ExportService struct {
|
|
db *gorm.DB
|
|
storage storage.FileStorage
|
|
actors ActorsBackupProvider
|
|
}
|
|
|
|
// NewExportService creates a new ExportService.
|
|
//
|
|
// actors is used to back up the actor host's data, which is stored outside of the Pocket ID schema; when nil, the export does not include it.
|
|
func NewExportService(db *gorm.DB, storage storage.FileStorage, actors ActorsBackupProvider) *ExportService {
|
|
return &ExportService{
|
|
db: db,
|
|
storage: storage,
|
|
actors: actors,
|
|
}
|
|
}
|
|
|
|
// ExportToZip performs the full export process and writes the ZIP data to the given writer.
|
|
func (s *ExportService) ExportToZip(ctx context.Context, w io.Writer) error {
|
|
dbData, err := s.extractDatabase(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return s.writeExportZipStream(ctx, w, dbData)
|
|
}
|
|
|
|
// extractDatabase reads all tables into a DatabaseExport struct
|
|
//
|
|
// Every table is read inside a single read transaction, so the dump is a consistent snapshot of one point in time
|
|
func (s *ExportService) extractDatabase(ctx context.Context) (out DatabaseExport, err error) {
|
|
err = s.db.
|
|
WithContext(ctx).
|
|
Transaction(func(tx *gorm.DB) error {
|
|
out, err = extractDatabaseTx(tx)
|
|
return err
|
|
}, snapshotTxOptions(s.db.Name()))
|
|
if err != nil {
|
|
return DatabaseExport{}, err
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// snapshotTxOptions returns the transaction options that give a read a consistent snapshot on the given database provider, without blocking concurrent writers
|
|
func snapshotTxOptions(provider string) *sql.TxOptions {
|
|
opts := &sql.TxOptions{
|
|
ReadOnly: true,
|
|
}
|
|
|
|
// Postgres defaults to read committed, which takes a new snapshot for every statement, so tables read later in the export would include writes that landed after it started
|
|
// Repeatable read instead pins a single snapshot for the whole transaction
|
|
// SQLite is left at the driver's default: in WAL mode (the one used by Pocket ID) a read transaction already sees one consistent snapshot, and the driver rejects nothing but also honors no other isolation level
|
|
if provider == "postgres" {
|
|
opts.Isolation = sql.LevelRepeatableRead
|
|
}
|
|
|
|
return opts
|
|
}
|
|
|
|
// extractDatabaseTx dumps every exported table using the given transaction
|
|
func extractDatabaseTx(tx *gorm.DB) (DatabaseExport, error) {
|
|
schema, err := utils.LoadDBSchemaTypes(tx)
|
|
if err != nil {
|
|
return DatabaseExport{}, fmt.Errorf("failed to load schema types: %w", err)
|
|
}
|
|
|
|
version, err := schemaVersion(tx)
|
|
if err != nil {
|
|
return DatabaseExport{}, err
|
|
}
|
|
|
|
out := DatabaseExport{
|
|
Provider: tx.Name(),
|
|
Version: version,
|
|
Tables: map[string][]map[string]any{},
|
|
// These tables need to be inserted in a specific order because of foreign key constraints
|
|
// Not all tables are listed here, because not all tables are order-dependent
|
|
TableOrder: []string{"users", "user_groups", "oidc_clients", "oauth2_sessions", "signup_tokens", "apis", "api_permissions", "oidc_clients_allowed_apis", "oidc_clients_allowed_api_permissions"},
|
|
}
|
|
|
|
for table := range schema {
|
|
// Skip internal tables and the actor host's own "francis_" tables
|
|
if table == "storage" || table == "schema_migrations" || strings.HasPrefix(table, "francis_") {
|
|
continue
|
|
}
|
|
err = dumpTable(tx, table, schema[table], &out)
|
|
if err != nil {
|
|
return DatabaseExport{}, err
|
|
}
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
func schemaVersion(tx *gorm.DB) (uint, error) {
|
|
var version uint
|
|
err := tx.Raw("SELECT version FROM schema_migrations").Row().Scan(&version)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to query schema version: %w", err)
|
|
}
|
|
return version, nil
|
|
}
|
|
|
|
// dumpTable selects all rows from a table and appends them to out.Tables
|
|
func dumpTable(tx *gorm.DB, table string, types utils.DBSchemaTableTypes, out *DatabaseExport) error {
|
|
rows, err := tx.Raw("SELECT * FROM " + table).Rows()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read table %s: %w", table, err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
cols, _ := rows.Columns()
|
|
if len(cols) != len(types) {
|
|
// Should never happen...
|
|
return fmt.Errorf("mismatched columns in table (%d) and schema (%d)", len(cols), len(types))
|
|
}
|
|
|
|
for rows.Next() {
|
|
vals := getScanValuesForTable(cols, types)
|
|
err = rows.Scan(vals...)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to scan row in table %s: %w", table, err)
|
|
}
|
|
|
|
rowMap := make(map[string]any, len(cols))
|
|
for i, col := range cols {
|
|
rowMap[col] = vals[i]
|
|
}
|
|
|
|
out.Tables[table] = append(out.Tables[table], rowMap)
|
|
}
|
|
|
|
return rows.Err()
|
|
}
|
|
|
|
func getScanValuesForTable(cols []string, types utils.DBSchemaTableTypes) []any {
|
|
res := make([]any, len(cols))
|
|
for i, col := range cols {
|
|
// Store a pointer
|
|
// Note: don't create a helper function for this switch, because it would return type "any" and mess everything up
|
|
// If the column is nullable, we need a pointer to a pointer!
|
|
switch types[col].Name {
|
|
case "boolean", "bool":
|
|
var x bool
|
|
if types[col].Nullable {
|
|
res[i] = new(new(x))
|
|
} else {
|
|
res[i] = new(x)
|
|
}
|
|
case "blob", "bytea", "jsonb":
|
|
// Treat jsonb columns as binary too
|
|
var x []byte
|
|
if types[col].Nullable {
|
|
res[i] = new(new(x))
|
|
} else {
|
|
res[i] = new(x)
|
|
}
|
|
case "timestamp", "timestamptz", "timestamp with time zone", "datetime":
|
|
var x datatype.DateTime
|
|
if types[col].Nullable {
|
|
res[i] = new(new(x))
|
|
} else {
|
|
res[i] = new(x)
|
|
}
|
|
case "integer", "int", "bigint":
|
|
var x int64
|
|
if types[col].Nullable {
|
|
res[i] = new(new(x))
|
|
} else {
|
|
res[i] = new(x)
|
|
}
|
|
default:
|
|
// Treat everything else as a string (including the "numeric" type)
|
|
var x string
|
|
if types[col].Nullable {
|
|
res[i] = new(new(x))
|
|
} else {
|
|
res[i] = new(x)
|
|
}
|
|
}
|
|
}
|
|
|
|
return res
|
|
}
|
|
|
|
func (s *ExportService) writeExportZipStream(ctx context.Context, w io.Writer, dbData DatabaseExport) error {
|
|
zipWriter := zip.NewWriter(w)
|
|
|
|
// Add database.json
|
|
jsonWriter, err := zipWriter.Create("database.json")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create database.json in zip: %w", err)
|
|
}
|
|
|
|
jsonEncoder := json.NewEncoder(jsonWriter)
|
|
jsonEncoder.SetEscapeHTML(false)
|
|
|
|
err = jsonEncoder.Encode(dbData)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to encode database.json: %w", err)
|
|
}
|
|
|
|
// Add the actor host's data
|
|
err = s.addActorsBackupToZip(ctx, zipWriter)
|
|
if err != nil {
|
|
return fmt.Errorf("error adding the actor host's data to the export zip: %w", err)
|
|
}
|
|
|
|
// Add uploaded files
|
|
err = s.addUploadsToZip(ctx, zipWriter)
|
|
if err != nil {
|
|
return fmt.Errorf("error adding uploads to the export zip: %w", err)
|
|
}
|
|
|
|
err = zipWriter.Close()
|
|
if err != nil {
|
|
return fmt.Errorf("error closing the zip writer: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// addActorsBackupToZip adds the actor host's data (actor state, alarms, and dead-lettered jobs) to the ZIP archive as a Francis backup stream
|
|
// That data lives in the actor host's own tables, so it's exported through Francis' own portable backup format instead
|
|
func (s *ExportService) addActorsBackupToZip(ctx context.Context, zipWriter *zip.Writer) error {
|
|
if s.actors == nil {
|
|
return nil
|
|
}
|
|
|
|
w, err := zipWriter.Create(actorsBackupFileName)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create %s in zip: %w", actorsBackupFileName, err)
|
|
}
|
|
|
|
err = s.actors.Backup(ctx, w)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to back up the actor host's data: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// addUploadsToZip adds all files from the storage to the ZIP archive under the "uploads/" directory
|
|
func (s *ExportService) addUploadsToZip(ctx context.Context, zipWriter *zip.Writer) error {
|
|
return s.storage.Walk(ctx, "/", func(p storage.ObjectInfo) error {
|
|
zipPath := filepath.Join("uploads", p.Path)
|
|
|
|
w, err := zipWriter.Create(zipPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create zip entry for '%s': %w", zipPath, err)
|
|
}
|
|
|
|
f, _, err := s.storage.Open(ctx, p.Path)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open file '%s': %w", zipPath, err)
|
|
}
|
|
defer f.Close()
|
|
|
|
_, err = io.Copy(w, f)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to copy file '%s' into zip: %w", zipPath, err)
|
|
}
|
|
return nil
|
|
})
|
|
}
|