Files
archivdms/internal/objectstore/objectstore.go
T
patrick 89de794356
CI / Backend (go vet, go test -cover) (push) Has been cancelled
CI / Frontend (ESLint, tsc, next build) (push) Has been cancelled
FDN-02/FDN-03/FDN-07/FDN-08: Migrations-Rollback, Objekt-Storage-Interface, go.sum-Fix, Observability
- FDN-02: Rollback-fähige Down-Migrationen (024-026), archivdms seed dev CLI
- FDN-03: internal/objectstore Interface + lokaler WORM-Treiber, signierte Download-URLs
- FDN-07: go.mod/go.sum vervollständigt (fehlender go-ldap/v3-Eintrag), CI-Pipeline (.gitea/workflows/ci.yml, bereits in FDN-01 committet) damit lauffähig
- FDN-08: Request-ID-Middleware, /metrics-Endpoint, Panic-Recovery, Login/Logout/Me technisches Logging inkl. Access-Log je Anfrage
2026-08-11 22:27:52 +02:00

106 lines
5.2 KiB
Go

// Package objectstore puts the existing local WORM document storage behind a
// small Go interface (FDN-03). It is a pure abstraction layer: the on-disk
// layout, the chmod 0440 WORM lock and the SHA-256 content addressing are
// exactly the ones the upload pipeline has always used — nothing about the
// path scheme or the archival semantics changes here.
//
// # Pfadschema (bestehend, NICHT verändert)
//
// All paths are rooted at config.Storage.BasePath (default /var/lib/archivdms):
//
// <BasePath>/inbox/<tenant_id>/<random>.<ext> raw upload, scratch, writable (0640)
// <BasePath>/store/<tenant_id>/<yyyy>/<mm>/<sha256>.<ext> finished archive, WORM (0440)
// <BasePath>/ocr-tmp/<random>/ OCR scratch, removed after use
// <BasePath>/thumbnails/<tenant_id>/<sha256>.png regenerable preview, not WORM
//
// Properties of the store/ layer that callers may rely on:
//
// - Tenant separation is the FIRST path segment: every object of a tenant
// lives below store/<tenant_id>/ and nowhere else. Open/Stat/Delete
// therefore verify that the given path really is inside that tenant's
// subtree (containment check) — a stored path from a foreign tenant is
// rejected with ErrOutsideTenant instead of being read.
// - <yyyy>/<mm> is derived from the archival (upload) time, not from the
// recognised Belegdatum: after the WORM move a file is never moved again.
// - The file name is the lowercase hex SHA-256 of the file content plus the
// original extension. Content addressing gives byte-identical re-uploads
// the same path, which is the filesystem half of the duplicate protection
// (the DB unique index on (tenant_id, content_hash) is the other half).
// - Archived files are chmod 0440. The directory stays writable for the
// service user, so a legally confirmed deletion (after retain_until) can
// still unlink the file — no code path ever overwrites an archived file.
// - Nothing is encrypted or container-wrapped: every object is readable with
// plain OS tools, deliberately unlike a closed vendor archive.
//
// Deliberately NO S3/object-storage driver: the WORM/GoBD guarantee rests on
// POSIX file permissions (0440) which an object store cannot provide in the
// same way. The local driver is and stays the only implementation.
package objectstore
import (
"context"
"errors"
"io"
"net/url"
"os"
"time"
)
// Storage-level errors. Callers map these onto HTTP status codes / domain
// errors (e.g. ErrObjectExists -> storage.ErrDuplicateContentHash).
var (
// ErrObjectExists is returned by Archive when the target WORM path is
// already taken, i.e. the identical content is already archived.
ErrObjectExists = errors.New("objectstore: object already exists")
// ErrObjectNotFound is returned by Open/Stat/Delete when the object does
// not exist on disk.
ErrObjectNotFound = errors.New("objectstore: object not found")
// ErrOutsideTenant is returned when a storage path does not resolve into
// the requesting tenant's store subtree (IDOR / path-traversal guard).
ErrOutsideTenant = errors.New("objectstore: path outside tenant store")
// ErrSignatureInvalid is returned when a signed URL is malformed or its
// HMAC does not verify.
ErrSignatureInvalid = errors.New("objectstore: signature invalid")
// ErrSignatureExpired is returned when a signed URL's expiry has passed.
ErrSignatureExpired = errors.New("objectstore: signature expired")
)
// SignedRef is the payload carried by a signed download URL: which document of
// which tenant may be downloaded, and until when.
type SignedRef struct {
TenantID int64
DocumentID int64
ExpiresAt time.Time
}
// Store is the document blob storage abstraction. Every method is
// tenant-scoped; there is intentionally no "list everything" call.
type Store interface {
// Archive moves an already-hashed scratch file (inbox or split part) into
// the tenant's WORM store and locks it with chmod 0440. It returns the
// final storage path. On success the caller no longer owns srcPath; on
// failure srcPath is removed. Returns ErrObjectExists when the content is
// already archived (duplicate).
Archive(ctx context.Context, tenantID int64, srcPath, ext, contentHash string, at time.Time) (string, error)
// Open opens an archived object read-only after verifying that
// storagePath belongs to tenantID.
Open(ctx context.Context, tenantID int64, storagePath string) (io.ReadSeekCloser, error)
// Stat reports metadata of an archived object (tenant-checked).
Stat(ctx context.Context, tenantID int64, storagePath string) (os.FileInfo, error)
// Delete unlinks an archived object (tenant-checked). Only ever called
// after a confirmed, retention-cleared deletion request; a missing file is
// reported as ErrObjectNotFound.
Delete(ctx context.Context, tenantID int64, storagePath string) error
// SignedURL builds a time-limited, HMAC-signed download URL for a
// document. ttl <= 0 uses the configured default validity.
SignedURL(tenantID, documentID int64, ttl time.Duration) (string, error)
// VerifySignedURL validates the query parameters of a signed URL against
// the signing key and the current time.
VerifySignedURL(q url.Values, now time.Time) (SignedRef, error)
}