- 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
106 lines
5.2 KiB
Go
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)
|
|
}
|