Changeset 0.34.0 (#208)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
211
server/export/format.go
Normal file
211
server/export/format.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package export
|
||||
|
||||
// format.go — v0.34.0 CS0
|
||||
//
|
||||
// Defines the .switchboard archive format: a zip file containing a
|
||||
// manifest.json and per-entity JSON files under data/. File blobs
|
||||
// live under files/{id}/{filename}.
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── Constants ────────────────────────────────
|
||||
|
||||
const (
|
||||
FormatVersion = 1
|
||||
MaxExportArchiveSize = 500 * 1024 * 1024 // 500 MB total
|
||||
MaxExportFileSize = 100 * 1024 * 1024 // 100 MB per file blob
|
||||
MaxExportFiles = 10000
|
||||
ExportContentType = "application/zip"
|
||||
ExportExtension = ".switchboard"
|
||||
)
|
||||
|
||||
// ── Manifest ─────────────────────────────────
|
||||
|
||||
// Manifest is the top-level metadata written to manifest.json.
|
||||
type Manifest struct {
|
||||
Version string `json:"version"` // server version, e.g. "0.34.0"
|
||||
FormatVersion int `json:"format_version"` // always 1 for now
|
||||
ExportType string `json:"export_type"` // "user", "team"
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExportedBy string `json:"exported_by"` // user ID
|
||||
EntityCounts map[string]int `json:"entity_counts"`
|
||||
Scope *ManifestScope `json:"scope,omitempty"`
|
||||
}
|
||||
|
||||
// ManifestScope narrows the export to a user or team.
|
||||
type ManifestScope struct {
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
TeamID string `json:"team_id,omitempty"`
|
||||
}
|
||||
|
||||
// ── ArchiveWriter ────────────────────────────
|
||||
|
||||
// ArchiveWriter wraps zip.Writer with size tracking and convenience
|
||||
// methods for writing manifest and entity JSON files.
|
||||
type ArchiveWriter struct {
|
||||
zw *zip.Writer
|
||||
written int64
|
||||
maxBytes int64
|
||||
}
|
||||
|
||||
// NewArchiveWriter creates an ArchiveWriter that streams to w.
|
||||
func NewArchiveWriter(w io.Writer) *ArchiveWriter {
|
||||
return &ArchiveWriter{
|
||||
zw: zip.NewWriter(w),
|
||||
maxBytes: MaxExportArchiveSize,
|
||||
}
|
||||
}
|
||||
|
||||
// WriteManifest serializes m as indented JSON into manifest.json.
|
||||
func (aw *ArchiveWriter) WriteManifest(m *Manifest) error {
|
||||
data, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("export: marshal manifest: %w", err)
|
||||
}
|
||||
return aw.writeEntry("manifest.json", data)
|
||||
}
|
||||
|
||||
// WriteEntityJSON serializes data as indented JSON into data/{name}.json.
|
||||
func (aw *ArchiveWriter) WriteEntityJSON(name string, data interface{}) (int, error) {
|
||||
buf, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("export: marshal %s: %w", name, err)
|
||||
}
|
||||
if err := aw.writeEntry("data/"+name+".json", buf); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Return entity count for manifest
|
||||
switch v := data.(type) {
|
||||
case []interface{}:
|
||||
return len(v), nil
|
||||
default:
|
||||
// Use json.RawMessage length heuristic — caller tracks count
|
||||
return 0, nil
|
||||
}
|
||||
}
|
||||
|
||||
// WriteFile streams a file blob into files/{zipPath}. Returns bytes
|
||||
// written or an error if the file exceeds MaxExportFileSize.
|
||||
func (aw *ArchiveWriter) WriteFile(zipPath string, r io.Reader) (int64, error) {
|
||||
if aw.written+1 > aw.maxBytes {
|
||||
return 0, fmt.Errorf("export: archive size limit exceeded")
|
||||
}
|
||||
f, err := aw.zw.Create("files/" + zipPath)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("export: create zip entry %s: %w", zipPath, err)
|
||||
}
|
||||
lr := io.LimitReader(r, MaxExportFileSize+1)
|
||||
n, err := io.Copy(f, lr)
|
||||
if err != nil {
|
||||
return n, fmt.Errorf("export: write file %s: %w", zipPath, err)
|
||||
}
|
||||
if n > MaxExportFileSize {
|
||||
return n, fmt.Errorf("export: file %s exceeds %d bytes", zipPath, MaxExportFileSize)
|
||||
}
|
||||
aw.written += n
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Close finalizes the zip archive.
|
||||
func (aw *ArchiveWriter) Close() error {
|
||||
return aw.zw.Close()
|
||||
}
|
||||
|
||||
// BytesWritten returns the approximate bytes written so far.
|
||||
func (aw *ArchiveWriter) BytesWritten() int64 {
|
||||
return aw.written
|
||||
}
|
||||
|
||||
// ── ArchiveReader ────────────────────────────
|
||||
|
||||
// ArchiveReader wraps zip.ReadCloser with validation helpers.
|
||||
type ArchiveReader struct {
|
||||
zr *zip.ReadCloser
|
||||
}
|
||||
|
||||
// OpenArchive opens and validates a .switchboard zip archive.
|
||||
func OpenArchive(path string) (*ArchiveReader, error) {
|
||||
zr, err := zip.OpenReader(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: open archive: %w", err)
|
||||
}
|
||||
return &ArchiveReader{zr: zr}, nil
|
||||
}
|
||||
|
||||
// ReadManifest reads and validates manifest.json from the archive.
|
||||
func (ar *ArchiveReader) ReadManifest() (*Manifest, error) {
|
||||
for _, f := range ar.zr.File {
|
||||
if f.Name == "manifest.json" {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: open manifest: %w", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
var m Manifest
|
||||
if err := json.NewDecoder(rc).Decode(&m); err != nil {
|
||||
return nil, fmt.Errorf("export: decode manifest: %w", err)
|
||||
}
|
||||
if m.FormatVersion > FormatVersion {
|
||||
return nil, fmt.Errorf("export: unsupported format version %d (max %d)", m.FormatVersion, FormatVersion)
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("export: manifest.json not found in archive")
|
||||
}
|
||||
|
||||
// ReadEntityJSON reads data/{name}.json into target.
|
||||
func (ar *ArchiveReader) ReadEntityJSON(name string, target interface{}) error {
|
||||
path := "data/" + name + ".json"
|
||||
for _, f := range ar.zr.File {
|
||||
if f.Name == path {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("export: open %s: %w", path, err)
|
||||
}
|
||||
defer rc.Close()
|
||||
if err := json.NewDecoder(rc).Decode(target); err != nil {
|
||||
return fmt.Errorf("export: decode %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
// Entity file not present — not an error (sparse archives are valid)
|
||||
return nil
|
||||
}
|
||||
|
||||
// FileEntries returns zip entries under files/.
|
||||
func (ar *ArchiveReader) FileEntries() []*zip.File {
|
||||
var out []*zip.File
|
||||
for _, f := range ar.zr.File {
|
||||
if len(f.Name) > 6 && f.Name[:6] == "files/" && !f.FileInfo().IsDir() {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Close closes the underlying zip reader.
|
||||
func (ar *ArchiveReader) Close() error {
|
||||
return ar.zr.Close()
|
||||
}
|
||||
|
||||
// writeEntry writes raw bytes as a zip entry, tracking total size.
|
||||
func (aw *ArchiveWriter) writeEntry(name string, data []byte) error {
|
||||
if aw.written+int64(len(data)) > aw.maxBytes {
|
||||
return fmt.Errorf("export: archive size limit exceeded writing %s", name)
|
||||
}
|
||||
f, err := aw.zw.Create(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("export: create entry %s: %w", name, err)
|
||||
}
|
||||
n, err := f.Write(data)
|
||||
aw.written += int64(n)
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user