Public source validation / validate (push) Failing after 3m8s
408 lines
14 KiB
Go
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)
|
|
}
|