Files
patrick 9a24ea29e1 FDN-01: repository & projektgerüst
Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
2026-08-11 21:27:53 +02:00

123 lines
4.4 KiB
Go

// 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)
}