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