Files
ITWorx-Pulse-Public/internal/dashboardapi/handler.go
T
ITWorx Pulse release export bd774932d5
Public source validation / validate (push) Failing after 3m8s
Publish ITWorx Pulse source
2026-09-03 02:09:19 +02:00

408 lines
14 KiB
Go

package dashboardapi
import (
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"github.com/itworx/pulse/internal/audit"
"github.com/itworx/pulse/internal/auth"
"github.com/itworx/pulse/internal/correlation"
"github.com/itworx/pulse/internal/dashboard"
"github.com/itworx/pulse/internal/problem"
"github.com/itworx/pulse/internal/widgetpreview"
)
type Handler struct {
Repository dashboard.Repository
Audit audit.Store
}
func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
principal, ok := auth.PrincipalFromContext(r.Context())
if !ok {
fail(w, r, http.StatusUnauthorized, "UNAUTHORIZED", "Authentication required.")
return
}
path := strings.TrimPrefix(r.URL.Path, "/api/v1/dashboards")
if path == "" || path == "/" {
switch r.Method {
case http.MethodGet:
h.list(w, r, principal.Subject)
case http.MethodPost:
if !auth.Allows(principal.Role, auth.PermissionEdit) {
fail(w, r, http.StatusForbidden, "FORBIDDEN", "Dashboard editing is not allowed for this role.")
return
}
h.create(w, r, principal.Subject)
default:
fail(w, r, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "This method is not supported.")
}
return
}
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) == 0 || parts[0] == "" {
fail(w, r, http.StatusNotFound, "NOT_FOUND", "Dashboard not found.")
return
}
id := parts[0]
if len(parts) == 2 && parts[1] == "versions" && r.Method == http.MethodGet {
h.versions(w, r, id, principal.Subject)
return
}
if len(parts) == 3 && parts[1] == "versions" && r.Method == http.MethodGet {
h.version(w, r, id, parts[2], principal.Subject)
return
}
if len(parts) == 2 && parts[1] == "document" && r.Method == http.MethodPut {
if !requireEdit(w, r, principal.Role) {
return
}
h.update(w, r, id, principal.Subject)
return
}
if len(parts) == 2 && parts[1] == "preview" && r.Method == http.MethodPost {
if !requireEdit(w, r, principal.Role) {
return
}
h.preview(w, r, id, principal.Subject)
return
}
if len(parts) == 2 && parts[1] == "clone" && r.Method == http.MethodPost {
if !requireEdit(w, r, principal.Role) {
return
}
h.clone(w, r, id, principal.Subject)
return
}
if len(parts) == 2 && parts[1] == "restore" && r.Method == http.MethodPost {
if !requireEdit(w, r, principal.Role) {
return
}
h.restore(w, r, id, principal.Subject, "")
return
}
if len(parts) == 3 && parts[1] == "restore" && r.Method == http.MethodPost {
if !requireEdit(w, r, principal.Role) {
return
}
h.restore(w, r, id, principal.Subject, parts[2])
return
}
if len(parts) == 1 && r.Method == http.MethodGet {
h.get(w, r, id, principal.Subject)
return
}
if len(parts) == 1 && r.Method == http.MethodPatch {
if !requireEdit(w, r, principal.Role) {
return
}
h.metadata(w, r, id, principal.Subject)
return
}
if len(parts) == 1 && r.Method == http.MethodDelete {
if !requireEdit(w, r, principal.Role) {
return
}
h.archive(w, r, id, principal.Subject)
return
}
fail(w, r, http.StatusNotFound, "NOT_FOUND", "Dashboard route not found.")
}
func requireEdit(w http.ResponseWriter, r *http.Request, role auth.Role) bool {
if auth.Allows(role, auth.PermissionEdit) {
return true
}
fail(w, r, http.StatusForbidden, "FORBIDDEN", "Dashboard editing is not allowed for this role.")
return false
}
func (h Handler) create(w http.ResponseWriter, r *http.Request, actor string) {
var doc dashboard.Document
if err := decode(r, &doc); err != nil {
fail(w, r, http.StatusBadRequest, "INVALID_DOCUMENT", "The dashboard document is invalid.")
return
}
s, v, err := h.Repository.Create(r.Context(), actor, doc, "initial version")
if err != nil {
h.repositoryFailure(w, r, err, "Dashboard could not be created.")
return
}
if err := h.record(r, actor, "dashboard.create", s.ID, nil, map[string]any{"revision": s.Revision}); err != nil {
fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.")
return
}
write(w, http.StatusCreated, map[string]any{"dashboard": s, "version": v})
}
func (h Handler) list(w http.ResponseWriter, r *http.Request, actor string) {
limit := 100
if value := r.URL.Query().Get("limit"); value != "" {
parsed, err := strconv.Atoi(value)
if err != nil || parsed < 1 || parsed > 100 {
fail(w, r, http.StatusBadRequest, "INVALID_LIMIT", "The dashboard limit must be between 1 and 100.")
return
}
limit = parsed
}
items, err := h.Repository.List(r.Context(), actor, limit)
if err != nil {
h.repositoryFailure(w, r, err, "Dashboard list unavailable.")
return
}
write(w, http.StatusOK, map[string]any{"items": items})
}
func (h Handler) get(w http.ResponseWriter, r *http.Request, id, actor string) {
s, v, err := h.Repository.Get(r.Context(), id, actor)
if err != nil {
h.repositoryFailure(w, r, err, "Dashboard not found.")
return
}
write(w, http.StatusOK, map[string]any{"dashboard": s, "version": v})
}
func (h Handler) preview(w http.ResponseWriter, r *http.Request, id, actor string) {
allowed, err := h.Repository.CanAccess(r.Context(), id, actor)
if err != nil {
h.repositoryFailure(w, r, err, "Dashboard preview unavailable.")
return
}
if !allowed {
fail(w, r, http.StatusForbidden, "FORBIDDEN", "You cannot preview this dashboard.")
return
}
var body struct {
Widget map[string]any
State string
}
if err := decode(r, &body); err != nil || body.Widget == nil {
fail(w, r, http.StatusBadRequest, "INVALID_PREVIEW", "A widget configuration is required.")
return
}
result, err := widgetpreview.Preview(body.Widget, body.State)
if err != nil {
var invalid widgetpreview.InvalidConfig
if errors.As(err, &invalid) {
fail(w, r, http.StatusBadRequest, "INVALID_WIDGET_CONFIG", "The widget configuration is invalid.", invalid.Fields)
return
}
fail(w, r, http.StatusBadRequest, "INVALID_PREVIEW", "The widget preview is invalid.")
return
}
write(w, http.StatusOK, map[string]any{"preview": result})
}
func (h Handler) update(w http.ResponseWriter, r *http.Request, id, actor string) {
expected, err := revision(r)
if err != nil {
fail(w, r, http.StatusBadRequest, "INVALID_REVISION", "A valid If-Match or revision value is required.")
return
}
var doc dashboard.Document
if err := decode(r, &doc); err != nil {
fail(w, r, http.StatusBadRequest, "INVALID_DOCUMENT", "The dashboard document is invalid.")
return
}
s, err := h.Repository.UpdateDocument(r.Context(), id, actor, expected, doc, "document update")
if err != nil {
h.repositoryFailure(w, r, err, "Dashboard update failed.")
return
}
if err := h.record(r, actor, "dashboard.update_document", s.ID, map[string]any{"revision": expected}, map[string]any{"revision": s.Revision}); err != nil {
fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.")
return
}
write(w, http.StatusOK, map[string]any{"dashboard": s})
}
func (h Handler) metadata(w http.ResponseWriter, r *http.Request, id, actor string) {
expected, err := revision(r)
if err != nil {
fail(w, r, http.StatusBadRequest, "INVALID_REVISION", "A valid If-Match or revision value is required.")
return
}
var body struct {
Name string
Description string
}
if err := decode(r, &body); err != nil || strings.TrimSpace(body.Name) == "" {
fail(w, r, http.StatusBadRequest, "INVALID_METADATA", "A dashboard name is required.")
return
}
s, err := h.Repository.UpdateMetadata(r.Context(), id, actor, expected, body.Name, body.Description)
if err != nil {
h.repositoryFailure(w, r, err, "Metadata update failed.")
return
}
if err := h.record(r, actor, "dashboard.update_metadata", s.ID, map[string]any{"revision": expected}, map[string]any{"revision": s.Revision}); err != nil {
fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.")
return
}
write(w, http.StatusOK, map[string]any{"dashboard": s})
}
func (h Handler) archive(w http.ResponseWriter, r *http.Request, id, actor string) {
expected, err := revision(r)
if err != nil {
fail(w, r, http.StatusBadRequest, "INVALID_REVISION", "A valid If-Match or revision value is required.")
return
}
s, err := h.Repository.Archive(r.Context(), id, actor, expected)
if err != nil {
h.repositoryFailure(w, r, err, "Dashboard archive failed.")
return
}
if err := h.record(r, actor, "dashboard.archive", s.ID, map[string]any{"revision": expected}, map[string]any{"revision": s.Revision}); err != nil {
fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.")
return
}
write(w, http.StatusOK, map[string]any{"dashboard": s})
}
func (h Handler) versions(w http.ResponseWriter, r *http.Request, id, actor string) {
items, err := h.Repository.Versions(r.Context(), id, actor, 100)
if err != nil {
h.repositoryFailure(w, r, err, "Version history unavailable.")
return
}
write(w, http.StatusOK, map[string]any{"items": items})
}
func (h Handler) version(w http.ResponseWriter, r *http.Request, id, value, actor string) {
number, err := strconv.Atoi(value)
if err != nil || number < 1 {
fail(w, r, http.StatusBadRequest, "INVALID_VERSION", "The version number is invalid.")
return
}
item, err := h.Repository.GetVersion(r.Context(), id, actor, number)
if err != nil {
h.repositoryFailure(w, r, err, "Dashboard version not found.")
return
}
write(w, http.StatusOK, map[string]any{"version": item})
}
func (h Handler) clone(w http.ResponseWriter, r *http.Request, id, actor string) {
var body struct {
Slug string
Name string
}
if err := decode(r, &body); err != nil {
fail(w, r, http.StatusBadRequest, "INVALID_CLONE", "The clone options are invalid.")
return
}
s, v, err := h.Repository.Clone(r.Context(), actor, id, body.Slug, body.Name)
if err != nil {
h.repositoryFailure(w, r, err, "Dashboard could not be cloned.")
return
}
if err := h.record(r, actor, "dashboard.clone", s.ID, nil, map[string]any{"revision": s.Revision}); err != nil {
fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.")
return
}
write(w, http.StatusCreated, map[string]any{"dashboard": s, "version": v})
}
func (h Handler) restore(w http.ResponseWriter, r *http.Request, id, actor, value string) {
number := value
if number == "" {
var body struct{ Version int }
if err := decode(r, &body); err != nil {
fail(w, r, http.StatusBadRequest, "INVALID_VERSION", "A version number is required.")
return
}
number = strconv.Itoa(body.Version)
}
versionNumber, err := strconv.Atoi(number)
if err != nil || versionNumber < 1 {
fail(w, r, http.StatusBadRequest, "INVALID_VERSION", "The version number is invalid.")
return
}
expected, err := revision(r)
if err != nil {
fail(w, r, http.StatusBadRequest, "INVALID_REVISION", "A valid If-Match or revision value is required.")
return
}
s, err := h.Repository.Restore(r.Context(), id, actor, expected, versionNumber)
if err != nil {
h.repositoryFailure(w, r, err, "Dashboard could not be restored.")
return
}
if err := h.record(r, actor, "dashboard.restore", s.ID, map[string]any{"version": versionNumber}, map[string]any{"revision": s.Revision}); err != nil {
fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.")
return
}
write(w, http.StatusOK, map[string]any{"dashboard": s})
}
func (h Handler) record(r *http.Request, actor, action, resourceID string, before, after map[string]any) error {
if h.Audit == nil {
return nil
}
return h.Audit.Append(r.Context(), audit.Event{Actor: actor, Action: action, ResourceType: "dashboard", ResourceID: resourceID, Result: "success", CorrelationID: correlation.FromContext(r.Context()), Before: before, After: after})
}
func (h Handler) repositoryFailure(w http.ResponseWriter, r *http.Request, err error, fallback string) {
switch {
case errors.Is(err, dashboard.ErrConflict):
fail(w, r, http.StatusConflict, "REVISION_CONFLICT", "The dashboard was changed by another request.")
case errors.Is(err, dashboard.ErrForbidden):
fail(w, r, http.StatusForbidden, "FORBIDDEN", "You cannot change this dashboard.")
case errors.Is(err, dashboard.ErrNotFound):
fail(w, r, http.StatusNotFound, "NOT_FOUND", fallback)
default:
fail(w, r, http.StatusBadRequest, "DASHBOARD_REQUEST_FAILED", fallback)
}
}
func decode(r *http.Request, target any) error {
contentType := strings.ToLower(strings.TrimSpace(strings.Split(r.Header.Get("Content-Type"), ";")[0]))
if contentType != "" && contentType != "application/json" {
return errors.New("unsupported content type")
}
body, err := io.ReadAll(io.LimitReader(r.Body, 2<<20+1))
if err != nil {
return err
}
defer r.Body.Close()
if len(body) > 2<<20 {
return errors.New("request too large")
}
decoder := json.NewDecoder(strings.NewReader(string(body)))
if err := decoder.Decode(target); err != nil {
return err
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
return errors.New("multiple JSON values")
}
return nil
}
func revision(r *http.Request) (int64, error) {
value := r.Header.Get("If-Match")
if value == "" {
value = r.URL.Query().Get("revision")
}
return strconv.ParseInt(strings.Trim(value, "\""), 10, 64)
}
func fail(w http.ResponseWriter, r *http.Request, status int, code, detail string, fields ...map[string]string) {
var extra map[string]string
if len(fields) > 0 {
extra = fields[0]
}
problem.Write(w, r, status, code, http.StatusText(status), detail, extra)
}
func write(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}