// 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): // // /inbox//. raw upload, scratch, writable (0640) // /store////. finished archive, WORM (0440) // /ocr-tmp// OCR scratch, removed after use // /thumbnails//.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// 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. // - / 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) }