Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
123 lines
4.4 KiB
Go
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)
|
|
}
|