Files
musenalm/helpers/exports/exporter.go
Simon Martens b0a57884bf +Datenexport
2026-01-28 17:26:04 +01:00

303 lines
7.3 KiB
Go

package exports
import (
"archive/zip"
"encoding/xml"
"os"
"path/filepath"
"strings"
"github.com/Theodor-Springmann-Stiftung/musenalm/dbmodels"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
)
const exportDirName = "exports"
func ExportDir(app core.App) (string, error) {
base := filepath.Join(app.DataDir(), exportDirName)
if err := os.MkdirAll(base, 0o755); err != nil {
return "", err
}
return base, nil
}
func ListTables(app core.App) ([]string, error) {
tables := []string{}
err := app.DB().
NewQuery("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").
Column(&tables)
if err != nil {
return nil, err
}
excluded := map[string]struct{}{
"_superusers": {},
"_mfas": {},
"_otps": {},
"_externalAuths": {},
"_authorigins": {},
"_authOrigins": {},
"access_tokens": {},
}
filtered := tables[:0]
for _, table := range tables {
if strings.HasPrefix(table, dbmodels.FTS5_PREFIX) {
continue
}
if _, ok := excluded[table]; ok {
continue
}
filtered = append(filtered, table)
}
return filtered, nil
}
func Run(app core.App, exportID string) error {
record, err := app.FindRecordById(dbmodels.EXPORTS_TABLE, exportID)
if err != nil {
return err
}
tables, err := ListTables(app)
if err != nil {
return markFailed(app, record, err)
}
record.Set(dbmodels.EXPORT_STATUS_FIELD, dbmodels.EXPORT_STATUS_RUNNING)
record.Set(dbmodels.EXPORT_TABLES_TOTAL_FIELD, len(tables))
record.Set(dbmodels.EXPORT_TABLES_DONE_FIELD, 0)
record.Set(dbmodels.EXPORT_PROGRESS_FIELD, 0)
record.Set(dbmodels.EXPORT_CURRENT_TABLE_FIELD, "")
record.Set(dbmodels.EXPORT_ERROR_FIELD, "")
if err := app.Save(record); err != nil {
return err
}
exportDir, err := ExportDir(app)
if err != nil {
return markFailed(app, record, err)
}
filename := exportID + ".zip"
tempPath := filepath.Join(exportDir, filename+".tmp")
finalPath := filepath.Join(exportDir, filename)
file, err := os.Create(tempPath)
if err != nil {
return markFailed(app, record, err)
}
defer func() {
if file != nil {
_ = file.Close()
}
}()
zipWriter := zip.NewWriter(file)
for idx, table := range tables {
updateProgress(app, record, table, idx, len(tables))
if err := exportTableZipEntry(app, zipWriter, table); err != nil {
return markFailed(app, record, err)
}
updateProgress(app, record, table, idx+1, len(tables))
}
if err := zipWriter.Close(); err != nil {
return markFailed(app, record, err)
}
if err := file.Close(); err != nil {
return markFailed(app, record, err)
}
file = nil
if err := os.Rename(tempPath, finalPath); err != nil {
return markFailed(app, record, err)
}
stat, err := os.Stat(finalPath)
if err != nil {
return markFailed(app, record, err)
}
record.Set(dbmodels.EXPORT_STATUS_FIELD, dbmodels.EXPORT_STATUS_COMPLETE)
record.Set(dbmodels.EXPORT_PROGRESS_FIELD, 100)
record.Set(dbmodels.EXPORT_TABLES_DONE_FIELD, len(tables))
record.Set(dbmodels.EXPORT_FILENAME_FIELD, filename)
record.Set(dbmodels.EXPORT_SIZE_FIELD, stat.Size())
record.Set(dbmodels.EXPORT_CURRENT_TABLE_FIELD, "")
record.Set(dbmodels.EXPORT_ERROR_FIELD, "")
if err := app.Save(record); err != nil {
return err
}
return nil
}
func exportTableZipEntry(app core.App, zipWriter *zip.Writer, table string) error {
entryName := safeFilename(table)
if entryName == "" {
entryName = "table"
}
entryName += ".xml"
entry, err := zipWriter.Create(entryName)
if err != nil {
return err
}
if _, err := entry.Write([]byte(xml.Header)); err != nil {
return err
}
encoder := xml.NewEncoder(entry)
start := xml.StartElement{
Name: xml.Name{Local: "table"},
Attr: []xml.Attr{{Name: xml.Name{Local: "name"}, Value: table}},
}
if err := encoder.EncodeToken(start); err != nil {
return err
}
query := "SELECT * FROM " + quoteTableName(table)
rows, err := app.DB().NewQuery(query).Rows()
if err != nil {
return err
}
defer rows.Close()
columns, err := rows.Columns()
if err != nil {
return err
}
for rows.Next() {
rowData := dbx.NullStringMap{}
if err := rows.ScanMap(rowData); err != nil {
return err
}
if err := encoder.EncodeToken(xml.StartElement{Name: xml.Name{Local: "row"}}); err != nil {
return err
}
sensitiveFields := map[string]struct{}{}
if table == "users" {
sensitiveFields = map[string]struct{}{
"password": {},
"password_hash": {},
"passwordhash": {},
"tokenkey": {},
"token_key": {},
}
}
for _, col := range columns {
lowerCol := strings.ToLower(col)
if _, ok := sensitiveFields[lowerCol]; ok {
if err := encoder.EncodeToken(xml.StartElement{Name: xml.Name{Local: col}, Attr: []xml.Attr{{Name: xml.Name{Local: "null"}, Value: "true"}}}); err != nil {
return err
}
if err := encoder.EncodeToken(xml.EndElement{Name: xml.Name{Local: col}}); err != nil {
return err
}
continue
}
value := rowData[col]
attrs := []xml.Attr{}
if !value.Valid {
attrs = append(attrs, xml.Attr{Name: xml.Name{Local: "null"}, Value: "true"})
}
if err := encoder.EncodeToken(xml.StartElement{Name: xml.Name{Local: col}, Attr: attrs}); err != nil {
return err
}
if value.Valid {
if err := encoder.EncodeToken(xml.CharData([]byte(value.String))); err != nil {
return err
}
}
if err := encoder.EncodeToken(xml.EndElement{Name: xml.Name{Local: col}}); err != nil {
return err
}
}
if err := encoder.EncodeToken(xml.EndElement{Name: xml.Name{Local: "row"}}); err != nil {
return err
}
}
if err := rows.Err(); err != nil {
return err
}
if err := encoder.EncodeToken(start.End()); err != nil {
return err
}
return encoder.Flush()
}
func updateProgress(app core.App, record *core.Record, table string, done, total int) {
progress := 0
if total > 0 {
progress = int(float64(done) / float64(total) * 100)
}
record.Set(dbmodels.EXPORT_CURRENT_TABLE_FIELD, table)
record.Set(dbmodels.EXPORT_TABLES_DONE_FIELD, done)
record.Set(dbmodels.EXPORT_PROGRESS_FIELD, progress)
if err := app.Save(record); err != nil {
app.Logger().Error("Export progress update failed", "error", err, "export_id", record.Id)
}
}
func markFailed(app core.App, record *core.Record, err error) error {
record.Set(dbmodels.EXPORT_STATUS_FIELD, dbmodels.EXPORT_STATUS_FAILED)
record.Set(dbmodels.EXPORT_ERROR_FIELD, err.Error())
record.Set(dbmodels.EXPORT_CURRENT_TABLE_FIELD, "")
record.Set(dbmodels.EXPORT_PROGRESS_FIELD, 0)
if saveErr := app.Save(record); saveErr != nil {
return saveErr
}
exportDir, dirErr := ExportDir(app)
if dirErr == nil {
filename := record.GetString(dbmodels.EXPORT_FILENAME_FIELD)
if filename == "" {
filename = record.Id + ".zip"
}
filename = filepath.Base(filename)
_ = os.Remove(filepath.Join(exportDir, filename))
_ = os.Remove(filepath.Join(exportDir, filename+".tmp"))
}
return err
}
func quoteTableName(name string) string {
return "`" + strings.ReplaceAll(name, "`", "``") + "`"
}
func safeFilename(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return ""
}
var b strings.Builder
b.Grow(len(name))
for _, r := range name {
if (r >= 'a' && r <= 'z') ||
(r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') ||
r == '_' || r == '-' || r == '.' {
b.WriteRune(r)
} else {
b.WriteByte('_')
}
}
return b.String()
}