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