- 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
281 lines
8.8 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|