- 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
248 lines
8.3 KiB
Go
248 lines
8.3 KiB
Go
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()
|
|
}
|