// 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) } } }