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,281 @@
|
||||
// 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"
|
||||
"os"
|
||||
"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.logger.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 := os.Open(rs.StoragePath())
|
||||
if err != nil {
|
||||
s.logger.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.logger.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.logger.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user