Files
DoGaMa-serv/internal/web/server.go
T

1240 lines
45 KiB
Go

// Package web serves DoGaMa's embedded, server-rendered interface.
package web
import (
"bytes"
"context"
"crypto/rand"
"crypto/subtle"
"embed"
"encoding/base64"
"encoding/json"
"errors"
"html/template"
"io"
"log/slog"
"net/http"
"regexp"
"time"
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/authorization"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/backup"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/importexport"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
)
const (
sessionCookie = "dogama_session"
csrfCookie = "dogama_csrf"
maxFormBytes = 64 << 10
)
//go:embed templates/*.html static/*.css
var assets embed.FS
var englishMessages = map[string]string{
"brand": "DoGaMa",
"setup.title": "Create administrator",
"setup.heading": "Welcome to DoGaMa",
"setup.introduction": "Create the first administrator to finish setup.",
"setup.submit": "Create administrator",
"login.title": "Sign in",
"login.heading": "Sign in to DoGaMa",
"login.submit": "Sign in",
"logout.submit": "Sign out",
"dashboard.title": "Dashboard",
"dashboard.ready": "The initial application foundation is ready.",
"field.username": "Username",
"field.password": "Password",
"error.csrf": "Request verification failed.",
"error.form": "Invalid form submission.",
"error.internal": "The request could not be completed.",
"error.credentials": "Invalid username or password.",
"dashboard.signed_in": "Signed in as",
}
type server struct {
auth *auth.Service
templates *template.Template
logger *slog.Logger
repository repository
lifecycle *instance.LifecycleService
permissions *authorization.Service
backups *backup.Service
imports *importexport.Service
}
type repository interface {
catalog.Repository
instance.Repository
instance.LifecycleRepository
authorization.Repository
}
type pageData struct {
Title string
CSRFToken string
Error string
User auth.User
GlobalLabels string
IsAdmin bool
}
// NewHandler constructs the complete HTTP application.
func NewHandler(authService *auth.Service, logger *slog.Logger) (http.Handler, error) {
return newHandler(authService, nil, nil, nil, logger)
}
// NewHandlerWithRepository enables the authenticated catalog and draft APIs.
func NewHandlerWithRepository(authService *auth.Service, repository repository, logger *slog.Logger) (http.Handler, error) {
return newHandler(authService, repository, nil, nil, logger)
}
func NewHandlerWithRepositoryAndImports(authService *auth.Service, repository repository, importService *importexport.Service, logger *slog.Logger) (http.Handler, error) {
return newHandlerWithImports(authService, repository, nil, nil, importService, logger)
}
// NewHandlerWithLifecycle enables privileged instance lifecycle operations
// through the restricted agent boundary.
func NewHandlerWithLifecycle(authService *auth.Service, repository repository, agent instance.LifecycleAgent, logger *slog.Logger) (http.Handler, error) {
return newHandler(authService, repository, instance.NewLifecycleService(repository, agent), nil, logger)
}
// NewHandlerWithLifecycleAndBackup enables lifecycle and backup operations.
func NewHandlerWithLifecycleAndBackup(authService *auth.Service, repository repository, agent instance.LifecycleAgent, backupService *backup.Service, importService *importexport.Service, logger *slog.Logger) (http.Handler, error) {
return newHandlerWithImports(authService, repository, instance.NewLifecycleService(repository, agent), backupService, importService, logger)
}
func newHandler(authService *auth.Service, repository repository, lifecycle *instance.LifecycleService, backupService *backup.Service, logger *slog.Logger) (http.Handler, error) {
return newHandlerWithImports(authService, repository, lifecycle, backupService, nil, logger)
}
func newHandlerWithImports(authService *auth.Service, repository repository, lifecycle *instance.LifecycleService, backupService *backup.Service, importService *importexport.Service, logger *slog.Logger) (http.Handler, error) {
templates, err := template.New("views").Funcs(template.FuncMap{"msg": message}).ParseFS(assets, "templates/*.html")
if err != nil {
return nil, err
}
s := &server{auth: authService, templates: templates, logger: logger, repository: repository, lifecycle: lifecycle, backups: backupService, imports: importService}
if repository != nil {
s.permissions = authorization.New(repository)
}
mux := http.NewServeMux()
if repository != nil {
mux.HandleFunc("GET /public/game-icons/{gameID}", s.publicGameIcon)
mux.HandleFunc("GET /api/v1/catalog", s.catalogList)
mux.HandleFunc("POST /api/v1/instances/preview", s.instancePreview)
mux.HandleFunc("POST /api/v1/instances/drafts", s.instanceDraft)
mux.HandleFunc("GET /api/v1/installation-requests", s.installationRequestList)
mux.HandleFunc("POST /api/v1/installation-requests", s.installationRequestCreate)
mux.HandleFunc("POST /api/v1/installation-requests/{id}/review", s.installationRequestReview)
mux.HandleFunc("GET /api/v1/admin/users", s.userList)
mux.HandleFunc("POST /api/v1/admin/users", s.userCreate)
mux.HandleFunc("GET /api/v1/admin/game-container-labels", s.globalLabelsGet)
mux.HandleFunc("PUT /api/v1/admin/game-container-labels", s.globalLabelsPut)
mux.HandleFunc("GET /api/v1/instances/{id}/memberships", s.membershipList)
mux.HandleFunc("PUT /api/v1/instances/{id}/memberships/{userID}", s.membershipSet)
mux.HandleFunc("DELETE /api/v1/instances/{id}/memberships/{userID}", s.membershipDelete)
mux.HandleFunc("PUT /api/v1/instances/{id}/memberships/{userID}/permissions/{permission}", s.permissionOverrideSet)
mux.HandleFunc("DELETE /api/v1/instances/{id}/memberships/{userID}/permissions/{permission}", s.permissionOverrideDelete)
if lifecycle != nil {
mux.HandleFunc("GET /api/v1/instances/{id}", s.instanceInspect)
mux.HandleFunc("GET /api/v1/instances/{id}/stats", s.instanceStats)
mux.HandleFunc("POST /api/v1/instances/{id}/install", s.instanceInstall)
mux.HandleFunc("POST /api/v1/instances/{id}/start", s.instanceStart)
mux.HandleFunc("POST /api/v1/instances/{id}/stop", s.instanceStop)
mux.HandleFunc("POST /api/v1/instances/{id}/restart", s.instanceRestart)
mux.HandleFunc("DELETE /api/v1/instances/{id}", s.instanceDeleteContainer)
mux.HandleFunc("GET /api/v1/instances/{id}/container-configuration", s.instanceConfigurationGet)
mux.HandleFunc("PUT /api/v1/instances/{id}/container-configuration", s.instanceConfigurationPut)
}
if backupService != nil {
mux.HandleFunc("GET /api/v1/instances/{id}/backups", s.backupList)
mux.HandleFunc("POST /api/v1/instances/{id}/backups", s.backupCreate)
mux.HandleFunc("GET /api/v1/instances/{id}/backups/{backupID}/export", s.backupExport)
mux.HandleFunc("POST /api/v1/instances/{id}/backups/{backupID}/restore", s.backupRestore)
mux.HandleFunc("GET /api/v1/instances/{id}/backup-policy", s.backupPolicyGet)
mux.HandleFunc("PUT /api/v1/instances/{id}/backup-policy", s.backupPolicySet)
}
if importService != nil {
mux.HandleFunc("POST /api/v1/imports", s.importCreate)
}
}
mux.HandleFunc("GET /static/app.v1.css", s.stylesheet)
mux.HandleFunc("GET /setup", s.setupForm)
mux.HandleFunc("POST /setup", s.setupSubmit)
mux.HandleFunc("GET /login", s.loginForm)
mux.HandleFunc("POST /login", s.loginSubmit)
mux.HandleFunc("POST /logout", s.logout)
mux.HandleFunc("POST /admin/game-container-labels", s.globalLabelsForm)
mux.HandleFunc("GET /", s.home)
return s.securityHeaders(mux), nil
}
var publicGameIDPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
func (s *server) publicGameIcon(w http.ResponseWriter, r *http.Request) {
gameID := r.PathValue("gameID")
if !publicGameIDPattern.MatchString(gameID) || gameID != "palworld" {
http.NotFound(w, r)
return
}
body, err := catalogdata.Files.ReadFile("palworld/assets/icon.png")
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "public, max-age=86400")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
}
func requestBaseURL(r *http.Request) string {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
if forwarded := r.Header.Get("X-Forwarded-Proto"); forwarded == "http" || forwarded == "https" {
scheme = forwarded
}
return scheme + "://" + r.Host
}
type applicationModeRequest struct {
Apply string `json:"apply"`
}
func (s *server) globalLabelsGet(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAPIUser(w, r, false)
if !ok {
return
}
if actor.Role != "admin" {
s.apiProblem(w, http.StatusForbidden, "forbidden", "Administrator access is required.")
return
}
repository := s.repository.(instance.ConfigurationRepository)
labels, err := repository.GetGlobalLabels(r.Context())
if err != nil {
s.apiProblem(w, 500, "settings_unavailable", "The settings are unavailable.")
return
}
s.apiJSON(w, http.StatusOK, map[string]any{"labels": instance.FormatLabels(labels), "variables": instance.AllowedLabelVariables})
}
func (s *server) globalLabelsPut(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAPIUser(w, r, true)
if !ok {
return
}
if err := s.permissions.RequireRecentAdmin(actor); err != nil {
s.authorizationProblem(w, err)
return
}
var request struct {
Labels string `json:"labels"`
Apply string `json:"apply"`
}
if !s.decodeStrictJSON(w, r, &request) {
return
}
labels, err := instance.ParseLabels(request.Labels)
if err != nil {
s.apiProblem(w, 422, "invalid_labels", err.Error())
return
}
if request.Apply != "immediate" && request.Apply != "next_start" {
s.apiProblem(w, 422, "invalid_apply_mode", "Apply must be immediate or next_start.")
return
}
if request.Apply == "immediate" && s.lifecycle == nil {
s.apiProblem(w, http.StatusConflict, "lifecycle_unavailable", "Immediate application requires the Docker agent.")
return
}
repository := s.repository.(instance.ConfigurationRepository)
affected, running, err := repository.SetGlobalLabels(r.Context(), labels, true)
if err != nil {
s.apiProblem(w, 500, "settings_update_failed", "The settings could not be updated.")
return
}
if request.Apply == "immediate" && s.lifecycle != nil {
for _, current := range mustLifecycleInstances(r.Context(), s.repository) {
if _, err := s.lifecycle.Configure(r.Context(), current.ID, instance.FormatLabels(current.Preview.CustomLabels), current.Preview.ImageTag, true); err != nil {
s.apiProblem(w, 502, "container_replace_failed", "Global labels were saved, but one or more containers could not be recreated.")
return
}
}
}
s.apiJSON(w, http.StatusOK, map[string]any{"labels": instance.FormatLabels(labels), "affected_instances": affected, "running_instances": running, "container_config_pending": request.Apply == "next_start"})
}
func (s *server) globalLabelsForm(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
if r.ParseForm() != nil {
s.problem(w, 400, message("error.form"))
return
}
actor, err := s.currentUser(r)
session, sessionErr := r.Cookie(sessionCookie)
if err != nil || sessionErr != nil || actor.Role != "admin" || !s.auth.ValidateCSRF(r.Context(), session.Value, r.FormValue("csrf_token")) {
s.problem(w, http.StatusForbidden, message("error.csrf"))
return
}
if err := s.permissions.RequireRecentAdmin(actor); err != nil {
s.problem(w, http.StatusForbidden, "Recent administrator authentication is required.")
return
}
labels, err := instance.ParseLabels(r.FormValue("labels"))
if err != nil {
s.problem(w, 422, err.Error())
return
}
apply := r.FormValue("apply")
if apply != "immediate" && apply != "next_start" {
s.problem(w, 422, "Invalid application mode.")
return
}
if apply == "immediate" && r.FormValue("confirm_disconnection") != "yes" {
s.problem(w, 422, "Confirm that players will be disconnected.")
return
}
repository := s.repository.(instance.ConfigurationRepository)
if _, _, err := repository.SetGlobalLabels(r.Context(), labels, true); err != nil {
s.problem(w, 500, message("error.internal"))
return
}
if apply == "immediate" {
if s.lifecycle == nil {
s.problem(w, 409, "The Docker agent is unavailable.")
return
}
for _, current := range mustLifecycleInstances(r.Context(), s.repository) {
if _, err := s.lifecycle.Configure(r.Context(), current.ID, instance.FormatLabels(current.Preview.CustomLabels), current.Preview.ImageTag, true); err != nil {
s.problem(w, 502, "The settings were saved, but a container could not be recreated.")
return
}
}
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func mustLifecycleInstances(ctx context.Context, repository repository) []instance.StoredInstance {
values, err := repository.ListLifecycleInstances(ctx)
if err != nil {
return nil
}
return values
}
func (s *server) instanceConfigurationGet(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireInstancePermission(w, r, authorization.PermissionInstanceView); !ok {
return
}
current, err := s.repository.GetInstance(r.Context(), r.PathValue("id"))
if err != nil {
s.lifecycleProblem(w, err)
return
}
s.apiJSON(w, 200, map[string]any{"custom_labels": instance.FormatLabels(current.Preview.CustomLabels), "docker_user": current.Preview.DockerUser, "image_tag": current.Preview.ImageTag, "container_config_pending": current.ContainerConfigPending, "docker_user_immutable": true})
}
func (s *server) instanceConfigurationPut(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireInstancePermission(w, r, authorization.PermissionInstanceConfigure); !ok {
return
}
var request struct {
Labels string `json:"labels"`
ImageTag instance.ImageTag `json:"image_tag"`
Apply string `json:"apply"`
}
if !s.decodeStrictJSON(w, r, &request) {
return
}
if request.Apply != "immediate" && request.Apply != "next_start" {
s.apiProblem(w, 422, "invalid_apply_mode", "Apply must be immediate or next_start.")
return
}
result, err := s.lifecycle.Configure(r.Context(), r.PathValue("id"), request.Labels, request.ImageTag, request.Apply == "immediate")
if err != nil {
s.apiProblem(w, 422, "invalid_container_configuration", err.Error())
return
}
s.apiJSON(w, 200, result)
}
func (s *server) decodeStrictJSON(w http.ResponseWriter, r *http.Request, value any) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if decoder.Decode(value) != nil || decoder.Decode(&struct{}{}) != io.EOF {
s.apiProblem(w, 400, "invalid_request", "The request is invalid.")
return false
}
return true
}
type previewAPIRequest struct {
TemplateID string `json:"template_id"`
TemplateVersion string `json:"template_version"`
DisplayName string `json:"display_name"`
Slug string `json:"slug"`
HostPorts map[string]int `json:"host_ports"`
MountPaths map[string]string `json:"mount_paths"`
Resources catalog.Resources `json:"resources"`
DataOrigin string `json:"data_origin"`
BackupRetention int `json:"backup_retention"`
ImportID string `json:"import_id"`
CustomLabels string `json:"custom_labels"`
DockerUser instance.DockerUser `json:"docker_user"`
ImageTag instance.ImageTag `json:"image_tag"`
}
func (s *server) catalogList(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAPIUser(w, r, false); !ok {
return
}
templates, err := s.repository.List(r.Context())
if err != nil {
s.apiProblem(w, http.StatusInternalServerError, "catalog_unavailable", "The catalog is unavailable.")
return
}
s.apiJSON(w, http.StatusOK, struct {
Templates []catalog.Summary `json:"templates"`
}{Templates: templates})
}
func (s *server) instancePreview(w http.ResponseWriter, r *http.Request) {
request, preview, ok := s.buildAPIPreview(w, r)
_ = request
if !ok {
return
}
s.apiJSON(w, http.StatusOK, preview)
}
func (s *server) instanceDraft(w http.ResponseWriter, r *http.Request) {
_, preview, ok := s.buildAPIPreview(w, r)
if !ok {
return
}
id := randomToken()
if err := s.repository.CreateDraft(r.Context(), instance.Draft{ID: id, Preview: preview}); err != nil {
s.apiProblem(w, http.StatusConflict, "draft_conflict", "The draft instance could not be created.")
return
}
s.apiJSON(w, http.StatusCreated, map[string]string{"id": id, "state": "draft", "plan_digest": preview.PlanDigest})
}
func (s *server) instanceInspect(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireInstancePermission(w, r, authorization.PermissionInstanceView); !ok {
return
}
result, err := s.lifecycle.Inspect(r.Context(), r.PathValue("id"))
if err != nil {
s.lifecycleProblem(w, err)
return
}
s.apiJSON(w, http.StatusOK, result)
}
func (s *server) instanceStats(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireInstancePermission(w, r, authorization.PermissionMetricsView); !ok {
return
}
stats, err := s.lifecycle.Stats(r.Context(), r.PathValue("id"))
if err != nil {
s.lifecycleProblem(w, err)
return
}
s.apiJSON(w, http.StatusOK, stats)
}
func (s *server) instanceInstall(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAPIUser(w, r, true)
if !ok {
return
}
if err := s.permissions.RequireRecentAdmin(actor); err != nil {
s.authorizationProblem(w, err)
return
}
current, err := s.repository.GetInstance(r.Context(), r.PathValue("id"))
if err != nil {
s.lifecycleProblem(w, err)
return
}
if current.Preview.DataOrigin == "import" {
if s.imports == nil {
s.apiProblem(w, http.StatusConflict, "import_unavailable", "The validated import is unavailable.")
return
}
mountPath := ""
for _, mount := range current.Preview.Mounts {
if mount.ID == current.Preview.Import.DestinationMount {
mountPath = mount.HostPath
break
}
}
if mountPath == "" {
s.apiProblem(w, http.StatusUnprocessableEntity, "invalid_import", "The import destination is invalid.")
return
}
if err := s.imports.ApplyToInstance(r.Context(), current.Preview.Import.ID, current.ID, current.Preview.Template.ID, current.Preview.Template.Version, mountPath, current.Preview.Import.DestinationRelativePath); err != nil {
s.apiProblem(w, http.StatusUnprocessableEntity, "invalid_import", "The validated import could not be applied.")
return
}
}
s.runLifecycleAction(w, r, s.lifecycle.Install)
}
func (s *server) instanceStart(w http.ResponseWriter, r *http.Request) {
s.lifecycleAction(w, r, authorization.PermissionInstanceStart, s.lifecycle.Start)
}
func (s *server) instanceStop(w http.ResponseWriter, r *http.Request) {
s.lifecycleAction(w, r, authorization.PermissionInstanceStop, s.lifecycle.Stop)
}
func (s *server) instanceRestart(w http.ResponseWriter, r *http.Request) {
s.lifecycleAction(w, r, authorization.PermissionInstanceRestart, s.lifecycle.Restart)
}
func (s *server) lifecycleAction(w http.ResponseWriter, r *http.Request, permission string, action func(context.Context, string) (instance.OperationResult, error)) {
if _, ok := s.requireInstancePermission(w, r, permission); !ok {
return
}
s.runLifecycleAction(w, r, action)
}
func (s *server) runLifecycleAction(w http.ResponseWriter, r *http.Request, action func(context.Context, string) (instance.OperationResult, error)) {
if r.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
body, err := io.ReadAll(r.Body)
if err != nil || len(bytes.TrimSpace(body)) != 0 {
s.apiProblem(w, http.StatusBadRequest, "invalid_request", "The request is invalid.")
return
}
}
result, err := action(r.Context(), r.PathValue("id"))
if err != nil {
s.lifecycleProblem(w, err)
return
}
s.apiJSON(w, http.StatusOK, result)
}
func (s *server) instanceDeleteContainer(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireInstancePermission(w, r, authorization.PermissionInstanceDelete); !ok {
return
}
var request struct {
Scope string `json:"scope"`
}
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if decoder.Decode(&request) != nil || request.Scope != "container_only" || decoder.Decode(&struct{}{}) != io.EOF {
s.apiProblem(w, http.StatusUnprocessableEntity, "unsafe_delete_scope", "Only container-only deletion is available; player data and backups are preserved.")
return
}
result, err := s.lifecycle.DeleteContainer(r.Context(), r.PathValue("id"))
if err != nil {
s.lifecycleProblem(w, err)
return
}
s.apiJSON(w, http.StatusOK, result)
}
func (s *server) lifecycleProblem(w http.ResponseWriter, err error) {
status, code := http.StatusBadGateway, "lifecycle_failed"
switch {
case errors.Is(err, instance.ErrInstanceNotFound):
status, code = http.StatusNotFound, "instance_not_found"
case errors.Is(err, instance.ErrOperationConflict):
status, code = http.StatusConflict, "operation_conflict"
case errors.Is(err, instance.ErrInvalidState):
status, code = http.StatusConflict, "invalid_instance_state"
}
s.apiProblem(w, status, code, "The instance operation could not be completed.")
}
func (s *server) buildAPIPreview(w http.ResponseWriter, r *http.Request) (previewAPIRequest, instance.Preview, bool) {
if _, ok := s.requireAPIUser(w, r, true); !ok {
return previewAPIRequest{}, instance.Preview{}, false
}
var request previewAPIRequest
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil {
s.apiProblem(w, http.StatusBadRequest, "invalid_request", "The request is invalid.")
return request, instance.Preview{}, false
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
s.apiProblem(w, http.StatusBadRequest, "invalid_request", "The request is invalid.")
return request, instance.Preview{}, false
}
snapshot, err := s.repository.Get(r.Context(), request.TemplateID, request.TemplateVersion)
if err != nil {
s.apiProblem(w, http.StatusNotFound, "template_not_found", "The template version was not found.")
return request, instance.Preview{}, false
}
if request.DataOrigin == "import" {
if s.imports == nil || s.imports.ValidateSelection(r.Context(), request.ImportID, request.TemplateID, request.TemplateVersion) != nil {
s.apiProblem(w, http.StatusUnprocessableEntity, "invalid_import", "A validated compatible import is required.")
return request, instance.Preview{}, false
}
}
preview, err := instance.BuildPreview(snapshot, instance.PreviewRequest{
DisplayName: request.DisplayName, Slug: request.Slug, HostPorts: request.HostPorts,
MountPaths: request.MountPaths, Resources: request.Resources, DataOrigin: request.DataOrigin,
BackupRetention: request.BackupRetention, ImportID: request.ImportID,
CustomLabels: request.CustomLabels, DockerUser: request.DockerUser, ImageTag: request.ImageTag,
PublicBaseURL: requestBaseURL(r),
})
if err != nil {
s.apiProblem(w, http.StatusUnprocessableEntity, "invalid_preview", "The deployment preview is invalid.")
return request, instance.Preview{}, false
}
if configured, ok := s.repository.(instance.ConfigurationRepository); ok {
labels, labelErr := configured.GetGlobalLabels(r.Context())
if labelErr != nil {
s.apiProblem(w, http.StatusInternalServerError, "settings_unavailable", "The global settings are unavailable.")
return request, instance.Preview{}, false
}
preview.GlobalLabels = labels
}
return request, preview, true
}
func (s *server) backupList(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireInstancePermission(w, r, authorization.PermissionBackupList); !ok {
return
}
values, err := s.backups.List(r.Context(), r.PathValue("id"))
if err != nil {
s.backupProblem(w, err)
return
}
s.apiJSON(w, http.StatusOK, map[string]any{"backups": values})
}
func (s *server) backupCreate(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireInstancePermission(w, r, authorization.PermissionBackupCreate)
if !ok || !s.requireEmptyBody(w, r) {
return
}
value, err := s.backups.Create(r.Context(), actor.ID, r.PathValue("id"), "manual")
if err != nil {
s.backupProblem(w, err)
return
}
s.apiJSON(w, http.StatusCreated, value)
}
func (s *server) backupExport(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireInstancePermission(w, r, authorization.PermissionBackupExport); !ok {
return
}
file, value, err := s.backups.Export(r.Context(), r.PathValue("id"), r.PathValue("backupID"))
if err != nil {
s.backupProblem(w, err)
return
}
defer file.Close()
w.Header().Set("Content-Type", "application/zstd")
w.Header().Set("Content-Disposition", `attachment; filename="`+value.ID+`.tar.zst"`)
w.Header().Set("X-Content-SHA256", value.SHA256)
http.ServeContent(w, r, value.ID+".tar.zst", time.Time{}, file)
}
func (s *server) backupRestore(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireInstancePermission(w, r, authorization.PermissionBackupRestore)
if !ok || !s.requireEmptyBody(w, r) {
return
}
if err := s.backups.Restore(r.Context(), actor.ID, r.PathValue("id"), r.PathValue("backupID")); err != nil {
s.backupProblem(w, err)
return
}
s.apiJSON(w, http.StatusOK, map[string]string{"state": "restored"})
}
func (s *server) backupPolicyGet(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireInstancePermission(w, r, authorization.PermissionBackupList); !ok {
return
}
value, err := s.backups.GetPolicy(r.Context(), r.PathValue("id"))
if err != nil {
s.backupProblem(w, err)
return
}
s.apiJSON(w, http.StatusOK, value)
}
func (s *server) backupPolicySet(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireInstancePermission(w, r, authorization.PermissionInstanceConfigure); !ok {
return
}
var request struct {
Enabled bool `json:"enabled"`
CronExpression string `json:"cron_expression"`
Timezone string `json:"timezone"`
RetentionCount int `json:"retention_count"`
}
if !s.decodeAPIJSON(w, r, &request) {
return
}
value, err := s.backups.SetPolicy(r.Context(), backup.Policy{InstanceID: r.PathValue("id"), Enabled: request.Enabled, CronExpression: request.CronExpression, Timezone: request.Timezone, RetentionCount: request.RetentionCount})
if err != nil {
s.backupProblem(w, err)
return
}
s.apiJSON(w, http.StatusOK, value)
}
func (s *server) backupProblem(w http.ResponseWriter, err error) {
status, code := http.StatusInternalServerError, "backup_failed"
switch {
case errors.Is(err, backup.ErrNotFound), errors.Is(err, instance.ErrInstanceNotFound):
status, code = http.StatusNotFound, "backup_not_found"
case errors.Is(err, backup.ErrInvalidInput), errors.Is(err, backup.ErrIncompatible):
status, code = http.StatusUnprocessableEntity, "invalid_backup"
case errors.Is(err, backup.ErrInvalidState), errors.Is(err, instance.ErrOperationConflict):
status, code = http.StatusConflict, "backup_conflict"
case errors.Is(err, backup.ErrIntegrity):
status, code = http.StatusUnprocessableEntity, "backup_integrity_failed"
}
s.apiProblem(w, status, code, "The backup operation could not be completed.")
}
func (s *server) importCreate(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAPIUser(w, r, true)
if !ok {
return
}
if err := s.permissions.RequireRecentAdmin(actor); err != nil {
s.authorizationProblem(w, err)
return
}
templateID, version, format := r.URL.Query().Get("template_id"), r.URL.Query().Get("template_version"), r.URL.Query().Get("format")
snapshot, err := s.repository.Get(r.Context(), templateID, version)
if err != nil {
s.apiProblem(w, http.StatusNotFound, "template_not_found", "The template version was not found.")
return
}
if !snapshot.Template.Imports.Supported {
s.apiProblem(w, http.StatusUnprocessableEntity, "import_unsupported", "The template does not support imports.")
return
}
maximum := int64(snapshot.Template.Imports.MaxExtractedSizeGB) << 30
r.Body = http.MaxBytesReader(w, r.Body, maximum+1)
value, err := s.imports.Stage(r.Context(), actor.ID, format, r.Body, importexport.Policy{TemplateID: templateID, TemplateVersion: version, AcceptedFormats: snapshot.Template.Imports.AcceptedFormats, MaxExpandedBytes: maximum, RequiredPaths: snapshot.Template.Imports.RequiredPaths})
if err != nil {
status, code := http.StatusUnprocessableEntity, "invalid_import"
if errors.Is(err, importexport.ErrLimitExceeded) {
status, code = http.StatusRequestEntityTooLarge, "import_limit_exceeded"
}
s.apiProblem(w, status, code, "The import could not be validated.")
return
}
s.apiJSON(w, http.StatusCreated, value)
}
func (s *server) requireInstancePermission(w http.ResponseWriter, r *http.Request, permission string) (auth.User, bool) {
user, ok := s.requireAPIUser(w, r, false)
if !ok {
return auth.User{}, false
}
if err := s.permissions.Require(r.Context(), user, r.PathValue("id"), permission); err != nil {
s.authorizationProblem(w, err)
return auth.User{}, false
}
return user, true
}
func (s *server) userList(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAPIUser(w, r, true); !ok {
return
}
users, err := s.auth.ListUsers(r.Context())
if err != nil {
s.apiProblem(w, http.StatusInternalServerError, "users_unavailable", "The users are unavailable.")
return
}
s.apiJSON(w, http.StatusOK, map[string]any{"users": users})
}
func (s *server) userCreate(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAPIUser(w, r, true)
if !ok {
return
}
if err := s.permissions.RequireRecentAdmin(actor); err != nil {
s.authorizationProblem(w, err)
return
}
var request struct {
Username string `json:"username"`
Password string `json:"password"`
Role string `json:"role"`
}
if !s.decodeAPIJSON(w, r, &request) {
return
}
user, err := s.auth.CreateUser(r.Context(), request.Username, request.Password, request.Role)
if err != nil {
s.apiProblem(w, http.StatusUnprocessableEntity, "invalid_user", "The user could not be created.")
return
}
s.apiJSON(w, http.StatusCreated, user)
}
func (s *server) membershipList(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAPIUser(w, r, true)
if !ok {
return
}
memberships, err := s.permissions.ListMemberships(r.Context(), actor, r.PathValue("id"))
if err != nil {
s.authorizationProblem(w, err)
return
}
s.apiJSON(w, http.StatusOK, map[string]any{"memberships": memberships})
}
func (s *server) membershipSet(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAPIUser(w, r, true)
if !ok {
return
}
var request struct {
Role string `json:"role"`
}
if !s.decodeAPIJSON(w, r, &request) {
return
}
err := s.permissions.SetMembership(r.Context(), actor, r.PathValue("id"), r.PathValue("userID"), request.Role)
if err != nil {
s.authorizationProblem(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *server) membershipDelete(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAPIUser(w, r, true)
if !ok || !s.requireEmptyBody(w, r) {
return
}
if err := s.permissions.DeleteMembership(r.Context(), actor, r.PathValue("id"), r.PathValue("userID")); err != nil {
s.authorizationProblem(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *server) permissionOverrideSet(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAPIUser(w, r, true)
if !ok {
return
}
var request struct {
Effect string `json:"effect"`
}
if !s.decodeAPIJSON(w, r, &request) {
return
}
err := s.permissions.SetOverride(r.Context(), actor, r.PathValue("id"), r.PathValue("userID"), r.PathValue("permission"), request.Effect)
if err != nil {
s.authorizationProblem(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *server) permissionOverrideDelete(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAPIUser(w, r, true)
if !ok || !s.requireEmptyBody(w, r) {
return
}
if err := s.permissions.DeleteOverride(r.Context(), actor, r.PathValue("id"), r.PathValue("userID"), r.PathValue("permission")); err != nil {
s.authorizationProblem(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *server) installationRequestCreate(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAPIUser(w, r, false)
if !ok {
return
}
var request struct {
TemplateID string `json:"template_id"`
TemplateVersion string `json:"template_version"`
SuggestedName string `json:"suggested_name"`
PlayerEstimate int `json:"player_estimate"`
DesiredSchedule string `json:"desired_schedule"`
ModsRequested bool `json:"mods_requested"`
Message string `json:"message"`
}
if !s.decodeAPIJSON(w, r, &request) {
return
}
created, err := s.permissions.CreateInstallationRequest(r.Context(), actor, authorization.RequestInput{
ID: randomToken(), TemplateID: request.TemplateID, TemplateVersion: request.TemplateVersion,
SuggestedName: request.SuggestedName, PlayerEstimate: request.PlayerEstimate,
DesiredSchedule: request.DesiredSchedule, ModsRequested: request.ModsRequested, Message: request.Message,
})
if err != nil {
s.authorizationProblem(w, err)
return
}
s.apiJSON(w, http.StatusCreated, created)
}
func (s *server) installationRequestList(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAPIUser(w, r, false)
if !ok {
return
}
requests, err := s.permissions.ListInstallationRequests(r.Context(), actor)
if err != nil {
s.authorizationProblem(w, err)
return
}
s.apiJSON(w, http.StatusOK, map[string]any{"requests": requests})
}
func (s *server) installationRequestReview(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAPIUser(w, r, true)
if !ok {
return
}
var request struct {
Decision string `json:"decision"`
Reason string `json:"reason"`
}
if !s.decodeAPIJSON(w, r, &request) {
return
}
reviewed, err := s.permissions.ReviewInstallationRequest(r.Context(), actor, r.PathValue("id"), request.Decision, request.Reason)
if err != nil {
s.authorizationProblem(w, err)
return
}
s.apiJSON(w, http.StatusOK, reviewed)
}
func (s *server) decodeAPIJSON(w http.ResponseWriter, r *http.Request, target any) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
s.apiProblem(w, http.StatusBadRequest, "invalid_request", "The request is invalid.")
return false
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
s.apiProblem(w, http.StatusBadRequest, "invalid_request", "The request is invalid.")
return false
}
return true
}
func (s *server) requireEmptyBody(w http.ResponseWriter, r *http.Request) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
body, err := io.ReadAll(r.Body)
if err != nil || len(bytes.TrimSpace(body)) != 0 {
s.apiProblem(w, http.StatusBadRequest, "invalid_request", "The request is invalid.")
return false
}
return true
}
func (s *server) authorizationProblem(w http.ResponseWriter, err error) {
status, code := http.StatusInternalServerError, "authorization_failed"
switch {
case errors.Is(err, authorization.ErrDenied):
status, code = http.StatusForbidden, "permission_denied"
case errors.Is(err, authorization.ErrRecentAuth):
status, code = http.StatusForbidden, "reauthentication_required"
case errors.Is(err, authorization.ErrInvalidInput):
status, code = http.StatusUnprocessableEntity, "invalid_request"
case errors.Is(err, authorization.ErrNotFound):
status, code = http.StatusNotFound, "not_found"
case errors.Is(err, authorization.ErrConflict):
status, code = http.StatusConflict, "conflict"
}
s.apiProblem(w, status, code, "The authorization request could not be completed.")
}
func (s *server) requireAPIUser(w http.ResponseWriter, r *http.Request, admin bool) (auth.User, bool) {
user, err := s.currentUser(r)
if err != nil {
s.apiProblem(w, http.StatusUnauthorized, "authentication_required", "Authentication is required.")
return auth.User{}, false
}
if admin && user.Role != "admin" {
s.apiProblem(w, http.StatusForbidden, "permission_denied", "Permission denied.")
return auth.User{}, false
}
if r.Method != http.MethodGet {
session, err := r.Cookie(sessionCookie)
if err != nil || !s.auth.ValidateCSRF(r.Context(), session.Value, r.Header.Get("X-CSRF-Token")) {
s.apiProblem(w, http.StatusForbidden, "csrf_failed", "Request verification failed.")
return auth.User{}, false
}
}
return user, true
}
func (s *server) apiJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func (s *server) apiProblem(w http.ResponseWriter, status int, code, message string) {
s.apiJSON(w, status, map[string]string{"code": code, "message": message})
}
func (s *server) setupForm(w http.ResponseWriter, r *http.Request) {
if !s.requireBootstrap(w, r, true) {
return
}
token := s.anonymousCSRF(w, r)
s.render(w, http.StatusOK, "setup.html", pageData{Title: message("setup.title"), CSRFToken: token})
}
func (s *server) setupSubmit(w http.ResponseWriter, r *http.Request) {
if !s.requireBootstrap(w, r, true) {
return
}
if !s.parseForm(w, r) || !validAnonymousCSRF(r) {
s.problem(w, http.StatusForbidden, message("error.csrf"))
return
}
err := s.auth.BootstrapAdmin(r.Context(), r.FormValue("username"), r.FormValue("password"))
if err != nil {
if errors.Is(err, auth.ErrBootstrapComplete) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
token := s.anonymousCSRF(w, r)
s.render(w, http.StatusUnprocessableEntity, "setup.html", pageData{Title: message("setup.title"), CSRFToken: token, Error: err.Error()})
return
}
clearCookie(w, csrfCookie)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (s *server) loginForm(w http.ResponseWriter, r *http.Request) {
if !s.requireBootstrap(w, r, false) {
return
}
if _, err := s.currentUser(r); err == nil {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
token := s.anonymousCSRF(w, r)
s.render(w, http.StatusOK, "login.html", pageData{Title: message("login.title"), CSRFToken: token})
}
func (s *server) loginSubmit(w http.ResponseWriter, r *http.Request) {
if !s.requireBootstrap(w, r, false) {
return
}
if !s.parseForm(w, r) || !validAnonymousCSRF(r) {
s.problem(w, http.StatusForbidden, message("error.csrf"))
return
}
session, err := s.auth.Login(r.Context(), r.FormValue("username"), r.FormValue("password"), r.RemoteAddr)
if err != nil {
status := http.StatusUnauthorized
if errors.Is(err, auth.ErrRateLimited) {
status = http.StatusTooManyRequests
w.Header().Set("Retry-After", "60")
}
token := s.anonymousCSRF(w, r)
s.render(w, status, "login.html", pageData{Title: message("login.title"), CSRFToken: token, Error: message("error.credentials")})
return
}
if cookie, cookieErr := r.Cookie(sessionCookie); cookieErr == nil {
if revokeErr := s.auth.Revoke(r.Context(), cookie.Value); revokeErr != nil {
_ = s.auth.Revoke(r.Context(), session.Token)
s.logger.Error("session rotation failed", "event", "auth.session.rotation.failed", "error", revokeErr)
s.problem(w, http.StatusInternalServerError, message("error.internal"))
return
}
}
setCookie(w, sessionCookie, session.Token, session.ExpiresAt, true)
setCookie(w, csrfCookie, session.CSRFToken, session.ExpiresAt, true)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *server) logout(w http.ResponseWriter, r *http.Request) {
if !s.requireBootstrap(w, r, false) {
return
}
session, err := r.Cookie(sessionCookie)
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if !s.parseForm(w, r) || !s.auth.ValidateCSRF(r.Context(), session.Value, r.FormValue("csrf_token")) {
s.problem(w, http.StatusForbidden, message("error.csrf"))
return
}
if err := s.auth.Revoke(r.Context(), session.Value); err != nil {
s.logger.Error("session revocation failed", "event", "auth.logout.failed", "error", err)
s.problem(w, http.StatusInternalServerError, message("error.internal"))
return
}
clearCookie(w, sessionCookie)
clearCookie(w, csrfCookie)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (s *server) home(w http.ResponseWriter, r *http.Request) {
if !s.requireBootstrap(w, r, false) {
return
}
user, err := s.currentUser(r)
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
csrf, err := r.Cookie(csrfCookie)
if err != nil {
s.problem(w, http.StatusForbidden, message("error.csrf"))
return
}
data := pageData{Title: message("dashboard.title"), User: user, CSRFToken: csrf.Value, IsAdmin: user.Role == "admin"}
if data.IsAdmin && s.repository != nil {
if configured, ok := s.repository.(instance.ConfigurationRepository); ok {
if labels, labelErr := configured.GetGlobalLabels(r.Context()); labelErr == nil {
data.GlobalLabels = instance.FormatLabels(labels)
}
}
}
s.render(w, http.StatusOK, "home.html", data)
}
func (s *server) currentUser(r *http.Request) (auth.User, error) {
cookie, err := r.Cookie(sessionCookie)
if err != nil {
return auth.User{}, auth.ErrInvalidSession
}
return s.auth.Authenticate(r.Context(), cookie.Value)
}
func (s *server) requireBootstrap(w http.ResponseWriter, r *http.Request, setupRoute bool) bool {
required, err := s.auth.BootstrapRequired(r.Context())
if err != nil {
s.logger.Error("bootstrap state failed", "event", "bootstrap.state.failed", "error", err)
s.problem(w, http.StatusInternalServerError, message("error.internal"))
return false
}
if required != setupRoute {
if required {
http.Redirect(w, r, "/setup", http.StatusSeeOther)
} else {
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
return false
}
return true
}
func (s *server) anonymousCSRF(w http.ResponseWriter, r *http.Request) string {
if cookie, err := r.Cookie(csrfCookie); err == nil && cookie.Value != "" {
return cookie.Value
}
token := randomToken()
setCookie(w, csrfCookie, token, time.Now().Add(time.Hour), true)
return token
}
func validAnonymousCSRF(r *http.Request) bool {
cookie, err := r.Cookie(csrfCookie)
provided := r.FormValue("csrf_token")
if err != nil || cookie.Value == "" || provided == "" || len(cookie.Value) != len(provided) {
return false
}
return subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(provided)) == 1
}
func (s *server) parseForm(w http.ResponseWriter, r *http.Request) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
if err := r.ParseForm(); err != nil {
s.problem(w, http.StatusBadRequest, message("error.form"))
return false
}
return true
}
func (s *server) render(w http.ResponseWriter, status int, name string, data pageData) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
s.logger.Error("template rendering failed", "event", "http.render.failed", "template", name, "error", err)
}
}
func (s *server) stylesheet(w http.ResponseWriter, _ *http.Request) {
body, err := assets.Open("static/app.v1.css")
if err != nil {
http.Error(w, "Not found.", http.StatusNotFound)
return
}
defer body.Close()
w.Header().Set("Content-Type", "text/css; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
_, _ = io.Copy(w, body)
}
func (s *server) problem(w http.ResponseWriter, status int, message string) {
http.Error(w, message, status)
}
func (s *server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
next.ServeHTTP(w, r)
})
}
func setCookie(w http.ResponseWriter, name, value string, expires time.Time, httpOnly bool) {
http.SetCookie(w, &http.Cookie{Name: name, Value: value, Path: "/", Expires: expires, MaxAge: int(time.Until(expires).Seconds()), HttpOnly: httpOnly, Secure: true, SameSite: http.SameSiteStrictMode})
}
func clearCookie(w http.ResponseWriter, name string) {
http.SetCookie(w, &http.Cookie{Name: name, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode})
}
func randomToken() string {
value := make([]byte, 32)
if _, err := rand.Read(value); err != nil {
panic("crypto/rand failed: " + err.Error())
}
return base64.RawURLEncoding.EncodeToString(value)
}
func message(key string) string {
if value, ok := englishMessages[key]; ok {
return value
}
return key
}