Files
nexarch/dms/internal/sharelinkapi/handler.go
patrickandClaude Sonnet 5 47d7b04b85
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
DOC-22: gofmt
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:14:35 +02:00

323 lines
11 KiB
Go

// Package sharelinkapi haengt DOC-22 (externe Freigabelinks) ein: die
// verwaltenden Endpunkte unter /documents/{id}/share-links laufen wie alle
// dokument-eigenen Routen durch authz.Guard (nur Eigentuemer, Identitaet
// zwingend); der Abrufpfad /share/{token} ist bewusst NICHT unter
// /documents registriert und in der OpenAPI-Beschreibung als "security": []
// markiert -- er ist die kontrollierte Ausnahme von "nur mit Konto".
package sharelinkapi
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/audittrail"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/authz"
dmscrypto "gitea.perlbach24.de/scripte/nexarch/dms/internal/crypto"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/sharelink"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/storage"
)
// ShareLinkActor steht im Audit-Log fuer Zugriffe ueber den oeffentlichen
// Pfad -- die Anfrage traegt keine Benutzeridentitaet (analog zu
// downloadapi.SignedURLActor).
const ShareLinkActor = "share-link"
const visibleDocument = `d.trashed_at IS NULL AND d.deleted_at IS NULL
AND NOT EXISTS (SELECT 1 FROM revision_scans rs WHERE rs.revision_id = fr.id AND rs.status <> 'clean')`
type Server struct {
pool *pgxpool.Pool
storageSvc *storage.Service
cryptoSvc *dmscrypto.Service
tenantSlug string
enabled bool
defaultTTL time.Duration
maxTTL time.Duration
maxAttempts int
lockDuration time.Duration
pathPrefix string
}
func New(pool *pgxpool.Pool, storageSvc *storage.Service, cryptoSvc *dmscrypto.Service, tenantSlug string, enabled bool, defaultTTL, maxTTL time.Duration, maxAttempts int, lockDuration time.Duration) *Server {
if defaultTTL <= 0 || defaultTTL > maxTTL {
defaultTTL = maxTTL
}
if maxAttempts <= 0 {
maxAttempts = 5
}
if lockDuration <= 0 {
lockDuration = 15 * time.Minute
}
return &Server{pool: pool, storageSvc: storageSvc, cryptoSvc: cryptoSvc, tenantSlug: tenantSlug, enabled: enabled,
defaultTTL: defaultTTL, maxTTL: maxTTL, maxAttempts: maxAttempts, lockDuration: lockDuration}
}
// SetPathPrefix stellt den Routenpraefix (FDN-14) den ausgestellten
// Freigabelink-URLs voran.
func (s *Server) SetPathPrefix(prefix string) { s.pathPrefix = prefix }
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /documents/{id}/share-links", s.handleCreate)
mux.HandleFunc("GET /documents/{id}/share-links", s.handleList)
mux.HandleFunc("DELETE /documents/{id}/share-links/{linkID}", s.handleRevoke)
mux.HandleFunc("GET /share/{token}", s.handleAccess)
mux.HandleFunc("POST /share/{token}", s.handleAccess)
}
func writeError(w http.ResponseWriter, status int, code, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]string{"code": code, "message": message}})
}
func caller(r *http.Request) string {
c, _ := authz.Identity(r)
return c
}
type createRequest struct {
TTLSeconds *int `json:"ttl_seconds"`
Password string `json:"password"`
DownloadLimit *int `json:"download_limit"`
}
type linkDTO struct {
ID string `json:"id"`
URL string `json:"url,omitempty"`
ExpiresAt string `json:"expires_at"`
DownloadLimit *int `json:"download_limit,omitempty"`
DownloadCount int `json:"download_count"`
HasPassword bool `json:"has_password"`
RevokedAt *string `json:"revoked_at,omitempty"`
}
func toDTO(l sharelink.Link) linkDTO {
d := linkDTO{ID: l.ID, ExpiresAt: l.ExpiresAt.UTC().Format(time.RFC3339), DownloadLimit: l.DownloadLimit,
DownloadCount: l.DownloadCount, HasPassword: l.HasPassword}
if l.RevokedAt != nil {
s := l.RevokedAt.UTC().Format(time.RFC3339)
d.RevokedAt = &s
}
return d
}
func (s *Server) handleCreate(w http.ResponseWriter, r *http.Request) {
if !s.enabled {
writeError(w, http.StatusServiceUnavailable, "unavailable", "freigabelinks sind fuer diesen mandanten deaktiviert")
return
}
documentID := r.PathValue("id")
c := caller(r)
if c == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "fehlende identitaet")
return
}
var req createRequest
if r.ContentLength != 0 {
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "ungueltiger anfragekoerper")
return
}
}
ttl := s.defaultTTL
if req.TTLSeconds != nil {
ttl = time.Duration(*req.TTLSeconds) * time.Second
}
if req.DownloadLimit != nil && *req.DownloadLimit <= 0 {
writeError(w, http.StatusBadRequest, "invalid_request", "download_limit muss positiv sein")
return
}
token, link, err := sharelink.Create(r.Context(), s.pool, documentID, c, ttl, s.maxTTL, req.Password, req.DownloadLimit)
if errors.Is(err, sharelink.ErrTTLTooLong) {
writeError(w, http.StatusBadRequest, "invalid_request", "gueltigkeitsdauer ueberschreitet die erlaubte obergrenze")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
}
if err := audittrail.Record(r.Context(), s.pool, c, "share_link_created", "document", documentID, map[string]any{
"share_link_id": link.ID, "expires_at": link.ExpiresAt.UTC().Format(time.RFC3339), "has_password": link.HasPassword,
}); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
}
dto := toDTO(link)
dto.URL = fmt.Sprintf("%s/share/%s", s.pathPrefix, url.PathEscape(token))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(dto)
}
func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
documentID := r.PathValue("id")
links, err := sharelink.List(r.Context(), s.pool, documentID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
}
out := make([]linkDTO, 0, len(links))
for _, l := range links {
out = append(out, toDTO(l))
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(out)
}
func validUUID(s string) bool {
if len(s) != 36 {
return false
}
for i, r := range s {
switch i {
case 8, 13, 18, 23:
if r != '-' {
return false
}
default:
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
return false
}
}
}
return true
}
func (s *Server) handleRevoke(w http.ResponseWriter, r *http.Request) {
documentID, linkID := r.PathValue("id"), r.PathValue("linkID")
if !validUUID(linkID) {
writeError(w, http.StatusNotFound, "not_found", "freigabelink nicht gefunden")
return
}
c := caller(r)
err := sharelink.Revoke(r.Context(), s.pool, documentID, linkID)
if errors.Is(err, sharelink.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "freigabelink nicht gefunden")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
}
if err := audittrail.Record(r.Context(), s.pool, c, "share_link_revoked", "document", documentID, map[string]any{"share_link_id": linkID}); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
func passwordFrom(r *http.Request) string {
if r.Method != http.MethodPost {
return ""
}
var body struct {
Password string `json:"password"`
}
if r.ContentLength == 0 {
return ""
}
_ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body)
return body.Password
}
// handleAccess ist der unauthentifizierte Abrufpfad (Akzeptanzkriterium 2).
// Jeder nicht nutzbare Ausgang (unbekannt, abgelaufen, widerrufen,
// Limit erreicht) liefert denselben generischen 404 -- keine
// Unterscheidung, die Rueckschluesse auf einen erratenen Token zuliesse.
func (s *Server) handleAccess(w http.ResponseWriter, r *http.Request) {
token := r.PathValue("token")
password := passwordFrom(r)
documentID, linkID, err := sharelink.Resolve(r.Context(), s.pool, token, password, s.maxAttempts, s.lockDuration)
switch {
case errors.Is(err, sharelink.ErrNotFound):
writeError(w, http.StatusNotFound, "not_found", "freigabelink nicht gefunden")
return
case errors.Is(err, sharelink.ErrLocked):
writeError(w, http.StatusLocked, "locked", "zu viele fehlversuche, bitte spaeter erneut versuchen")
return
case errors.Is(err, sharelink.ErrNeedPassword):
writeError(w, http.StatusUnauthorized, "password_required", "passwort erforderlich")
return
case errors.Is(err, sharelink.ErrWrongPassword):
_ = audittrail.Record(r.Context(), s.pool, ShareLinkActor, "share_link_access_denied", "document", "", map[string]any{"reason": "wrong_password"})
writeError(w, http.StatusUnauthorized, "invalid_password", "falsches passwort")
return
case err != nil:
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
}
var revisionID, mimeType, storageKey, title string
var wrappedDEK []byte
err = s.pool.QueryRow(r.Context(), `
SELECT fr.id, fr.mime_type, fr.storage_key, fr.wrapped_dek, d.title
FROM documents d JOIN file_revisions fr ON fr.id = d.current_revision_id
WHERE d.id = $1 AND `+visibleDocument+`
`, documentID).Scan(&revisionID, &mimeType, &storageKey, &wrappedDEK, &title)
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "not_found", "freigabelink nicht gefunden")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
}
if err := sharelink.CountDownload(r.Context(), s.pool, linkID); err != nil {
writeError(w, http.StatusNotFound, "not_found", "freigabelink nicht gefunden")
return
}
// Vor der Auslieferung protokollieren: ohne Eintrag kein Zugriff.
if err := audittrail.Record(r.Context(), s.pool, ShareLinkActor, "share_link_accessed", "document", documentID, map[string]any{"share_link_id": linkID, "revision_id": revisionID}); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
}
cipherReader, err := s.storageSvc.Get(r.Context(), storageKey)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "original nicht ladbar: "+err.Error())
return
}
defer func() { _ = cipherReader.Close() }()
plainReader, err := s.cryptoSvc.Open(r.Context(), s.tenantSlug, wrappedDEK, cipherReader)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "original nicht entschluesselbar: "+err.Error())
return
}
w.Header().Set("Content-Type", mimeType)
w.Header().Set("X-Content-Type-Options", "nosniff")
if title == "" {
title = revisionID
}
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"; filename*=UTF-8''%s`,
toASCIIFallback(title), url.PathEscape(title)))
_, _ = io.Copy(w, plainReader)
}
func toASCIIFallback(s string) string {
var b strings.Builder
for _, r := range s {
if r >= 32 && r < 127 && r != '"' && r != '\\' {
b.WriteRune(r)
} else {
b.WriteByte('_')
}
}
return b.String()
}