// Package storage is the PostgreSQL-backed metadata + file-blob store for // archivdms. It follows archivmail's internal/storage pattern (idempotent // initSchema() run at startup, no external migration tool — see // internal/storage/migrations/README.md for the documentation convention) // but the core model is `documents`, not `emails`. // // Multi-tenancy is applied at the application layer: every query filters // manually by tenant_id (no Postgres row-level security), consistent with // archivmail. package storage import ( "context" "fmt" "log/slog" "os" "path/filepath" "archivdms/internal/index" "github.com/jackc/pgx/v5/pgxpool" ) // Config holds the configuration for initialising a Store. type Config struct { Dir string // base directory for document blob storage DSN string // PostgreSQL DSN RetentionDays int // default GoBD retention period in days (0 = no default lock) } // Store is the document metadata store (PostgreSQL) plus a file-based blob // store for the underlying document files. type Store struct { dir string db *pgxpool.Pool retentionDays int // indexer is the optional (nil-able) full-text search sync layer // (internal/index). When nil, all index sync calls are silent no-ops — // Postgres stays the single source of truth. Wired via SetIndexer. indexer index.TenantIndexer // logger is used only for best-effort index-sync warnings. May be nil. logger *slog.Logger } // SetIndexer wires the optional full-text search index (internal/index) into // the store, together with a logger for best-effort sync warnings. Both may be // nil (index disabled). Call once at startup, before serving requests. func (s *Store) SetIndexer(indexer index.TenantIndexer, logger *slog.Logger) { s.indexer = indexer s.logger = logger } // New initialises the storage directory and connects to PostgreSQL, creating // the schema if needed. func New(cfg Config) (*Store, error) { for _, sub := range []string{"documents"} { if err := os.MkdirAll(filepath.Join(cfg.Dir, sub), 0o700); err != nil { return nil, fmt.Errorf("storage: mkdir %s: %w", sub, err) } } s := &Store{dir: cfg.Dir, retentionDays: cfg.RetentionDays} if cfg.DSN != "" { pool, err := pgxpool.New(context.Background(), cfg.DSN) if err != nil { return nil, fmt.Errorf("storage: db connect: %w", err) } s.db = pool if err := s.initSchema(context.Background()); err != nil { pool.Close() return nil, fmt.Errorf("storage: init schema: %w", err) } // Reminders (Wiedervorlage) schema — kept in its own file (reminders.go) // following the archivmail saved_searches.go pattern, but wired in here // so a single storage.New() call brings up the whole schema. if err := s.initReminderSchema(context.Background()); err != nil { pool.Close() return nil, fmt.Errorf("storage: init reminder schema: %w", err) } // SFTP credentials (internal/sftpserver) — kept in its own file // (sftp_credentials.go), wired in here like reminders above. if err := s.initSFTPCredentialsSchema(context.Background()); err != nil { pool.Close() return nil, fmt.Errorf("storage: init sftp credentials schema: %w", err) } // Group-resolved document ACL (permissions.go) — wired in here like the // schemas above so a single storage.New() brings up the whole schema. if err := s.initPermissionsSchema(context.Background()); err != nil { pool.Close() return nil, fmt.Errorf("storage: init permissions schema: %w", err) } // External document share-links (shares.go) — wired in here like the // schemas above so a single storage.New() brings up the whole schema. if err := s.initSharesSchema(context.Background()); err != nil { pool.Close() return nil, fmt.Errorf("storage: init shares schema: %w", err) } // Per-tenant API keys for the Buchhaltungs-Pull-API // (accounting_api_keys.go) — wired in here like the schemas above. if err := s.initAccountingAPIKeysSchema(context.Background()); err != nil { pool.Close() return nil, fmt.Errorf("storage: init accounting api keys schema: %w", err) } } return s, nil } // Close releases the database connection pool (if any). func (s *Store) Close() { if s.db != nil { s.db.Close() } } // DocumentPath returns the on-disk path for a document's stored blob, // addressed by content hash (WORM-friendly: same content -> same path). func (s *Store) DocumentPath(contentHash string) string { return filepath.Join(s.dir, "documents", contentHash) }