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
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
package objectstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/hkdf"
|
||||
|
||||
"archivdms/config"
|
||||
)
|
||||
|
||||
// signKeyInfo domain-separates the download-URL signing key from every other
|
||||
// key derived from the same master secret (JWT uses "archivdms-jwt-v1", the
|
||||
// LDAP secretbox uses "archivdms-ldap-secretbox-v1").
|
||||
const signKeyInfo = "archivdms-storage-url-v1"
|
||||
|
||||
// SignedPath is the route a signed download URL points at. It is served
|
||||
// WITHOUT session auth — the signature is the credential.
|
||||
const SignedPath = "/public/files"
|
||||
|
||||
// LocalStore is the local-filesystem WORM driver and the only implementation
|
||||
// of Store. It owns no state beyond the storage configuration, the URL signing
|
||||
// key and the public base URL used for link generation.
|
||||
type LocalStore struct {
|
||||
cfg config.StorageConfig
|
||||
signKey []byte
|
||||
baseURL string
|
||||
}
|
||||
|
||||
// compile-time interface check.
|
||||
var _ Store = (*LocalStore)(nil)
|
||||
|
||||
// NewLocalStore builds the local driver. secret is the application master
|
||||
// secret (config.APIConfig.Secret) from which the URL signing key is derived
|
||||
// via HKDF-SHA256; baseURL is the public origin used for generated links
|
||||
// (empty = emit site-relative URLs).
|
||||
func NewLocalStore(cfg config.StorageConfig, secret, baseURL string) (*LocalStore, error) {
|
||||
if strings.TrimSpace(secret) == "" {
|
||||
return nil, fmt.Errorf("objectstore: empty signing secret")
|
||||
}
|
||||
key := make([]byte, 32)
|
||||
if _, err := io.ReadFull(hkdf.New(sha256.New, []byte(secret), nil, []byte(signKeyInfo)), key); err != nil {
|
||||
return nil, fmt.Errorf("objectstore: derive signing key: %w", err)
|
||||
}
|
||||
return &LocalStore{
|
||||
cfg: cfg,
|
||||
signKey: key,
|
||||
baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// tenantRoot is the tenant's WORM subtree: <BasePath>/store/<tenant_id>.
|
||||
func (l *LocalStore) tenantRoot(tenantID int64) string {
|
||||
return filepath.Join(l.cfg.StorePath(), strconv.FormatInt(tenantID, 10))
|
||||
}
|
||||
|
||||
// resolveTenantPath cleans storagePath and verifies it lies inside the
|
||||
// tenant's own store subtree. This is the filesystem-level counterpart of the
|
||||
// "WHERE tenant_id = $N" rule: even a manipulated storage_path from the DB
|
||||
// cannot be used to read another tenant's archive.
|
||||
func (l *LocalStore) resolveTenantPath(tenantID int64, storagePath string) (string, error) {
|
||||
if strings.TrimSpace(storagePath) == "" {
|
||||
return "", ErrObjectNotFound
|
||||
}
|
||||
clean := filepath.Clean(storagePath)
|
||||
root := filepath.Clean(l.tenantRoot(tenantID))
|
||||
if clean != root && !strings.HasPrefix(clean, root+string(os.PathSeparator)) {
|
||||
return "", ErrOutsideTenant
|
||||
}
|
||||
return clean, nil
|
||||
}
|
||||
|
||||
// Archive implements Store. It reproduces, unchanged, the archival steps the
|
||||
// upload pipeline has always performed: build store/<tenant>/<yyyy>/<mm>,
|
||||
// reject an existing target as duplicate, move (rename, copy+remove fallback
|
||||
// across devices) and finally chmod 0440 — the one and only chmod, applied
|
||||
// once the file sits at its final path.
|
||||
func (l *LocalStore) Archive(ctx context.Context, tenantID int64, srcPath, ext, contentHash string, at time.Time) (string, error) {
|
||||
if at.IsZero() {
|
||||
at = time.Now()
|
||||
}
|
||||
storeDir := filepath.Join(l.tenantRoot(tenantID),
|
||||
fmt.Sprintf("%04d", at.Year()), fmt.Sprintf("%02d", at.Month()))
|
||||
if err := os.MkdirAll(storeDir, 0o750); err != nil {
|
||||
os.Remove(srcPath)
|
||||
return "", fmt.Errorf("objectstore: create store dir: %w", err)
|
||||
}
|
||||
dst := filepath.Join(storeDir, contentHash+ext)
|
||||
|
||||
// Collision check: identical hash already stored -> duplicate.
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
os.Remove(srcPath)
|
||||
return "", ErrObjectExists
|
||||
} else if !os.IsNotExist(err) {
|
||||
os.Remove(srcPath)
|
||||
return "", fmt.Errorf("objectstore: stat store path: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(srcPath, dst); err != nil {
|
||||
if copyErr := copyFile(srcPath, dst); copyErr != nil {
|
||||
os.Remove(srcPath)
|
||||
return "", fmt.Errorf("objectstore: move file to store: rename failed (%v), copy fallback failed: %w", err, copyErr)
|
||||
}
|
||||
os.Remove(srcPath)
|
||||
}
|
||||
|
||||
// WORM lock: read-only for everyone from now on.
|
||||
if err := os.Chmod(dst, 0o440); err != nil {
|
||||
return "", fmt.Errorf("objectstore: chmod store file: %w", err)
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
// Open implements Store.
|
||||
func (l *LocalStore) Open(ctx context.Context, tenantID int64, storagePath string) (io.ReadSeekCloser, error) {
|
||||
p, err := l.resolveTenantPath(tenantID, storagePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := os.Open(p)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("objectstore: open %q: %w", p, ErrObjectNotFound)
|
||||
}
|
||||
return nil, fmt.Errorf("objectstore: open object: %w", err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// Stat implements Store.
|
||||
func (l *LocalStore) Stat(ctx context.Context, tenantID int64, storagePath string) (os.FileInfo, error) {
|
||||
p, err := l.resolveTenantPath(tenantID, storagePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fi, err := os.Stat(p)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("objectstore: stat %q: %w", p, ErrObjectNotFound)
|
||||
}
|
||||
return nil, fmt.Errorf("objectstore: stat object: %w", err)
|
||||
}
|
||||
return fi, nil
|
||||
}
|
||||
|
||||
// Delete implements Store. Only legitimate after a confirmed deletion request
|
||||
// whose retention period has expired — this layer performs no retention check
|
||||
// of its own; that stays in storage.ConfirmDeleteRequest, which still unlinks
|
||||
// inside its own transaction and is deliberately left untouched by FDN-03
|
||||
// (moving it here would mean handing the DB layer a filesystem driver).
|
||||
func (l *LocalStore) Delete(ctx context.Context, tenantID int64, storagePath string) error {
|
||||
p, err := l.resolveTenantPath(tenantID, storagePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(p); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("objectstore: remove %q: %w", p, ErrObjectNotFound)
|
||||
}
|
||||
return fmt.Errorf("objectstore: remove object: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SignedURL implements Store. The URL carries tenant id, document id and an
|
||||
// absolute expiry, authenticated by an HMAC-SHA256 over exactly those three
|
||||
// values — the same "unguessable token, hard expiry, server-side check"
|
||||
// principle as the external share links, only stateless (no DB row).
|
||||
func (l *LocalStore) SignedURL(tenantID, documentID int64, ttl time.Duration) (string, error) {
|
||||
if tenantID <= 0 || documentID <= 0 {
|
||||
return "", fmt.Errorf("objectstore: invalid signed url reference")
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = l.cfg.ResolvedSignedURLTTL()
|
||||
}
|
||||
exp := time.Now().Add(ttl).Unix()
|
||||
q := url.Values{}
|
||||
q.Set("t", strconv.FormatInt(tenantID, 10))
|
||||
q.Set("d", strconv.FormatInt(documentID, 10))
|
||||
q.Set("exp", strconv.FormatInt(exp, 10))
|
||||
q.Set("sig", l.sign(tenantID, documentID, exp))
|
||||
return l.baseURL + SignedPath + "?" + q.Encode(), nil
|
||||
}
|
||||
|
||||
// VerifySignedURL implements Store. Signature first, expiry second, so a
|
||||
// forged link never learns anything from the expiry branch.
|
||||
func (l *LocalStore) VerifySignedURL(q url.Values, now time.Time) (SignedRef, error) {
|
||||
tenantID, err1 := strconv.ParseInt(q.Get("t"), 10, 64)
|
||||
documentID, err2 := strconv.ParseInt(q.Get("d"), 10, 64)
|
||||
exp, err3 := strconv.ParseInt(q.Get("exp"), 10, 64)
|
||||
sig := q.Get("sig")
|
||||
if err1 != nil || err2 != nil || err3 != nil || sig == "" || tenantID <= 0 || documentID <= 0 {
|
||||
return SignedRef{}, ErrSignatureInvalid
|
||||
}
|
||||
want := l.sign(tenantID, documentID, exp)
|
||||
if !hmac.Equal([]byte(want), []byte(sig)) {
|
||||
return SignedRef{}, ErrSignatureInvalid
|
||||
}
|
||||
expiresAt := time.Unix(exp, 0)
|
||||
if !now.Before(expiresAt) {
|
||||
return SignedRef{}, ErrSignatureExpired
|
||||
}
|
||||
return SignedRef{TenantID: tenantID, DocumentID: documentID, ExpiresAt: expiresAt}, nil
|
||||
}
|
||||
|
||||
// sign returns the base64url HMAC-SHA256 over the canonical payload.
|
||||
func (l *LocalStore) sign(tenantID, documentID, exp int64) string {
|
||||
mac := hmac.New(sha256.New, l.signKey)
|
||||
fmt.Fprintf(mac, "v1|%d|%d|%d", tenantID, documentID, exp)
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// copyFile is the cross-device fallback for os.Rename (EXDEV): copy + fsync.
|
||||
// The source is removed by the caller.
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o640)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
out.Close()
|
||||
os.Remove(dst)
|
||||
return err
|
||||
}
|
||||
if err := out.Sync(); err != nil {
|
||||
out.Close()
|
||||
os.Remove(dst)
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package objectstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"archivdms/config"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) *LocalStore {
|
||||
t.Helper()
|
||||
l, err := NewLocalStore(config.StorageConfig{BasePath: t.TempDir()}, "test-master-secret", "https://dms.example.test")
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocalStore: %v", err)
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// stage writes a scratch file and returns its path plus content hash.
|
||||
func stage(t *testing.T, content string) (string, string) {
|
||||
t.Helper()
|
||||
p := filepath.Join(t.TempDir(), "scratch.pdf")
|
||||
if err := os.WriteFile(p, []byte(content), 0o640); err != nil {
|
||||
t.Fatalf("write scratch: %v", err)
|
||||
}
|
||||
sum := sha256.Sum256([]byte(content))
|
||||
return p, hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// AK 1 + Prüfung 1: Round-Trip Archive -> Open, WORM path scheme and 0440.
|
||||
func TestArchiveOpenRoundTrip(t *testing.T) {
|
||||
l := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
src, hash := stage(t, "hello worm")
|
||||
at := time.Date(2026, 3, 7, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
dst, err := l.Archive(ctx, 42, src, ".pdf", hash, at)
|
||||
if err != nil {
|
||||
t.Fatalf("Archive: %v", err)
|
||||
}
|
||||
want := filepath.Join(l.cfg.StorePath(), "42", "2026", "03", hash+".pdf")
|
||||
if dst != want {
|
||||
t.Fatalf("path scheme changed: got %q want %q", dst, want)
|
||||
}
|
||||
fi, err := os.Stat(dst)
|
||||
if err != nil {
|
||||
t.Fatalf("stat archived: %v", err)
|
||||
}
|
||||
if fi.Mode().Perm() != 0o440 {
|
||||
t.Fatalf("WORM permissions: got %o want 0440", fi.Mode().Perm())
|
||||
}
|
||||
if _, err := os.Stat(src); !os.IsNotExist(err) {
|
||||
t.Fatalf("scratch file not consumed")
|
||||
}
|
||||
|
||||
f, err := l.Open(ctx, 42, dst)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
got, _ := io.ReadAll(f)
|
||||
if string(got) != "hello worm" {
|
||||
t.Fatalf("round-trip content mismatch: %q", got)
|
||||
}
|
||||
|
||||
// Duplicate archival of the same content is rejected.
|
||||
src2, _ := stage(t, "hello worm")
|
||||
if _, err := l.Archive(ctx, 42, src2, ".pdf", hash, at); !errors.Is(err, ErrObjectExists) {
|
||||
t.Fatalf("duplicate: got %v want ErrObjectExists", err)
|
||||
}
|
||||
}
|
||||
|
||||
// AK 1: cross-tenant access is refused even with a valid path.
|
||||
func TestOpenForeignTenantRejected(t *testing.T) {
|
||||
l := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
src, hash := stage(t, "tenant one")
|
||||
dst, err := l.Archive(ctx, 1, src, ".pdf", hash, time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("Archive: %v", err)
|
||||
}
|
||||
if _, err := l.Open(ctx, 2, dst); !errors.Is(err, ErrOutsideTenant) {
|
||||
t.Fatalf("foreign tenant: got %v want ErrOutsideTenant", err)
|
||||
}
|
||||
if _, err := l.Open(ctx, 1, filepath.Join(filepath.Dir(dst), "..", "..", "..", "2", "x.pdf")); !errors.Is(err, ErrOutsideTenant) {
|
||||
t.Fatalf("traversal: got %v want ErrOutsideTenant", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Prüfung 3: missing object yields a clear, typed error.
|
||||
func TestMissingObjectErrors(t *testing.T) {
|
||||
l := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
missing := filepath.Join(l.cfg.StorePath(), "7", "2026", "01", "deadbeef.pdf")
|
||||
|
||||
if _, err := l.Open(ctx, 7, missing); !errors.Is(err, ErrObjectNotFound) {
|
||||
t.Fatalf("Open: got %v want ErrObjectNotFound", err)
|
||||
}
|
||||
if _, err := l.Stat(ctx, 7, missing); !errors.Is(err, ErrObjectNotFound) {
|
||||
t.Fatalf("Stat: got %v want ErrObjectNotFound", err)
|
||||
}
|
||||
if err := l.Delete(ctx, 7, missing); !errors.Is(err, ErrObjectNotFound) {
|
||||
t.Fatalf("Delete: got %v want ErrObjectNotFound", err)
|
||||
}
|
||||
if _, err := l.Open(ctx, 7, ""); !errors.Is(err, ErrObjectNotFound) {
|
||||
t.Fatalf("empty path: got %v want ErrObjectNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteRemovesObject(t *testing.T) {
|
||||
l := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
src, hash := stage(t, "to be deleted")
|
||||
dst, err := l.Archive(ctx, 5, src, ".pdf", hash, time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("Archive: %v", err)
|
||||
}
|
||||
if err := l.Delete(ctx, 5, dst); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(dst); !os.IsNotExist(err) {
|
||||
t.Fatalf("object still present after delete")
|
||||
}
|
||||
}
|
||||
|
||||
// AK 2 + Prüfung 2: signed URLs verify while valid and are refused afterwards.
|
||||
func TestSignedURLLifecycle(t *testing.T) {
|
||||
l := newTestStore(t)
|
||||
link, err := l.SignedURL(3, 99, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("SignedURL: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(link, "https://dms.example.test"+SignedPath+"?") {
|
||||
t.Fatalf("unexpected link: %s", link)
|
||||
}
|
||||
u, err := url.Parse(link)
|
||||
if err != nil {
|
||||
t.Fatalf("parse link: %v", err)
|
||||
}
|
||||
ref, err := l.VerifySignedURL(u.Query(), time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
if ref.TenantID != 3 || ref.DocumentID != 99 {
|
||||
t.Fatalf("payload mismatch: %+v", ref)
|
||||
}
|
||||
|
||||
// Expired.
|
||||
if _, err := l.VerifySignedURL(u.Query(), time.Now().Add(2*time.Minute)); !errors.Is(err, ErrSignatureExpired) {
|
||||
t.Fatalf("expired: got %v want ErrSignatureExpired", err)
|
||||
}
|
||||
|
||||
// Tampered document id.
|
||||
q := u.Query()
|
||||
q.Set("d", "100")
|
||||
if _, err := l.VerifySignedURL(q, time.Now()); !errors.Is(err, ErrSignatureInvalid) {
|
||||
t.Fatalf("tampered: got %v want ErrSignatureInvalid", err)
|
||||
}
|
||||
|
||||
// Foreign key material.
|
||||
other, err := NewLocalStore(config.StorageConfig{BasePath: t.TempDir()}, "different-secret", "")
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocalStore: %v", err)
|
||||
}
|
||||
if _, err := other.VerifySignedURL(u.Query(), time.Now()); !errors.Is(err, ErrSignatureInvalid) {
|
||||
t.Fatalf("foreign key: got %v want ErrSignatureInvalid", err)
|
||||
}
|
||||
|
||||
// Missing parameters.
|
||||
if _, err := l.VerifySignedURL(url.Values{}, time.Now()); !errors.Is(err, ErrSignatureInvalid) {
|
||||
t.Fatalf("empty query: got %v want ErrSignatureInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
// AK 2: ttl <= 0 falls back to the configured default validity.
|
||||
func TestSignedURLDefaultTTL(t *testing.T) {
|
||||
l, err := NewLocalStore(config.StorageConfig{BasePath: t.TempDir(), SignedURLTTLMinutes: 5}, "s", "")
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocalStore: %v", err)
|
||||
}
|
||||
link, err := l.SignedURL(1, 1, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("SignedURL: %v", err)
|
||||
}
|
||||
u, _ := url.Parse(link)
|
||||
if strings.HasPrefix(link, "http") {
|
||||
t.Fatalf("empty baseURL must yield a relative link: %s", link)
|
||||
}
|
||||
if _, err := l.VerifySignedURL(u.Query(), time.Now().Add(4*time.Minute)); err != nil {
|
||||
t.Fatalf("within default ttl: %v", err)
|
||||
}
|
||||
if _, err := l.VerifySignedURL(u.Query(), time.Now().Add(6*time.Minute)); !errors.Is(err, ErrSignatureExpired) {
|
||||
t.Fatalf("past default ttl: got %v want ErrSignatureExpired", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user