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.
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user