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
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
323 lines
11 KiB
Go
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()
|
|
}
|