1240 lines
45 KiB
Go
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
|
|
}
|