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: /store/. 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///, // 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() }