FDN-01: repository & projektgerüst
Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
This commit is contained in:
@@ -0,0 +1,389 @@
|
||||
// External document share-links (see migrations/009_shares.sql). A share is a
|
||||
// tenant-scoped, expiring, optionally password-protected public link to a
|
||||
// single document. The raw token is generated once (32 bytes crypto/rand,
|
||||
// base64url) and returned to the caller exactly once at creation time; only its
|
||||
// SHA-256 hash is ever persisted (token_hash). The public download endpoint
|
||||
// always looks a share up by token_hash, never by id.
|
||||
//
|
||||
// Shares are never hard-deleted: revoking only sets revoked_at/revoked_by, and
|
||||
// every access attempt (success or failure) is recorded in
|
||||
// document_share_accesses for GoBD traceability. The underlying file stays in
|
||||
// the WORM store and is streamed server-side; storage_path/content_hash are
|
||||
// never exposed to the public client.
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const shareBcryptCost = 12
|
||||
|
||||
// shareDummyBcryptHash burns bcrypt time when a share has no password but a
|
||||
// client nevertheless submits one, so password-protected and unprotected
|
||||
// shares are not trivially distinguishable by response timing.
|
||||
const shareDummyBcryptHash = "$2a$12$C6UzMDM.H6dfI/f/IKcEeO4TW/OZ/6PdTdSU0/eV1JCJXo.0DGvTa"
|
||||
|
||||
// Share lifecycle / validation errors.
|
||||
var (
|
||||
// ErrShareNotFound is returned when a share lookup (by id+tenant or by
|
||||
// token_hash) matches no row.
|
||||
ErrShareNotFound = errors.New("storage: share not found")
|
||||
// ErrShareRevoked is returned when a share has been revoked.
|
||||
ErrShareRevoked = errors.New("storage: share revoked")
|
||||
// ErrShareExpired is returned when a share is past its expires_at.
|
||||
ErrShareExpired = errors.New("storage: share expired")
|
||||
// ErrShareMaxReached is returned when a share hit its max_accesses cap.
|
||||
ErrShareMaxReached = errors.New("storage: share max accesses reached")
|
||||
// ErrShareBadPassword is returned when a required share password is
|
||||
// missing or wrong.
|
||||
ErrShareBadPassword = errors.New("storage: share password incorrect")
|
||||
)
|
||||
|
||||
// Share access-result constants (mirror the CHECK constraint on
|
||||
// document_share_accesses.result).
|
||||
const (
|
||||
ShareResultSuccess = "success"
|
||||
ShareResultExpired = "expired"
|
||||
ShareResultRevoked = "revoked"
|
||||
ShareResultMaxReached = "max_reached"
|
||||
ShareResultBadPassword = "bad_password"
|
||||
ShareResultRateLimited = "rate_limited"
|
||||
)
|
||||
|
||||
// DocumentShare is the public-safe view of a share row. token_hash and
|
||||
// password_hash are deliberately NOT part of this struct so they can never be
|
||||
// serialised into an API response.
|
||||
type DocumentShare struct {
|
||||
ID int64 `json:"id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
DocumentID int64 `json:"document_id"`
|
||||
CreatedBy int64 `json:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
MaxAccesses *int `json:"max_accesses,omitempty"`
|
||||
AccessCount int `json:"access_count"`
|
||||
HasPassword bool `json:"has_password"`
|
||||
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
||||
RevokedBy *int64 `json:"revoked_by,omitempty"`
|
||||
// DocumentTitle is joined in for the listing endpoints; empty when not
|
||||
// selected.
|
||||
DocumentTitle string `json:"document_title,omitempty"`
|
||||
}
|
||||
|
||||
// CreateShareRequest holds the parameters for creating a share.
|
||||
type CreateShareRequest struct {
|
||||
TenantID int64
|
||||
DocumentID int64
|
||||
CreatedBy int64
|
||||
ExpiresAt time.Time
|
||||
MaxAccesses *int // nil = unlimited (until expiry)
|
||||
Password string // "" = no password protection
|
||||
}
|
||||
|
||||
// ResolvedShare carries the internal fields the public download flow needs but
|
||||
// which must never reach the client: password_hash plus the document's WORM
|
||||
// storage location. Its sensitive fields are unexported and reached only via
|
||||
// accessor methods / the Verify* helpers, so a handler cannot accidentally
|
||||
// serialise password_hash or storage_path into a response.
|
||||
type ResolvedShare struct {
|
||||
share DocumentShare
|
||||
passwordHash string
|
||||
storagePath string
|
||||
contentHash string
|
||||
DocumentTitle string
|
||||
}
|
||||
|
||||
// ShareID returns the share's id.
|
||||
func (rs *ResolvedShare) ShareID() int64 { return rs.share.ID }
|
||||
|
||||
// TenantID returns the owning tenant id.
|
||||
func (rs *ResolvedShare) TenantID() int64 { return rs.share.TenantID }
|
||||
|
||||
// DocumentID returns the shared document's id.
|
||||
func (rs *ResolvedShare) DocumentID() int64 { return rs.share.DocumentID }
|
||||
|
||||
// ExpiresAt returns the share's expiry.
|
||||
func (rs *ResolvedShare) ExpiresAt() time.Time { return rs.share.ExpiresAt }
|
||||
|
||||
// HasPassword reports whether the share is password-protected.
|
||||
func (rs *ResolvedShare) HasPassword() bool { return rs.passwordHash != "" }
|
||||
|
||||
// StoragePath returns the WORM path of the underlying file (server-side only).
|
||||
func (rs *ResolvedShare) StoragePath() string { return rs.storagePath }
|
||||
|
||||
func (s *Store) initSharesSchema(ctx context.Context) error {
|
||||
_, err := s.db.Exec(ctx, `
|
||||
-- No FK on tenant_id / created_by / revoked_by: consistent with the rest
|
||||
-- of the schema (plain BIGINT), because tenants/users are owned by other
|
||||
-- stores that initialise after storage.New() (see cmd/archivdms/main.go).
|
||||
-- document_id keeps its FK: documents is this store's own table.
|
||||
CREATE TABLE IF NOT EXISTS document_shares (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
created_by BIGINT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
max_accesses INT,
|
||||
access_count INT NOT NULL DEFAULT 0,
|
||||
password_hash TEXT,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
revoked_by BIGINT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_document_shares_document ON document_shares(document_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_document_shares_tenant ON document_shares(tenant_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS document_share_accesses (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
share_id BIGINT NOT NULL REFERENCES document_shares(id) ON DELETE CASCADE,
|
||||
accessed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
ip_address INET,
|
||||
user_agent TEXT,
|
||||
result TEXT NOT NULL CHECK (result IN ('success','expired','revoked','max_reached','bad_password','rate_limited'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_share_accesses_share ON document_share_accesses(share_id, accessed_at DESC);
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: create shares tables: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hashShareToken returns the hex-encoded SHA-256 of a raw share token, the
|
||||
// value persisted in / looked up from document_shares.token_hash.
|
||||
func hashShareToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// CreateShare inserts a new share for a document and returns the stored share
|
||||
// plus the raw (plaintext) token. The token is returned ONLY here and never
|
||||
// again — only its SHA-256 hash is persisted. The document must belong to the
|
||||
// tenant, otherwise ErrShareNotFound is returned.
|
||||
func (s *Store) CreateShare(ctx context.Context, req CreateShareRequest) (*DocumentShare, string, error) {
|
||||
// Ownership check: the document must belong to the tenant (IDOR guard).
|
||||
var owned bool
|
||||
if err := s.db.QueryRow(ctx, `
|
||||
SELECT EXISTS(SELECT 1 FROM documents WHERE id = $1 AND tenant_id = $2)
|
||||
`, req.DocumentID, req.TenantID).Scan(&owned); err != nil {
|
||||
return nil, "", fmt.Errorf("storage: check share document: %w", err)
|
||||
}
|
||||
if !owned {
|
||||
return nil, "", ErrShareNotFound
|
||||
}
|
||||
|
||||
rawBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(rawBytes); err != nil {
|
||||
return nil, "", fmt.Errorf("storage: generate share token: %w", err)
|
||||
}
|
||||
token := base64.RawURLEncoding.EncodeToString(rawBytes)
|
||||
tokenHash := hashShareToken(token)
|
||||
|
||||
var passwordHash any
|
||||
if req.Password != "" {
|
||||
h, err := bcrypt.GenerateFromPassword([]byte(req.Password), shareBcryptCost)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("storage: share bcrypt: %w", err)
|
||||
}
|
||||
passwordHash = string(h)
|
||||
}
|
||||
|
||||
var d DocumentShare
|
||||
var pw *string
|
||||
err := s.db.QueryRow(ctx, `
|
||||
INSERT INTO document_shares (tenant_id, document_id, token_hash, created_by, expires_at, max_accesses, password_hash)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, tenant_id, document_id, created_by, created_at, expires_at, max_accesses, access_count, password_hash, revoked_at, revoked_by
|
||||
`, req.TenantID, req.DocumentID, tokenHash, req.CreatedBy, req.ExpiresAt, req.MaxAccesses, passwordHash,
|
||||
).Scan(&d.ID, &d.TenantID, &d.DocumentID, &d.CreatedBy, &d.CreatedAt, &d.ExpiresAt, &d.MaxAccesses, &d.AccessCount, &pw, &d.RevokedAt, &d.RevokedBy)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("storage: create share: %w", err)
|
||||
}
|
||||
d.HasPassword = pw != nil
|
||||
return &d, token, nil
|
||||
}
|
||||
|
||||
// ListSharesForDocument returns all shares (including revoked ones — no hard
|
||||
// delete) for a document, scoped to tenant ownership, newest first.
|
||||
func (s *Store) ListSharesForDocument(ctx context.Context, documentID, tenantID int64) ([]DocumentShare, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, tenant_id, document_id, created_by, created_at, expires_at, max_accesses, access_count,
|
||||
(password_hash IS NOT NULL), revoked_at, revoked_by
|
||||
FROM document_shares
|
||||
WHERE document_id = $1 AND tenant_id = $2
|
||||
ORDER BY created_at DESC
|
||||
`, documentID, tenantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: list document shares: %w", err)
|
||||
}
|
||||
return scanShares(rows)
|
||||
}
|
||||
|
||||
// ListSharesForTenant returns all shares of a tenant with the document title
|
||||
// joined in, newest first (domain_admin/superadmin overview).
|
||||
func (s *Store) ListSharesForTenant(ctx context.Context, tenantID int64) ([]DocumentShare, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT sh.id, sh.tenant_id, sh.document_id, sh.created_by, sh.created_at, sh.expires_at,
|
||||
sh.max_accesses, sh.access_count, (sh.password_hash IS NOT NULL), sh.revoked_at, sh.revoked_by,
|
||||
COALESCE(d.title, '')
|
||||
FROM document_shares sh
|
||||
LEFT JOIN documents d ON d.id = sh.document_id
|
||||
WHERE sh.tenant_id = $1
|
||||
ORDER BY sh.created_at DESC
|
||||
`, tenantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: list tenant shares: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]DocumentShare, 0)
|
||||
for rows.Next() {
|
||||
var d DocumentShare
|
||||
if err := rows.Scan(&d.ID, &d.TenantID, &d.DocumentID, &d.CreatedBy, &d.CreatedAt, &d.ExpiresAt,
|
||||
&d.MaxAccesses, &d.AccessCount, &d.HasPassword, &d.RevokedAt, &d.RevokedBy, &d.DocumentTitle); err != nil {
|
||||
return nil, fmt.Errorf("storage: scan tenant share: %w", err)
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func scanShares(rows pgx.Rows) ([]DocumentShare, error) {
|
||||
defer rows.Close()
|
||||
out := make([]DocumentShare, 0)
|
||||
for rows.Next() {
|
||||
var d DocumentShare
|
||||
if err := rows.Scan(&d.ID, &d.TenantID, &d.DocumentID, &d.CreatedBy, &d.CreatedAt, &d.ExpiresAt,
|
||||
&d.MaxAccesses, &d.AccessCount, &d.HasPassword, &d.RevokedAt, &d.RevokedBy); err != nil {
|
||||
return nil, fmt.Errorf("storage: scan share: %w", err)
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// RevokeShare marks a share as revoked (never hard-deleted), scoped to tenant
|
||||
// ownership. Idempotent-ish: revoking an already-revoked share updates
|
||||
// revoked_at/revoked_by again but still succeeds. Returns ErrShareNotFound when
|
||||
// no share of that id belongs to the tenant.
|
||||
func (s *Store) RevokeShare(ctx context.Context, shareID, tenantID, revokedBy int64) error {
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
UPDATE document_shares SET revoked_at = now(), revoked_by = $3
|
||||
WHERE id = $1 AND tenant_id = $2
|
||||
`, shareID, tenantID, revokedBy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: revoke share: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrShareNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveShareByToken looks a share up by the SHA-256 hash of the raw token
|
||||
// (never by id) and joins the document's storage location. Returns
|
||||
// ErrShareNotFound when no share matches.
|
||||
func (s *Store) ResolveShareByToken(ctx context.Context, token string) (*ResolvedShare, error) {
|
||||
tokenHash := hashShareToken(token)
|
||||
var rs ResolvedShare
|
||||
var pw *string
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT sh.id, sh.tenant_id, sh.document_id, sh.created_by, sh.created_at, sh.expires_at,
|
||||
sh.max_accesses, sh.access_count, sh.password_hash, sh.revoked_at, sh.revoked_by,
|
||||
COALESCE(d.title, ''), COALESCE(d.storage_path, ''), COALESCE(d.content_hash, '')
|
||||
FROM document_shares sh
|
||||
JOIN documents d ON d.id = sh.document_id
|
||||
WHERE sh.token_hash = $1
|
||||
`, tokenHash).Scan(&rs.share.ID, &rs.share.TenantID, &rs.share.DocumentID, &rs.share.CreatedBy,
|
||||
&rs.share.CreatedAt, &rs.share.ExpiresAt, &rs.share.MaxAccesses, &rs.share.AccessCount, &pw,
|
||||
&rs.share.RevokedAt, &rs.share.RevokedBy, &rs.DocumentTitle, &rs.storagePath, &rs.contentHash)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrShareNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("storage: resolve share by token: %w", err)
|
||||
}
|
||||
if pw != nil {
|
||||
rs.passwordHash = *pw
|
||||
rs.share.HasPassword = true
|
||||
}
|
||||
return &rs, nil
|
||||
}
|
||||
|
||||
// VerifyState applies the fixed check order (revoked -> expired ->
|
||||
// max_accesses) and returns the matching result string (for the access log)
|
||||
// plus the sentinel error. now is passed in so callers share one timestamp.
|
||||
func (rs *ResolvedShare) VerifyState(now time.Time) (string, error) {
|
||||
if rs.share.RevokedAt != nil {
|
||||
return ShareResultRevoked, ErrShareRevoked
|
||||
}
|
||||
if !now.Before(rs.share.ExpiresAt) {
|
||||
return ShareResultExpired, ErrShareExpired
|
||||
}
|
||||
if rs.share.MaxAccesses != nil && rs.share.AccessCount >= *rs.share.MaxAccesses {
|
||||
return ShareResultMaxReached, ErrShareMaxReached
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// VerifyPassword checks a submitted password against the share's stored bcrypt
|
||||
// hash. Runs a bcrypt comparison (dummy hash when the share has no password) to
|
||||
// avoid leaking, via timing, whether a share is protected.
|
||||
func (rs *ResolvedShare) VerifyPassword(password string) error {
|
||||
if rs.passwordHash == "" {
|
||||
// Burn comparable time so protected/unprotected shares look alike.
|
||||
_ = bcrypt.CompareHashAndPassword([]byte(shareDummyBcryptHash), []byte(password))
|
||||
return nil
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(rs.passwordHash), []byte(password)); err != nil {
|
||||
return ErrShareBadPassword
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IncrementShareAccess atomically bumps access_count, but only while the share
|
||||
// is still within its cap — the WHERE guard closes the race where two parallel
|
||||
// downloads could both pass the in-memory max check. Returns true when the
|
||||
// counter was incremented (i.e. the download may proceed).
|
||||
func (s *Store) IncrementShareAccess(ctx context.Context, shareID int64) (bool, error) {
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
UPDATE document_shares
|
||||
SET access_count = access_count + 1
|
||||
WHERE id = $1
|
||||
AND revoked_at IS NULL
|
||||
AND expires_at > now()
|
||||
AND (max_accesses IS NULL OR access_count < max_accesses)
|
||||
`, shareID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("storage: increment share access: %w", err)
|
||||
}
|
||||
return tag.RowsAffected() == 1, nil
|
||||
}
|
||||
|
||||
// LogShareAccess appends an access-attempt record. ip may be empty (stored as
|
||||
// NULL). Errors are returned so the caller can decide, but a logging failure
|
||||
// should never block the response path — the public handler logs-and-continues.
|
||||
func (s *Store) LogShareAccess(ctx context.Context, shareID int64, ip, userAgent, result string) error {
|
||||
var ipArg any
|
||||
if ip != "" {
|
||||
ipArg = ip
|
||||
}
|
||||
_, err := s.db.Exec(ctx, `
|
||||
INSERT INTO document_share_accesses (share_id, ip_address, user_agent, result)
|
||||
VALUES ($1, $2::inet, $3, $4)
|
||||
`, shareID, ipArg, userAgent, result)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: log share access: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user