Files
archivdms/internal/api/public_share_handlers.go
T
patrick 89de794356
CI / Backend (go vet, go test -cover) (push) Has been cancelled
CI / Frontend (ESLint, tsc, next build) (push) Has been cancelled
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
2026-08-11 22:27:52 +02:00

281 lines
8.8 KiB
Go

// Public (unauthenticated) share-link handlers. These are wired into the mux
// WITHOUT the s.auth middleware (see server.go): the share token itself is the
// only credential. Every lookup goes through the SHA-256 token_hash, never an
// id; every attempt is rate-limited per client IP and recorded in
// document_share_accesses (and, for downloads, the audit log). The archived
// file is streamed straight from the WORM store — storage_path/content_hash are
// never exposed to the client.
//
// GET /public/share/{token} metadata (title, whether a password is needed)
// POST /public/share/{token}/download body optional {password}; streams the file
package api
import (
"encoding/json"
"errors"
"io"
"net/http"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"archivdms/internal/audit"
"archivdms/internal/storage"
)
// publicShareMeta is the safe, minimal public view of a share.
type publicShareMeta struct {
Title string `json:"title"`
RequiresPassword bool `json:"requires_password"`
ExpiresAt time.Time `json:"expires_at"`
}
// publicDownloadRequest is the optional JSON body for the download endpoint.
type publicDownloadRequest struct {
Password string `json:"password"`
}
// handlePublicShareMeta handles GET /public/share/{token}. It reveals only the
// document title, whether a password is required, and the expiry — never the
// file. Revoked/expired/max-reached shares are reported as such but never leak
// the title.
func (s *Server) handlePublicShareMeta(w http.ResponseWriter, r *http.Request) {
ip := s.remoteIP(r)
if !s.shareLimiter.allow(ip) {
writeError(w, http.StatusTooManyRequests, "too many requests")
return
}
token := r.PathValue("token")
rs, err := s.store.ResolveShareByToken(r.Context(), token)
if err != nil {
// Unknown token: indistinguishable 404, nothing to log (no share_id).
writeError(w, http.StatusNotFound, "share not found")
return
}
if _, stateErr := rs.VerifyState(time.Now()); stateErr != nil {
writeError(w, shareStateStatus(stateErr), shareStateMessage(stateErr))
return
}
writeJSON(w, http.StatusOK, publicShareMeta{
Title: rs.DocumentTitle,
RequiresPassword: rs.HasPassword(),
ExpiresAt: rs.ExpiresAt(),
})
}
// handlePublicShareDownload handles POST /public/share/{token}/download. Check
// order: rate-limit -> resolve -> revoked -> expired -> max_accesses ->
// password -> deliver (atomic access_count++). Every branch records an access
// row and the terminal outcome is audit-logged (EventShareAccessed).
func (s *Server) handlePublicShareDownload(w http.ResponseWriter, r *http.Request) {
ip := s.remoteIP(r)
token := r.PathValue("token")
rs, err := s.store.ResolveShareByToken(r.Context(), token)
if err != nil {
// Unknown token: 404, no share to attach an access row to.
writeError(w, http.StatusNotFound, "share not found")
return
}
// Rate limit now that we have a share_id to log a 'rate_limited' attempt.
if !s.shareLimiter.allow(ip) {
s.recordShareAccess(r, rs, ip, storage.ShareResultRateLimited)
writeError(w, http.StatusTooManyRequests, "too many requests")
return
}
if result, stateErr := rs.VerifyState(time.Now()); stateErr != nil {
s.recordShareAccess(r, rs, ip, result)
writeError(w, shareStateStatus(stateErr), shareStateMessage(stateErr))
return
}
var body publicDownloadRequest
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&body) // body is optional
}
if err := rs.VerifyPassword(body.Password); err != nil {
s.recordShareAccess(r, rs, ip, storage.ShareResultBadPassword)
writeError(w, http.StatusUnauthorized, "password required or incorrect")
return
}
// Atomically claim one access slot (closes the max_accesses race).
ok, err := s.store.IncrementShareAccess(r.Context(), rs.ShareID())
if err != nil {
s.reqLog(r.Context()).Error("share access increment failed", "share_id", rs.ShareID(), "err", err)
writeError(w, http.StatusInternalServerError, "download failed")
return
}
if !ok {
// Lost the race (revoked/expired/max between our check and the update).
s.recordShareAccess(r, rs, ip, storage.ShareResultMaxReached)
writeError(w, http.StatusForbidden, "share no longer available")
return
}
f, err := s.objects.Open(r.Context(), rs.TenantID(), rs.StoragePath())
if err != nil {
s.reqLog(r.Context()).Error("share file open failed", "share_id", rs.ShareID(), "err", err)
writeError(w, http.StatusInternalServerError, "download failed")
return
}
defer f.Close()
s.recordShareAccess(r, rs, ip, storage.ShareResultSuccess)
ext := filepath.Ext(rs.StoragePath())
w.Header().Set("Content-Type", detectMimeType("", ext, rs.StoragePath()))
w.Header().Set("Content-Disposition", "attachment; filename=\""+safeDownloadName(rs.DocumentTitle, ext)+"\"")
w.Header().Set("X-Content-Type-Options", "nosniff")
if _, err := io.Copy(w, f); err != nil {
s.reqLog(r.Context()).Warn("share file stream interrupted", "share_id", rs.ShareID(), "err", err)
}
}
// recordShareAccess writes the per-attempt access row and mirrors the outcome
// into the audit log (EventShareAccessed). Never blocks the response path.
func (s *Server) recordShareAccess(r *http.Request, rs *storage.ResolvedShare, ip, result string) {
if err := s.store.LogShareAccess(r.Context(), rs.ShareID(), ip, r.UserAgent(), result); err != nil {
s.reqLog(r.Context()).Error("share access log failed", "share_id", rs.ShareID(), "err", err)
}
tenantID := rs.TenantID()
s.audlog.Log(audit.Entry{
EventType: audit.EventShareAccessed,
Username: "public",
IPAddress: ip,
TenantID: &tenantID,
DocumentID: strconv.FormatInt(rs.DocumentID(), 10),
Success: result == storage.ShareResultSuccess,
Detail: "share:" + strconv.FormatInt(rs.ShareID(), 10) + " result:" + result,
})
}
// shareStateStatus maps a share-state error to an HTTP status.
func shareStateStatus(err error) int {
switch {
case errors.Is(err, storage.ErrShareRevoked):
return http.StatusForbidden
case errors.Is(err, storage.ErrShareExpired):
return http.StatusGone
case errors.Is(err, storage.ErrShareMaxReached):
return http.StatusForbidden
default:
return http.StatusForbidden
}
}
func shareStateMessage(err error) string {
switch {
case errors.Is(err, storage.ErrShareRevoked):
return "share revoked"
case errors.Is(err, storage.ErrShareExpired):
return "share expired"
case errors.Is(err, storage.ErrShareMaxReached):
return "share access limit reached"
default:
return "share not available"
}
}
// safeDownloadName builds a Content-Disposition filename from the document
// title, stripping anything that could break the header or the client's
// filesystem, and appending the stored extension.
func safeDownloadName(title, ext string) string {
title = strings.TrimSpace(title)
if title == "" {
title = "document"
}
var b strings.Builder
for _, ch := range title {
switch {
case ch >= 'a' && ch <= 'z', ch >= 'A' && ch <= 'Z', ch >= '0' && ch <= '9':
b.WriteRune(ch)
case ch == '-', ch == '_', ch == '.', ch == ' ':
b.WriteRune(ch)
default:
b.WriteRune('_')
}
}
name := strings.TrimSpace(b.String())
if name == "" {
name = "document"
}
if ext != "" && !strings.HasSuffix(strings.ToLower(name), strings.ToLower(ext)) {
name += ext
}
return name
}
// --- per-IP token-bucket rate limiter ---
// ipRateLimiter is a minimal in-memory per-IP token-bucket limiter (no external
// dependency). Each IP gets its own bucket of `burst` tokens, refilled at
// `refillPerSec` tokens per second. Buckets are created lazily and swept when
// they have been idle and full for a while.
type ipRateLimiter struct {
mu sync.Mutex
buckets map[string]*tokenBucket
burst float64
refillPerSec float64
lastSweep time.Time
}
type tokenBucket struct {
tokens float64
last time.Time
}
func newIPRateLimiter(burst, refillPerSec float64) *ipRateLimiter {
return &ipRateLimiter{
buckets: make(map[string]*tokenBucket),
burst: burst,
refillPerSec: refillPerSec,
lastSweep: time.Now(),
}
}
// allow consumes one token for ip, returning false when the bucket is empty.
func (l *ipRateLimiter) allow(ip string) bool {
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
l.sweepLocked(now)
b, ok := l.buckets[ip]
if !ok {
b = &tokenBucket{tokens: l.burst, last: now}
l.buckets[ip] = b
}
// Refill based on elapsed time.
elapsed := now.Sub(b.last).Seconds()
b.tokens += elapsed * l.refillPerSec
if b.tokens > l.burst {
b.tokens = l.burst
}
b.last = now
if b.tokens < 1 {
return false
}
b.tokens--
return true
}
// sweepLocked drops idle, full buckets roughly once a minute to bound memory.
func (l *ipRateLimiter) sweepLocked(now time.Time) {
if now.Sub(l.lastSweep) < time.Minute {
return
}
l.lastSweep = now
for ip, b := range l.buckets {
if now.Sub(b.last) > 10*time.Minute {
delete(l.buckets, ip)
}
}
}