diff --git a/server/internal/api/docs/openapi.json b/server/internal/api/docs/openapi.json index 8f73b33..796174e 100644 --- a/server/internal/api/docs/openapi.json +++ b/server/internal/api/docs/openapi.json @@ -453,6 +453,21 @@ }, "type": "object" }, + "api.StatusIncidentUpdateRequest": { + "properties": { + "body": { + "type": "string" + }, + "status": { + "type": "string" + } + }, + "required": [ + "body", + "status" + ], + "type": "object" + }, "api.StatusResponse": { "properties": { "status": { @@ -1601,6 +1616,170 @@ }, "type": "object" }, + "models.StatusIncident": { + "properties": { + "_id": { + "type": "string" + }, + "affected_monitors": { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "created_at": { + "type": "string" + }, + "impact": { + "type": "string" + }, + "incident_id": { + "type": "string" + }, + "instance_id": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "page_ids": { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "resolved_at": { + "type": "string" + }, + "scheduled_end": { + "type": "string" + }, + "scheduled_start": { + "type": "string" + }, + "started_at": { + "type": "string" + }, + "status": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "updates": { + "items": { + "$ref": "#/components/schemas/models.StatusIncidentUpdate" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, + "models.StatusIncidentUpdate": { + "properties": { + "at": { + "type": "string" + }, + "author": { + "type": "string" + }, + "body": { + "type": "string" + }, + "status": { + "type": "string" + } + }, + "type": "object" + }, + "models.StatusPage": { + "properties": { + "_id": { + "type": "string" + }, + "banner": { + "$ref": "#/components/schemas/models.StatusPageBanner" + }, + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "instance_id": { + "type": "string" + }, + "logo_url": { + "type": "string" + }, + "page_id": { + "type": "string" + }, + "published": { + "type": "boolean" + }, + "sections": { + "items": { + "$ref": "#/components/schemas/models.StatusPageSection" + }, + "type": "array", + "uniqueItems": false + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "type": "object" + }, + "models.StatusPageBanner": { + "properties": { + "enabled": { + "type": "boolean" + }, + "level": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "type": "object" + }, + "models.StatusPageEntry": { + "properties": { + "display_name": { + "type": "string" + }, + "monitor_id": { + "type": "string" + } + }, + "type": "object" + }, + "models.StatusPageSection": { + "properties": { + "entries": { + "items": { + "$ref": "#/components/schemas/models.StatusPageEntry" + }, + "type": "array", + "uniqueItems": false + }, + "name": { + "type": "string" + } + }, + "type": "object" + }, "models.StepOverride": { "properties": { "script": { @@ -6871,6 +7050,593 @@ ] } }, + "/status-pages": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/models.StatusPage" + }, + "type": "array" + } + } + }, + "description": "OK" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.ErrorResponse" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "summary": "List status pages", + "tags": [ + "status-pages" + ] + }, + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object" + }, + { + "$ref": "#/components/schemas/models.StatusPage", + "summary": "body", + "description": "Status page" + } + ] + } + } + }, + "description": "Status page", + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/models.StatusPage" + } + } + }, + "description": "Created" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.ErrorResponse" + } + } + }, + "description": "Conflict" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "summary": "Create a status page", + "tags": [ + "status-pages" + ] + } + }, + "/status-pages/{pageId}": { + "delete": { + "parameters": [ + { + "description": "Page id", + "in": "path", + "name": "pageId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.ErrorResponse" + } + } + }, + "description": "Not Found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "summary": "Delete a status page", + "tags": [ + "status-pages" + ] + }, + "get": { + "parameters": [ + { + "description": "Page id", + "in": "path", + "name": "pageId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/models.StatusPage" + } + } + }, + "description": "OK" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.ErrorResponse" + } + } + }, + "description": "Not Found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "summary": "Get a status page", + "tags": [ + "status-pages" + ] + }, + "put": { + "parameters": [ + { + "description": "Page id", + "in": "path", + "name": "pageId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object" + }, + { + "$ref": "#/components/schemas/models.StatusPage", + "summary": "body", + "description": "Status page" + } + ] + } + } + }, + "description": "Status page", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/models.StatusPage" + } + } + }, + "description": "OK" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.ErrorResponse" + } + } + }, + "description": "Not Found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "summary": "Update a status page", + "tags": [ + "status-pages" + ] + } + }, + "/status-pages/{pageId}/incidents": { + "get": { + "parameters": [ + { + "description": "Page id", + "in": "path", + "name": "pageId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/models.StatusIncident" + }, + "type": "array" + } + } + }, + "description": "OK" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "summary": "List authored incidents for a status page", + "tags": [ + "status-pages" + ] + }, + "post": { + "parameters": [ + { + "description": "Page id", + "in": "path", + "name": "pageId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object" + }, + { + "$ref": "#/components/schemas/models.StatusIncident", + "summary": "body", + "description": "Incident" + } + ] + } + } + }, + "description": "Incident", + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/models.StatusIncident" + } + } + }, + "description": "Created" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.ErrorResponse" + } + } + }, + "description": "Bad Request" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "summary": "Create an incident or maintenance window", + "tags": [ + "status-pages" + ] + } + }, + "/status-pages/{pageId}/incidents/{incidentId}": { + "delete": { + "parameters": [ + { + "description": "Page id", + "in": "path", + "name": "pageId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Incident id", + "in": "path", + "name": "incidentId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.ErrorResponse" + } + } + }, + "description": "Not Found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "summary": "Delete an incident or maintenance window", + "tags": [ + "status-pages" + ] + }, + "put": { + "parameters": [ + { + "description": "Page id", + "in": "path", + "name": "pageId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Incident id", + "in": "path", + "name": "incidentId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object" + }, + { + "$ref": "#/components/schemas/models.StatusIncident", + "summary": "body", + "description": "Incident" + } + ] + } + } + }, + "description": "Incident", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/models.StatusIncident" + } + } + }, + "description": "OK" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.ErrorResponse" + } + } + }, + "description": "Not Found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "summary": "Update an incident or maintenance window", + "tags": [ + "status-pages" + ] + } + }, + "/status-pages/{pageId}/incidents/{incidentId}/updates": { + "post": { + "parameters": [ + { + "description": "Page id", + "in": "path", + "name": "pageId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Incident id", + "in": "path", + "name": "incidentId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object" + }, + { + "$ref": "#/components/schemas/api.StatusIncidentUpdateRequest", + "summary": "body", + "description": "Update" + } + ] + } + } + }, + "description": "Update", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/models.StatusIncident" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.ErrorResponse" + } + } + }, + "description": "Not Found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "summary": "Post an update to an incident", + "tags": [ + "status-pages" + ] + } + }, "/steps": { "get": { "responses": { diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index 6e7694f..7ea07bd 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -166,6 +166,8 @@ func RegisterRoutes(r *gin.Engine) { apiGroup.POST("/servers/:id/workloads/refresh", refreshServerWorkloads) apiGroup.POST("/servers/:id/workloads/:wid/action", auth.RequireRole("owner", "admin"), controlWorkload) apiGroup.GET("/servers/:id/workloads/:wid/logs", auth.RequireRole("owner", "admin"), getWorkloadLogs) + + registerStatusPageRoutes(apiGroup) } } diff --git a/server/internal/api/scopes.go b/server/internal/api/scopes.go index 742036f..bea5297 100644 --- a/server/internal/api/scopes.go +++ b/server/internal/api/scopes.go @@ -156,6 +156,19 @@ var routeScopes = map[string]string{ "GET /api/openapi.json": "settings:read", "GET /api/docs": "settings:read", "GET /api/docs/scalar.js": "settings:read", + + // Status pages. Reading is status:read even though the pages themselves + // are public, because these routes read the unpublished ones too. + "GET /api/status-pages": "status:read", + "POST /api/status-pages": "status:write", + "GET /api/status-pages/:pageId": "status:read", + "PUT /api/status-pages/:pageId": "status:write", + "DELETE /api/status-pages/:pageId": "status:write", + "GET /api/status-pages/:pageId/incidents": "status:read", + "POST /api/status-pages/:pageId/incidents": "status:write", + "PUT /api/status-pages/:pageId/incidents/:incidentId": "status:write", + "DELETE /api/status-pages/:pageId/incidents/:incidentId": "status:write", + "POST /api/status-pages/:pageId/incidents/:incidentId/updates": "status:write", } // RequireScopes enforces routeScopes for token-authenticated requests and does diff --git a/server/internal/api/statuspages.go b/server/internal/api/statuspages.go new file mode 100644 index 0000000..d1b8fd1 --- /dev/null +++ b/server/internal/api/statuspages.go @@ -0,0 +1,312 @@ +package api + +import ( + "errors" + "net/http" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services" + "gitea.hostxtra.co.uk/mrhid6/vantage/shared/license" + "github.com/gin-gonic/gin" +) + +func registerStatusPageRoutes(g *gin.RouterGroup) { + // Owner or admin throughout: publishing a page is speaking to the public + // in the instance's name. The feature gate sits alongside the role gate so + // authoring and serving are gated by the same licence feature. + sp := g.Group("/status-pages") + sp.Use(auth.RequireRole("owner", "admin"), RequireFeature(license.FeatureStatusPages)) + + sp.GET("", listStatusPages) + sp.POST("", createStatusPage) + sp.GET("/:pageId", getStatusPage) + sp.PUT("/:pageId", updateStatusPage) + sp.DELETE("/:pageId", deleteStatusPage) + sp.GET("/:pageId/incidents", listStatusIncidents) + sp.POST("/:pageId/incidents", createStatusIncident) + sp.PUT("/:pageId/incidents/:incidentId", updateStatusIncident) + sp.DELETE("/:pageId/incidents/:incidentId", deleteStatusIncident) + sp.POST("/:pageId/incidents/:incidentId/updates", appendStatusIncidentUpdate) +} + +// statusPageError maps the service errors onto codes once, so ten handlers do +// not each invent their own. services.ErrPageInvalid covers every validation +// failure in the status page and incident services — a missing title or an +// invalid incident status is a 400, not a 500. +func statusPageError(c *gin.Context, err error) { + switch { + case errors.Is(err, services.ErrPageNotFound), errors.Is(err, services.ErrIncidentNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + case errors.Is(err, services.ErrPageIDTaken): + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + case errors.Is(err, services.ErrInvalidPageID), errors.Is(err, services.ErrPageInvalid): + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + } +} + +// listStatusPages godoc +// +// @Summary List status pages +// @Tags status-pages +// @Produce json +// @Success 200 {array} models.StatusPage +// @Failure 500 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /status-pages [get] +func listStatusPages(c *gin.Context) { + pages, err := services.ListStatusPages(auth.InstanceID(c)) + if err != nil { + statusPageError(c, err) + return + } + c.JSON(http.StatusOK, pages) +} + +// createStatusPage godoc +// +// @Summary Create a status page +// @Tags status-pages +// @Accept json +// @Produce json +// @Param body body models.StatusPage true "Status page" +// @Success 201 {object} models.StatusPage +// @Failure 400 {object} ErrorResponse +// @Failure 409 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /status-pages [post] +func createStatusPage(c *gin.Context) { + var p models.StatusPage + if err := c.ShouldBindJSON(&p); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + created, err := services.CreateStatusPage(auth.InstanceID(c), &p) + if err != nil { + statusPageError(c, err) + return + } + services.LogEvent(auth.InstanceID(c), "status_page_created", actorFromCtx(c), "", "", + "Status page '"+created.PageID+"' created") + c.JSON(http.StatusCreated, created) +} + +// getStatusPage godoc +// +// @Summary Get a status page +// @Tags status-pages +// @Produce json +// @Param pageId path string true "Page id" +// @Success 200 {object} models.StatusPage +// @Failure 404 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /status-pages/{pageId} [get] +func getStatusPage(c *gin.Context) { + page, err := services.GetStatusPage(auth.InstanceID(c), c.Param("pageId")) + if err != nil { + statusPageError(c, err) + return + } + c.JSON(http.StatusOK, page) +} + +// updateStatusPage godoc +// +// @Summary Update a status page +// @Tags status-pages +// @Accept json +// @Produce json +// @Param pageId path string true "Page id" +// @Param body body models.StatusPage true "Status page" +// @Success 200 {object} models.StatusPage +// @Failure 404 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /status-pages/{pageId} [put] +func updateStatusPage(c *gin.Context) { + var p models.StatusPage + if err := c.ShouldBindJSON(&p); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + updated, err := services.UpdateStatusPage(auth.InstanceID(c), c.Param("pageId"), &p) + if err != nil { + statusPageError(c, err) + return + } + services.LogEvent(auth.InstanceID(c), "status_page_updated", actorFromCtx(c), "", "", + "Status page '"+updated.PageID+"' updated") + c.JSON(http.StatusOK, updated) +} + +// deleteStatusPage godoc +// +// @Summary Delete a status page +// @Tags status-pages +// @Produce json +// @Param pageId path string true "Page id" +// @Success 204 "No Content" +// @Failure 404 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /status-pages/{pageId} [delete] +func deleteStatusPage(c *gin.Context) { + if err := services.DeleteStatusPage(auth.InstanceID(c), c.Param("pageId")); err != nil { + statusPageError(c, err) + return + } + services.LogEvent(auth.InstanceID(c), "status_page_deleted", actorFromCtx(c), "", "", + "Status page '"+c.Param("pageId")+"' deleted") + c.Status(http.StatusNoContent) +} + +// listStatusIncidents godoc +// +// @Summary List authored incidents for a status page +// @Tags status-pages +// @Produce json +// @Param pageId path string true "Page id" +// @Success 200 {array} models.StatusIncident +// @Security cookieAuth +// @Security bearerAuth +// @Router /status-pages/{pageId}/incidents [get] +func listStatusIncidents(c *gin.Context) { + incs, err := services.ListStatusIncidents(auth.InstanceID(c), c.Param("pageId")) + if err != nil { + statusPageError(c, err) + return + } + c.JSON(http.StatusOK, incs) +} + +// createStatusIncident godoc +// +// @Summary Create an incident or maintenance window +// @Tags status-pages +// @Accept json +// @Produce json +// @Param pageId path string true "Page id" +// @Param body body models.StatusIncident true "Incident" +// @Success 201 {object} models.StatusIncident +// @Failure 400 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /status-pages/{pageId}/incidents [post] +func createStatusIncident(c *gin.Context) { + var inc models.StatusIncident + if err := c.ShouldBindJSON(&inc); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + // The page in the path is always one of the pages the incident names, so + // creating from a page cannot produce an incident that page never shows. + if !contains(inc.PageIDs, c.Param("pageId")) { + inc.PageIDs = append(inc.PageIDs, c.Param("pageId")) + } + created, err := services.CreateStatusIncident(auth.InstanceID(c), &inc) + if err != nil { + statusPageError(c, err) + return + } + services.LogEvent(auth.InstanceID(c), "status_incident_created", actorFromCtx(c), "", "", + "Status "+created.Kind+" '"+created.Title+"' created") + c.JSON(http.StatusCreated, created) +} + +func contains(list []string, want string) bool { + for _, v := range list { + if v == want { + return true + } + } + return false +} + +// updateStatusIncident godoc +// +// @Summary Update an incident or maintenance window +// @Tags status-pages +// @Accept json +// @Produce json +// @Param pageId path string true "Page id" +// @Param incidentId path string true "Incident id" +// @Param body body models.StatusIncident true "Incident" +// @Success 200 {object} models.StatusIncident +// @Failure 404 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /status-pages/{pageId}/incidents/{incidentId} [put] +func updateStatusIncident(c *gin.Context) { + var inc models.StatusIncident + if err := c.ShouldBindJSON(&inc); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + updated, err := services.UpdateStatusIncident(auth.InstanceID(c), c.Param("incidentId"), &inc) + if err != nil { + statusPageError(c, err) + return + } + services.LogEvent(auth.InstanceID(c), "status_incident_updated", actorFromCtx(c), "", "", + "Status "+updated.Kind+" '"+updated.Title+"' updated") + c.JSON(http.StatusOK, updated) +} + +// deleteStatusIncident godoc +// +// @Summary Delete an incident or maintenance window +// @Tags status-pages +// @Produce json +// @Param pageId path string true "Page id" +// @Param incidentId path string true "Incident id" +// @Success 204 "No Content" +// @Failure 404 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /status-pages/{pageId}/incidents/{incidentId} [delete] +func deleteStatusIncident(c *gin.Context) { + if err := services.DeleteStatusIncident(auth.InstanceID(c), c.Param("incidentId")); err != nil { + statusPageError(c, err) + return + } + services.LogEvent(auth.InstanceID(c), "status_incident_deleted", actorFromCtx(c), "", "", + "Status incident '"+c.Param("incidentId")+"' deleted") + c.Status(http.StatusNoContent) +} + +// appendStatusIncidentUpdate godoc +// +// @Summary Post an update to an incident +// @Tags status-pages +// @Accept json +// @Produce json +// @Param pageId path string true "Page id" +// @Param incidentId path string true "Incident id" +// @Param body body StatusIncidentUpdateRequest true "Update" +// @Success 200 {object} models.StatusIncident +// @Failure 400 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Security cookieAuth +// @Security bearerAuth +// @Router /status-pages/{pageId}/incidents/{incidentId}/updates [post] +func appendStatusIncidentUpdate(c *gin.Context) { + var body StatusIncidentUpdateRequest + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + updated, err := services.AppendStatusIncidentUpdate( + auth.InstanceID(c), c.Param("incidentId"), body.Status, body.Body, actorFromCtx(c)) + if err != nil { + statusPageError(c, err) + return + } + services.LogEvent(auth.InstanceID(c), "status_incident_update_posted", actorFromCtx(c), "", "", + "Update posted to '"+updated.Title+"' ("+body.Status+")") + c.JSON(http.StatusOK, updated) +} diff --git a/server/internal/api/types.go b/server/internal/api/types.go index 4fe2314..e94770f 100644 --- a/server/internal/api/types.go +++ b/server/internal/api/types.go @@ -237,3 +237,12 @@ type WorkloadLogsResponse struct { Text string `json:"text"` Truncated bool `json:"truncated"` } + +// --- status pages --- + +// StatusIncidentUpdateRequest is one post to an incident's timeline. The author +// is taken from the session, never from the body. +type StatusIncidentUpdateRequest struct { + Status string `json:"status" binding:"required"` + Body string `json:"body" binding:"required"` +} diff --git a/server/internal/services/scopes.go b/server/internal/services/scopes.go index a1bce53..ce8a4f4 100644 --- a/server/internal/services/scopes.go +++ b/server/internal/services/scopes.go @@ -26,6 +26,7 @@ var ScopeResources = []string{ "vulns", "workloads", "settings", + "status", } const ( diff --git a/server/internal/services/statusincidents.go b/server/internal/services/statusincidents.go index e83c054..512ab16 100644 --- a/server/internal/services/statusincidents.go +++ b/server/internal/services/statusincidents.go @@ -35,34 +35,34 @@ var impacts = map[string]bool{ func validateIncident(inc *models.StatusIncident) error { if inc.Title == "" { - return errors.New("title is required") + return fmt.Errorf("%w: title is required", ErrPageInvalid) } if len(inc.PageIDs) == 0 { - return errors.New("at least one page is required") + return fmt.Errorf("%w: at least one page is required", ErrPageInvalid) } switch inc.Kind { case models.StatusKindIncident: if !incidentStatuses[inc.Status] { - return fmt.Errorf("invalid incident status %q", inc.Status) + return fmt.Errorf("%w: invalid incident status %q", ErrPageInvalid, inc.Status) } case models.StatusKindMaintenance: if !maintenanceStatuses[inc.Status] { - return fmt.Errorf("invalid maintenance status %q", inc.Status) + return fmt.Errorf("%w: invalid maintenance status %q", ErrPageInvalid, inc.Status) } if inc.ScheduledStart == nil || inc.ScheduledEnd == nil { - return errors.New("maintenance needs a scheduled start and end") + return fmt.Errorf("%w: maintenance needs a scheduled start and end", ErrPageInvalid) } if !inc.ScheduledEnd.After(*inc.ScheduledStart) { - return errors.New("maintenance must end after it starts") + return fmt.Errorf("%w: maintenance must end after it starts", ErrPageInvalid) } default: - return fmt.Errorf("invalid kind %q", inc.Kind) + return fmt.Errorf("%w: invalid kind %q", ErrPageInvalid, inc.Kind) } if inc.Impact == "" { inc.Impact = models.ImpactNone } if !impacts[inc.Impact] { - return fmt.Errorf("invalid impact %q", inc.Impact) + return fmt.Errorf("%w: invalid impact %q", ErrPageInvalid, inc.Impact) } return nil } @@ -214,14 +214,14 @@ func AppendStatusIncidentUpdate(instanceID, incidentID, status, body, author str return nil, err } if body == "" { - return nil, errors.New("update body is required") + return nil, fmt.Errorf("%w: update body is required", ErrPageInvalid) } valid := incidentStatuses if existing.Kind == models.StatusKindMaintenance { valid = maintenanceStatuses } if !valid[status] { - return nil, fmt.Errorf("invalid status %q for a %s", status, existing.Kind) + return nil, fmt.Errorf("%w: invalid status %q for a %s", ErrPageInvalid, status, existing.Kind) } upd := models.StatusIncidentUpdate{At: time.Now(), Status: status, Body: body, Author: author} diff --git a/server/internal/services/statuspages.go b/server/internal/services/statuspages.go index 9f8e4d4..4fdbc0c 100644 --- a/server/internal/services/statuspages.go +++ b/server/internal/services/statuspages.go @@ -3,6 +3,7 @@ package services import ( "context" "errors" + "fmt" "log" "regexp" "time" @@ -74,6 +75,12 @@ func EnsureStatusPageIndexes() error { var ( ErrPageNotFound = errors.New("status page not found") ErrPageIDTaken = errors.New("that page id is already in use") + + // ErrPageInvalid is the sentinel for validation failures on a page or + // incident body — anything the caller can fix by sending a different + // request. statusPageError maps it to 400; wrap it rather than returning a + // bare error, or a bad request answers 500. + ErrPageInvalid = errors.New("status page request invalid") ) // statusRedis is set by main.go at boot. It is nil in any process that has not @@ -134,7 +141,7 @@ func CreateStatusPage(instanceID string, p *models.StatusPage) (*models.StatusPa return nil, err } if p.Title == "" { - return nil, errors.New("title is required") + return nil, fmt.Errorf("%w: title is required", ErrPageInvalid) } p.ID = bson.ObjectID{} p.InstanceID = instanceID @@ -169,7 +176,7 @@ func CreateStatusPage(instanceID string, p *models.StatusPage) (*models.StatusPa // The page id itself is immutable: it is a URL that has been handed out. func UpdateStatusPage(instanceID, pageID string, p *models.StatusPage) (*models.StatusPage, error) { if p.Title == "" { - return nil, errors.New("title is required") + return nil, fmt.Errorf("%w: title is required", ErrPageInvalid) } if p.Sections == nil { p.Sections = []models.StatusPageSection{}