Files
archivdms/internal/api/share_handlers.go
T
patrick 9a24ea29e1 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.
2026-08-11 21:27:53 +02:00

160 lines
5.7 KiB
Go

// Authenticated share-link handlers (see internal/storage/shares.go and the
// public counterpart in public_share_handlers.go):
//
// POST /api/documents/{id}/shares create a share (expires_at required)
// GET /api/documents/{id}/shares list shares for a document
// DELETE /api/shares/{share_id} revoke a share (soft, never hard-delete)
// GET /api/shares all shares of the tenant (domain_admin+)
//
// Ownership is enforced in the store layer (document/share id + tenant_id), the
// same IDOR guard used by the other document endpoints. Every create/revoke is
// audit-logged (EventShareCreated/EventShareRevoked), including failures. The
// raw token is returned exactly once, in the create response.
package api
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
"archivdms/internal/audit"
"archivdms/internal/storage"
)
// createShareRequest is the POST body for creating a share. ExpiresAt is
// mandatory (no unbounded shares); MaxAccesses and Password are optional.
type createShareRequest struct {
ExpiresAt time.Time `json:"expires_at"`
MaxAccesses *int `json:"max_accesses,omitempty"`
Password string `json:"password,omitempty"`
}
// createShareResponse embeds the stored share plus the one-time plaintext
// token (only ever returned here).
type createShareResponse struct {
storage.DocumentShare
Token string `json:"token"`
}
func (s *Server) logShare(r *http.Request, event string, tenantID *int64, username, detail string, ok bool) {
s.audlog.Log(audit.Entry{
EventType: event, Username: username, TenantID: tenantID,
IPAddress: s.remoteIP(r), Success: ok, Detail: detail,
})
}
// handleCreateShare handles POST /api/documents/{id}/shares.
func (s *Server) handleCreateShare(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
var req createShareRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.ExpiresAt.IsZero() {
writeError(w, http.StatusBadRequest, "expires_at is required")
return
}
if !req.ExpiresAt.After(time.Now()) {
writeError(w, http.StatusBadRequest, "expires_at must be in the future")
return
}
if req.MaxAccesses != nil && *req.MaxAccesses < 1 {
writeError(w, http.StatusBadRequest, "max_accesses must be at least 1")
return
}
share, token, err := s.store.CreateShare(r.Context(), storage.CreateShareRequest{
TenantID: *sess.TenantID,
DocumentID: docID,
CreatedBy: sess.UserID,
ExpiresAt: req.ExpiresAt,
MaxAccesses: req.MaxAccesses,
Password: req.Password,
})
if err != nil {
s.logShare(r, audit.EventShareCreated, sess.TenantID, sess.Username, "share_create doc:"+strconv.FormatInt(docID, 10)+" err:"+err.Error(), false)
writeError(w, shareStatus(err), "create share failed")
return
}
s.logShare(r, audit.EventShareCreated, sess.TenantID, sess.Username, "share_create doc:"+strconv.FormatInt(docID, 10)+" share:"+strconv.FormatInt(share.ID, 10), true)
writeJSON(w, http.StatusCreated, createShareResponse{DocumentShare: *share, Token: token})
}
// handleListDocumentShares handles GET /api/documents/{id}/shares.
func (s *Server) handleListDocumentShares(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
shares, err := s.store.ListSharesForDocument(r.Context(), docID, *sess.TenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "list shares failed")
return
}
writeJSON(w, http.StatusOK, shares)
}
// handleRevokeShare handles DELETE /api/shares/{share_id}.
func (s *Server) handleRevokeShare(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
shareID, err := strconv.ParseInt(r.PathValue("share_id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid share id")
return
}
if err := s.store.RevokeShare(r.Context(), shareID, *sess.TenantID, sess.UserID); err != nil {
s.logShare(r, audit.EventShareRevoked, sess.TenantID, sess.Username, "share_revoke share:"+strconv.FormatInt(shareID, 10)+" err:"+err.Error(), false)
writeError(w, shareStatus(err), "revoke share failed")
return
}
s.logShare(r, audit.EventShareRevoked, sess.TenantID, sess.Username, "share_revoke share:"+strconv.FormatInt(shareID, 10), true)
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
}
// handleListTenantShares handles GET /api/shares (domain_admin+): every share
// of the caller's tenant, document title joined in.
func (s *Server) handleListTenantShares(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
shares, err := s.store.ListSharesForTenant(r.Context(), *sess.TenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "list shares failed")
return
}
writeJSON(w, http.StatusOK, shares)
}
// shareStatus maps store errors to an HTTP status for the authenticated
// endpoints.
func shareStatus(err error) int {
if errors.Is(err, storage.ErrShareNotFound) {
return http.StatusNotFound
}
return http.StatusInternalServerError
}