2524 lines
95 KiB
Go
2524 lines
95 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"
|
|
"fmt"
|
|
"html/template"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
|
|
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/audit"
|
|
"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"
|
|
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/notification"
|
|
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/templaterepo"
|
|
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/webaccess"
|
|
)
|
|
|
|
const (
|
|
sessionCookie = "dogama_session"
|
|
csrfCookie = "dogama_csrf"
|
|
maxFormBytes = 64 << 10
|
|
maxDeploymentUploadBytes = 256 << 20
|
|
)
|
|
|
|
//go:embed templates/*.html static/*
|
|
var assets embed.FS
|
|
|
|
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
|
|
audit *audit.Service
|
|
auditLocation *time.Location
|
|
notifications *notification.Service
|
|
catalogScan func(context.Context) (catalog.ScanResult, error)
|
|
serversRoot string
|
|
moduleRuntime moduleRuntime
|
|
}
|
|
|
|
// moduleRuntime is the narrow integration boundary used by SSR handlers. It
|
|
// keeps HTTP unaware of WASM details while allowing the capability states to
|
|
// be exercised without a live game API.
|
|
type moduleRuntime interface {
|
|
Live(context.Context, instance.StoredInstance, bool) instance.Live
|
|
ListBans(context.Context, instance.StoredInstance) ([]instance.Ban, error)
|
|
Action(context.Context, instance.StoredInstance, string, string, any) error
|
|
}
|
|
type completeHandler struct {
|
|
http.Handler
|
|
server *server
|
|
}
|
|
|
|
type repository interface {
|
|
catalog.Repository
|
|
instance.Repository
|
|
instance.LifecycleRepository
|
|
authorization.Repository
|
|
templaterepo.Repository
|
|
}
|
|
|
|
type pageData struct {
|
|
Title string
|
|
Language string
|
|
Languages []languageOption
|
|
RequireHTTPS bool
|
|
CanonicalURL string
|
|
CSRFToken string
|
|
Error string
|
|
User auth.User
|
|
GlobalLabels string
|
|
GameContainerUID uint32
|
|
GameContainerGID uint32
|
|
IsAdmin bool
|
|
Channels []notification.Channel
|
|
NotificationPreferences map[string]bool
|
|
NotificationLanguage string
|
|
AuditEvents []auditEventView
|
|
AuditPolicy audit.Policy
|
|
AuditFilter auditPageFilter
|
|
AuditActors []auditActorOption
|
|
AuditActions []string
|
|
AuditEventCount int
|
|
AuditPage, AuditPages int
|
|
AuditQuery string
|
|
ActivePage string
|
|
Instances []instance.StoredInstance
|
|
Users []auth.User
|
|
UserAccess map[string]map[string]authorization.Membership
|
|
Permissions []string
|
|
StatusCounts map[string]int
|
|
RecentActivity []dashboardActivity
|
|
SystemStatus dashboardSystemStatus
|
|
Catalog []catalog.Summary
|
|
CatalogDetail *catalog.Template
|
|
CatalogScan *catalog.ScanResult
|
|
CatalogScanner bool
|
|
TemplateRepositories []templaterepo.Entry
|
|
TemplateRepositoryInput templaterepo.Input
|
|
Deployment *deploymentPage
|
|
InstanceDetail *instanceDetailPage
|
|
}
|
|
|
|
// instanceDetailPage deliberately contains only values that are safe to render.
|
|
// Runtime secrets and module credentials never become part of this view model.
|
|
type instanceDetailPage struct {
|
|
Instance instance.StoredInstance
|
|
Backups []backup.Backup
|
|
CanStart bool
|
|
CanStop bool
|
|
CanBackup bool
|
|
CanUpdate bool
|
|
CanRestore bool
|
|
Busy bool
|
|
PasswordSet bool
|
|
MaxPlayers string
|
|
Message string
|
|
MessageError bool
|
|
ModuleAvailable bool
|
|
Live instance.Live
|
|
CanAnnounce bool
|
|
CanKick bool
|
|
CanBan bool
|
|
CanUnban bool
|
|
BansAvailable bool
|
|
BansUnavailable bool
|
|
UpdateStatus instance.UpdateStatus
|
|
UpdateRequest instance.UpdateRequest
|
|
}
|
|
|
|
type deploymentPage struct {
|
|
Template *catalog.Template
|
|
Values map[string]string
|
|
Error string
|
|
Success bool
|
|
}
|
|
|
|
type dashboardActivity struct {
|
|
Action string
|
|
Outcome string
|
|
Actor string
|
|
Instance string
|
|
Game string
|
|
Occurred time.Time
|
|
Time string
|
|
Language string
|
|
}
|
|
|
|
func (d dashboardActivity) Msg(key string) string { return localized(d.Language, key) }
|
|
|
|
func (d dashboardActivity) Icon() string {
|
|
switch d.Action {
|
|
case "instance.start":
|
|
return "play"
|
|
case "instance.stop":
|
|
return "stop"
|
|
case "instance.restart", "instance.update":
|
|
return "restart"
|
|
default:
|
|
return "clock"
|
|
}
|
|
}
|
|
|
|
type dashboardSystemStatus struct {
|
|
AgentOnline bool
|
|
AgentAvailable bool
|
|
DatabaseHealthy bool
|
|
DatabaseAvailable bool
|
|
StorageAvailable bool
|
|
StorageUsed uint64
|
|
StorageTotal uint64
|
|
LatestBackup time.Time
|
|
BackupAvailable bool
|
|
AuditRetention int
|
|
AuditAvailable 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)
|
|
}
|
|
|
|
// NewHandlerComplete enables the milestone-nine administration services.
|
|
func NewHandlerComplete(authService *auth.Service, repository repository, lifecycle *instance.LifecycleService, backupService *backup.Service, importService *importexport.Service, auditService *audit.Service, notificationService *notification.Service, logger *slog.Logger) (http.Handler, error) {
|
|
return newHandlerServices(authService, repository, lifecycle, backupService, importService, auditService, notificationService, logger)
|
|
}
|
|
|
|
// NewHandlerCompleteWithCatalog adds the local-template scanner used by the Catalog UI.
|
|
func NewHandlerCompleteWithCatalog(authService *auth.Service, repository repository, lifecycle *instance.LifecycleService, backupService *backup.Service, importService *importexport.Service, auditService *audit.Service, notificationService *notification.Service, scanner func(context.Context) (catalog.ScanResult, error), logger *slog.Logger) (http.Handler, error) {
|
|
return newHandlerServicesWithCatalog(authService, repository, lifecycle, backupService, importService, auditService, notificationService, scanner, logger)
|
|
}
|
|
|
|
// NewHandlerCompleteWithCatalogAndDeployment wires the server-data root into
|
|
// the SSR deployment flow. The root is never accepted from a browser.
|
|
func NewHandlerCompleteWithCatalogAndDeployment(authService *auth.Service, repository repository, lifecycle *instance.LifecycleService, backupService *backup.Service, importService *importexport.Service, auditService *audit.Service, notificationService *notification.Service, scanner func(context.Context) (catalog.ScanResult, error), serversRoot string, logger *slog.Logger) (http.Handler, error) {
|
|
h, err := newHandlerServicesWithCatalog(authService, repository, lifecycle, backupService, importService, auditService, notificationService, scanner, logger)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// The handler is private to this package; set the canonical configured root
|
|
// only after construction to retain compatibility with API-only constructors.
|
|
if concrete, ok := h.(*completeHandler); ok {
|
|
concrete.server.serversRoot = filepath.Clean(serversRoot)
|
|
}
|
|
return h, nil
|
|
}
|
|
|
|
// NewHandlerCompleteWithCatalogDeploymentAndRuntime adds the validated WASM
|
|
// integration facade to the SSR server without exposing it to HTTP handlers.
|
|
func NewHandlerCompleteWithCatalogDeploymentAndRuntime(authService *auth.Service, repository repository, lifecycle *instance.LifecycleService, backupService *backup.Service, importService *importexport.Service, auditService *audit.Service, notificationService *notification.Service, scanner func(context.Context) (catalog.ScanResult, error), serversRoot string, runtime moduleRuntime, logger *slog.Logger) (http.Handler, error) {
|
|
h, err := NewHandlerCompleteWithCatalogAndDeployment(authService, repository, lifecycle, backupService, importService, auditService, notificationService, scanner, serversRoot, logger)
|
|
if err == nil {
|
|
h.(*completeHandler).server.moduleRuntime = runtime
|
|
}
|
|
return h, err
|
|
}
|
|
|
|
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) {
|
|
return newHandlerServices(authService, repository, lifecycle, backupService, importService, nil, nil, logger)
|
|
}
|
|
|
|
func newHandlerServices(authService *auth.Service, repository repository, lifecycle *instance.LifecycleService, backupService *backup.Service, importService *importexport.Service, auditService *audit.Service, notificationService *notification.Service, logger *slog.Logger) (http.Handler, error) {
|
|
return newHandlerServicesWithCatalog(authService, repository, lifecycle, backupService, importService, auditService, notificationService, nil, logger)
|
|
}
|
|
|
|
func newHandlerServicesWithCatalog(authService *auth.Service, repository repository, lifecycle *instance.LifecycleService, backupService *backup.Service, importService *importexport.Service, auditService *audit.Service, notificationService *notification.Service, scanner func(context.Context) (catalog.ScanResult, error), logger *slog.Logger) (http.Handler, error) {
|
|
templates, err := template.New("views").Funcs(template.FuncMap{"msg": message, "statusClass": statusClass, "statusLabel": statusLabel, "activityLabel": activityLabel, "relativeTime": relativeTime, "storagePercent": storagePercent, "formatBytes": formatBytes, "plus": func(a, b int) int { return a + b }, "minus": func(a, b int) int { return a - b }}).ParseFS(assets, "templates/*.html")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
location, locationErr := auditDisplayLocation(os.Getenv("TZ"))
|
|
if locationErr != nil {
|
|
logger.Warn("invalid audit display timezone; using UTC", "timezone", os.Getenv("TZ"), "event", "audit.timezone.invalid")
|
|
}
|
|
s := &server{auth: authService, templates: templates, logger: logger, repository: repository, lifecycle: lifecycle, backups: backupService, imports: importService, audit: auditService, auditLocation: location, notifications: notificationService, catalogScan: scanner}
|
|
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 /public/game-artwork/{gameID}", s.publicGameArtwork)
|
|
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("PUT /api/v1/admin/users/{id}", s.userUpdate)
|
|
mux.HandleFunc("GET /api/v1/admin/game-container-labels", s.globalLabelsGet)
|
|
mux.HandleFunc("PUT /api/v1/admin/game-container-labels", s.globalLabelsPut)
|
|
if auditService != nil {
|
|
mux.HandleFunc("GET /api/v1/admin/audit", s.auditList)
|
|
mux.HandleFunc("GET /api/v1/admin/audit-policy", s.auditPolicyGet)
|
|
mux.HandleFunc("PUT /api/v1/admin/audit-policy", s.auditPolicyPut)
|
|
mux.HandleFunc("POST /api/v1/admin/audit/purge", s.auditPurge)
|
|
}
|
|
if notificationService != nil {
|
|
mux.HandleFunc("GET /api/v1/admin/notification-channels", s.notificationList)
|
|
mux.HandleFunc("POST /api/v1/admin/notification-channels", s.notificationCreate)
|
|
mux.HandleFunc("PUT /api/v1/admin/notification-channels/{id}", s.notificationUpdate)
|
|
mux.HandleFunc("DELETE /api/v1/admin/notification-channels/{id}", s.notificationDelete)
|
|
mux.HandleFunc("POST /api/v1/admin/notification-channels/{id}/test", s.notificationTest)
|
|
}
|
|
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/operations/{operationID}", s.operationProgress)
|
|
mux.HandleFunc("GET /api/v1/instances/{id}/diagnostics", s.instanceDiagnostics)
|
|
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)
|
|
mux.HandleFunc("GET /api/v1/instances/{id}/configuration-revisions", s.configurationRevisionList)
|
|
mux.HandleFunc("POST /api/v1/instances/{id}/configuration-revisions/{revision}/rollback", s.configurationRevisionRollback)
|
|
mux.HandleFunc("GET /api/v1/instances/{id}/mods", s.instanceModsGet)
|
|
mux.HandleFunc("PUT /api/v1/instances/{id}/mods", s.instanceModsPut)
|
|
mux.HandleFunc("POST /api/v1/instances/{id}/update/preview", s.instanceUpdatePreview)
|
|
mux.HandleFunc("POST /api/v1/instances/{id}/update", s.instanceUpdate)
|
|
}
|
|
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.css", s.stylesheet)
|
|
mux.HandleFunc("GET /static/mobile-fixes.css", s.mobileStylesheet)
|
|
mux.HandleFunc("GET /static/theme.css", s.themeStylesheet)
|
|
mux.HandleFunc("GET /static/app.js", s.javascript)
|
|
mux.HandleFunc("GET /static/dogama-logo.png", s.logo)
|
|
mux.HandleFunc("GET /static/dogama.png", s.brandLogo)
|
|
mux.HandleFunc("GET /static/dogama-brand-banner.png", s.brandBanner)
|
|
mux.HandleFunc("GET /static/icons.svg", s.icons)
|
|
mux.HandleFunc("GET /favicon.ico", s.favicon)
|
|
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("POST /admin/web-access", s.webAccessForm)
|
|
mux.HandleFunc("POST /admin/game-container-runtime", s.gameContainerRuntimeForm)
|
|
mux.HandleFunc("POST /admin/notification-channels", s.notificationForm)
|
|
mux.HandleFunc("POST /admin/notification-language", s.notificationLanguageForm)
|
|
mux.HandleFunc("POST /admin/notification-channels/{id}/test", s.notificationTestForm)
|
|
mux.HandleFunc("POST /admin/notification-channels/{id}/delete", s.notificationDeleteForm)
|
|
mux.HandleFunc("POST /admin/audit-policy", s.auditPolicyForm)
|
|
mux.HandleFunc("POST /admin/audit-purge", s.auditPurgeForm)
|
|
mux.HandleFunc("POST /administration/template-repositories", s.templateRepositoryCreateForm)
|
|
mux.HandleFunc("POST /administration/template-repositories/{id}/delete", s.templateRepositoryDeleteForm)
|
|
mux.HandleFunc("GET /audit", s.auditPage)
|
|
mux.HandleFunc("GET /catalog", s.catalogPage)
|
|
mux.HandleFunc("POST /catalog/scan", s.catalogScanForm)
|
|
mux.HandleFunc("GET /catalog/{id}", s.catalogDetailPage)
|
|
mux.HandleFunc("GET /catalog/{id}/deploy", s.deploymentPage)
|
|
mux.HandleFunc("POST /catalog/{id}/deploy", s.deploymentSubmit)
|
|
mux.HandleFunc("GET /account", s.accountPage)
|
|
mux.HandleFunc("POST /account/notifications", s.accountNotificationsForm)
|
|
mux.HandleFunc("POST /account/email", s.accountEmailForm)
|
|
mux.HandleFunc("POST /account/password", s.accountPasswordForm)
|
|
mux.HandleFunc("POST /account/language", s.accountLanguageForm)
|
|
mux.HandleFunc("GET /administration", s.settingsPage)
|
|
mux.HandleFunc("GET /administration/users", s.usersPage)
|
|
mux.HandleFunc("GET /administration/template-repositories", s.templateRepositoriesPage)
|
|
mux.HandleFunc("POST /administration/users", s.userCreateForm)
|
|
mux.HandleFunc("POST /administration/users/{id}", s.userUpdateForm)
|
|
mux.HandleFunc("POST /administration/users/{id}/memberships/{instanceID}", s.userMembershipForm)
|
|
mux.HandleFunc("POST /administration/users/{id}/memberships/{instanceID}/permissions/{permission}", s.userPermissionForm)
|
|
mux.HandleFunc("GET /instances/{id}", s.instanceDetailPage)
|
|
mux.HandleFunc("POST /instances/{id}/start", s.instanceDetailActionForm)
|
|
mux.HandleFunc("POST /instances/{id}/stop", s.instanceDetailActionForm)
|
|
mux.HandleFunc("POST /instances/{id}/backup", s.instanceDetailActionForm)
|
|
mux.HandleFunc("POST /instances/{id}/update", s.instanceDetailUpdateForm)
|
|
mux.HandleFunc("POST /instances/{id}/backups/{backupID}/restore", s.instanceDetailRestoreForm)
|
|
mux.HandleFunc("POST /instances/{id}/module/announcement", s.instanceModuleActionForm)
|
|
mux.HandleFunc("POST /instances/{id}/module/kick", s.instanceModuleActionForm)
|
|
mux.HandleFunc("POST /instances/{id}/module/ban", s.instanceModuleActionForm)
|
|
mux.HandleFunc("POST /instances/{id}/module/unban", s.instanceModuleActionForm)
|
|
mux.HandleFunc("POST /instances/{id}/password/reveal", s.instancePasswordRevealForm)
|
|
mux.HandleFunc("GET /", s.home)
|
|
return &completeHandler{Handler: s.enforceWebAccess(s.securityHeaders(s.auditRequests(mux))), server: s}, nil
|
|
}
|
|
|
|
type auditResponseWriter struct {
|
|
http.ResponseWriter
|
|
status int
|
|
}
|
|
|
|
func (w *auditResponseWriter) WriteHeader(status int) {
|
|
if w.status == 0 {
|
|
w.status = status
|
|
}
|
|
w.ResponseWriter.WriteHeader(status)
|
|
}
|
|
func (w *auditResponseWriter) Write(body []byte) (int, error) {
|
|
if w.status == 0 {
|
|
w.status = http.StatusOK
|
|
}
|
|
return w.ResponseWriter.Write(body)
|
|
}
|
|
|
|
func (s *server) auditRequests(next http.Handler) http.Handler {
|
|
if s.audit == nil {
|
|
return next
|
|
}
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
action := auditAction(r.Method, r.URL.Path)
|
|
if action == "" {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
actor, _ := s.currentUser(r)
|
|
instanceID := auditTargetID(r)
|
|
instanceSummary := s.auditInstanceSummary(r.Context(), instanceID)
|
|
wrapped := &auditResponseWriter{ResponseWriter: w}
|
|
next.ServeHTTP(wrapped, r)
|
|
status := wrapped.status
|
|
if status == 0 {
|
|
status = http.StatusOK
|
|
}
|
|
outcome := "allowed"
|
|
if status == http.StatusUnauthorized || status == http.StatusForbidden {
|
|
outcome = "denied"
|
|
} else if status >= 400 {
|
|
outcome = "failed"
|
|
}
|
|
summary := mergeAuditInstanceSummary(map[string]string{}, instanceSummary)
|
|
if instanceID != "" {
|
|
summary["target_id"] = instanceID
|
|
}
|
|
storedInstanceID := ""
|
|
if status < 400 && (strings.HasPrefix(r.URL.Path, "/api/v1/instances/") || strings.HasPrefix(r.URL.Path, "/instances/")) {
|
|
storedInstanceID = instanceID
|
|
}
|
|
if action == "instance.delete" {
|
|
storedInstanceID = ""
|
|
}
|
|
_ = s.audit.Record(r.Context(), audit.Event{ActorID: actor.ID, ActorLabel: actor.Username, InstanceID: storedInstanceID, Action: action, Outcome: outcome, Summary: summary})
|
|
})
|
|
}
|
|
|
|
func (s *server) auditInstanceSummary(ctx context.Context, id string) map[string]string {
|
|
if id == "" || s.repository == nil {
|
|
return nil
|
|
}
|
|
value, err := s.repository.GetInstance(ctx, id)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return map[string]string{"game_name": value.Preview.Game.Name, "instance_name": value.Preview.DisplayName, "instance_slug": value.Preview.Slug}
|
|
}
|
|
|
|
func mergeAuditInstanceSummary(summary, snapshot map[string]string) map[string]string {
|
|
if summary == nil {
|
|
summary = map[string]string{}
|
|
}
|
|
for key, value := range snapshot {
|
|
if value != "" {
|
|
summary[key] = value
|
|
}
|
|
}
|
|
return summary
|
|
}
|
|
|
|
func auditTargetID(r *http.Request) string {
|
|
if id := r.PathValue("id"); id != "" {
|
|
return id
|
|
}
|
|
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
|
for index, part := range parts[:len(parts)-1] {
|
|
if part == "instances" {
|
|
return parts[index+1]
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func auditAction(method, path string) string {
|
|
if method == http.MethodGet || strings.HasPrefix(path, "/api/v1/admin/audit") || strings.HasPrefix(path, "/api/v1/admin/notification") || strings.HasPrefix(path, "/admin/audit") || strings.HasPrefix(path, "/admin/notification") {
|
|
return ""
|
|
}
|
|
switch {
|
|
case method == http.MethodPost && (path == "/api/v1/admin/users" || path == "/administration/users"):
|
|
return "user.create"
|
|
case method == http.MethodPost && path == "/administration/template-repositories":
|
|
return "template_repository.create"
|
|
case method == http.MethodPost && strings.HasPrefix(path, "/administration/template-repositories/"):
|
|
return "template_repository.delete"
|
|
case (method == http.MethodPut && strings.HasPrefix(path, "/api/v1/admin/users/")) || (method == http.MethodPost && strings.HasPrefix(path, "/administration/users/") && !strings.Contains(path, "/memberships/")):
|
|
return "user.change"
|
|
case strings.Contains(path, "/memberships/") && strings.Contains(path, "/permissions/"):
|
|
return "permission.override.change"
|
|
case strings.Contains(path, "/memberships/"):
|
|
return "membership.change"
|
|
case strings.Contains(path, "installation-requests") && strings.HasSuffix(path, "/review"):
|
|
return "installation_request.review"
|
|
case strings.Contains(path, "/backups/") && strings.HasSuffix(path, "/restore"):
|
|
return "backup.restore"
|
|
case strings.HasSuffix(path, "/backups"):
|
|
return "backup.create"
|
|
case strings.HasPrefix(path, "/instances/") && strings.HasSuffix(path, "/backup"):
|
|
return "backup.create"
|
|
case strings.HasPrefix(path, "/instances/") && strings.Contains(path, "/backups/") && strings.HasSuffix(path, "/restore"):
|
|
return "backup.restore"
|
|
case strings.HasSuffix(path, "/module/announcement"):
|
|
return "announcement.send"
|
|
case strings.HasSuffix(path, "/module/kick"):
|
|
return "players.kick"
|
|
case strings.HasSuffix(path, "/module/ban"):
|
|
return "players.ban"
|
|
case strings.HasSuffix(path, "/module/unban"):
|
|
return "players.unban"
|
|
case strings.HasSuffix(path, "/password/reveal"):
|
|
return "instance.password.reveal"
|
|
case path == "/api/v1/imports":
|
|
return "import.create"
|
|
case strings.HasSuffix(path, "/update"):
|
|
return "instance.update"
|
|
case strings.Contains(path, "configuration-revisions") && strings.HasSuffix(path, "/rollback"):
|
|
return "configuration.rollback"
|
|
case strings.HasSuffix(path, "/start"):
|
|
return "instance.start"
|
|
case strings.HasSuffix(path, "/stop"):
|
|
return "instance.stop"
|
|
case strings.HasSuffix(path, "/restart"):
|
|
return "instance.restart"
|
|
case strings.HasSuffix(path, "/install"):
|
|
return "instance.create"
|
|
case strings.HasPrefix(path, "/api/v1/instances/") && method == http.MethodDelete:
|
|
return "instance.delete"
|
|
case strings.Contains(path, "container-configuration") || strings.HasSuffix(path, "/mods"):
|
|
return "instance.configuration.change"
|
|
case strings.Contains(path, "game-container-labels"):
|
|
return "security.configuration.change"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
var publicGameIDPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
|
|
|
func (s *server) publicGameIcon(w http.ResponseWriter, r *http.Request) {
|
|
s.publicGameAsset(w, r, "palworld/assets/icon.png", "image/png")
|
|
}
|
|
|
|
func (s *server) publicGameArtwork(w http.ResponseWriter, r *http.Request) {
|
|
s.publicGameAsset(w, r, "palworld/assets/banner.jpg", "image/jpeg")
|
|
}
|
|
|
|
func (s *server) publicGameAsset(w http.ResponseWriter, r *http.Request, assetPath, contentType string) {
|
|
gameID := r.PathValue("gameID")
|
|
if !publicGameIDPattern.MatchString(gameID) || gameID != "palworld" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
body, err := catalogdata.Files.ReadFile(assetPath)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", contentType)
|
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(body)
|
|
}
|
|
|
|
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, "/administration#containers", 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) configurationRevisionList(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := s.requireInstancePermission(w, r, authorization.PermissionInstanceView); !ok {
|
|
return
|
|
}
|
|
values, err := s.repository.(instance.ConfigurationRepository).ListConfigurationRevisions(r.Context(), r.PathValue("id"))
|
|
if err != nil {
|
|
s.lifecycleProblem(w, err)
|
|
return
|
|
}
|
|
s.apiJSON(w, 200, map[string]any{"revisions": values})
|
|
}
|
|
|
|
func (s *server) configurationRevisionRollback(w http.ResponseWriter, r *http.Request) {
|
|
actor, ok := s.requireInstancePermission(w, r, authorization.PermissionInstanceConfigure)
|
|
if !ok {
|
|
return
|
|
}
|
|
revision, err := strconv.Atoi(r.PathValue("revision"))
|
|
if err != nil || revision < 1 {
|
|
s.apiProblem(w, 422, "invalid_revision", "Revision must be positive.")
|
|
return
|
|
}
|
|
var request applicationModeRequest
|
|
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.RollbackConfiguration(r.Context(), r.PathValue("id"), revision, request.Apply == "immediate", actor.ID)
|
|
if err != nil {
|
|
s.apiProblem(w, 422, "configuration_rollback_failed", err.Error())
|
|
return
|
|
}
|
|
s.apiJSON(w, 200, result)
|
|
}
|
|
|
|
func (s *server) instanceModsGet(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, current.Preview.Mods)
|
|
}
|
|
|
|
func (s *server) instanceModsPut(w http.ResponseWriter, r *http.Request) {
|
|
actor, ok := s.requireInstancePermission(w, r, authorization.PermissionModsManage)
|
|
if !ok {
|
|
return
|
|
}
|
|
var request struct {
|
|
Items []string `json:"items"`
|
|
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.ConfigureMods(r.Context(), r.PathValue("id"), request.Items, request.Apply == "immediate", actor.ID)
|
|
if err != nil {
|
|
s.apiProblem(w, 422, "invalid_mod_configuration", err.Error())
|
|
return
|
|
}
|
|
s.apiJSON(w, 200, result)
|
|
}
|
|
|
|
func (s *server) instanceUpdatePreview(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := s.requireInstancePermission(w, r, authorization.PermissionInstanceUpdate); !ok {
|
|
return
|
|
}
|
|
var request instance.UpdateRequest
|
|
if !s.decodeStrictJSON(w, r, &request) {
|
|
return
|
|
}
|
|
current, err := s.repository.GetInstance(r.Context(), r.PathValue("id"))
|
|
if err != nil {
|
|
s.lifecycleProblem(w, err)
|
|
return
|
|
}
|
|
value, err := instance.PreviewUpdate(current, request)
|
|
if err != nil {
|
|
s.apiProblem(w, 422, "invalid_update", err.Error())
|
|
return
|
|
}
|
|
s.apiJSON(w, 200, value)
|
|
}
|
|
|
|
func (s *server) instanceUpdate(w http.ResponseWriter, r *http.Request) {
|
|
actor, ok := s.requireInstancePermission(w, r, authorization.PermissionInstanceUpdate)
|
|
if !ok {
|
|
return
|
|
}
|
|
var request instance.UpdateRequest
|
|
if !s.decodeStrictJSON(w, r, &request) {
|
|
return
|
|
}
|
|
if !request.Confirmed {
|
|
s.apiProblem(w, 422, "update_confirmation_required", "Update confirmation is required.")
|
|
return
|
|
}
|
|
current, err := s.repository.GetInstance(r.Context(), r.PathValue("id"))
|
|
if err != nil {
|
|
s.lifecycleProblem(w, err)
|
|
return
|
|
}
|
|
if current.Preview.UpdatePolicy.BackupBeforeUpdate {
|
|
if s.backups == nil {
|
|
s.apiProblem(w, 409, "backup_unavailable", "The required safety backup service is unavailable.")
|
|
return
|
|
}
|
|
if _, err := s.backups.Create(r.Context(), actor.ID, current.ID, "pre_update"); err != nil {
|
|
s.apiProblem(w, 422, "pre_update_backup_failed", "The safety backup failed; the update was not started.")
|
|
return
|
|
}
|
|
}
|
|
result, err := s.lifecycle.Update(r.Context(), current.ID, request, actor.ID)
|
|
if err != nil {
|
|
s.queueNotification(r, notification.Event{Type: "update.failed", Title: "Update failed", Message: "The instance update failed.", InstanceID: current.ID, InstanceName: current.Preview.DisplayName, Game: current.Preview.Game.Name, Image: current.Preview.Game.ArtworkURL, Action: "update", Severity: "error", Timestamp: time.Now().UTC()})
|
|
s.apiProblem(w, 422, "update_failed", err.Error())
|
|
return
|
|
}
|
|
s.queueNotification(r, notification.Event{Type: "update.completed", Title: "Update completed", Message: "The instance update completed.", InstanceID: current.ID, InstanceName: current.Preview.DisplayName, Game: current.Preview.Game.Name, Image: current.Preview.Game.ArtworkURL, Action: "update", Timestamp: time.Now().UTC()})
|
|
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)
|
|
}
|
|
|
|
// instanceDiagnostics is deliberately administrator-only. The operation
|
|
// history contains Docker details that must never be made available merely to
|
|
// an instance member.
|
|
func (s *server) instanceDiagnostics(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := s.requireAPIUser(w, r, false)
|
|
if !ok {
|
|
return
|
|
}
|
|
if user.Role != "admin" {
|
|
s.apiProblem(w, http.StatusForbidden, "diagnostics_forbidden", "Diagnostics are restricted to administrators.")
|
|
return
|
|
}
|
|
repository, ok := s.repository.(instance.DiagnosticRepository)
|
|
if !ok {
|
|
s.apiProblem(w, http.StatusServiceUnavailable, "diagnostics_unavailable", "Diagnostics are unavailable.")
|
|
return
|
|
}
|
|
if _, err := s.repository.GetInstance(r.Context(), r.PathValue("id")); err != nil {
|
|
s.lifecycleProblem(w, err)
|
|
return
|
|
}
|
|
history, err := repository.ListOperationHistory(r.Context(), r.PathValue("id"), 20)
|
|
if err != nil {
|
|
s.apiProblem(w, http.StatusInternalServerError, "diagnostics_unavailable", "Diagnostics are unavailable.")
|
|
return
|
|
}
|
|
s.apiJSON(w, http.StatusOK, map[string]any{"operations": history})
|
|
}
|
|
|
|
func (s *server) operationProgress(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := s.requireAPIUser(w, r, false)
|
|
if !ok {
|
|
return
|
|
}
|
|
repository, ok := s.repository.(instance.ProgressRepository)
|
|
if !ok {
|
|
s.apiProblem(w, http.StatusServiceUnavailable, "operations_unavailable", "Operations are unavailable.")
|
|
return
|
|
}
|
|
progress, err := repository.GetOperationProgress(r.Context(), r.PathValue("operationID"))
|
|
if errors.Is(err, instance.ErrInstanceNotFound) {
|
|
s.apiProblem(w, http.StatusNotFound, "operation_not_found", "The operation does not exist.")
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.apiProblem(w, http.StatusInternalServerError, "operations_unavailable", "Operations are unavailable.")
|
|
return
|
|
}
|
|
if s.permissions.Require(r.Context(), user, progress.InstanceID, authorization.PermissionInstanceView) != nil {
|
|
s.apiProblem(w, http.StatusForbidden, "permission_denied", "Permission denied.")
|
|
return
|
|
}
|
|
s.apiJSON(w, http.StatusOK, progress)
|
|
}
|
|
|
|
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
|
|
}
|
|
// Create a stopped container first. Imports are restored before configuration
|
|
// so form values deterministically win over imported INI files.
|
|
if _, err := s.lifecycle.Install(r.Context(), current.ID); 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
|
|
}
|
|
}
|
|
if err := s.applyDeploymentConfiguration(r.Context(), current.ID, current.Preview); err != nil {
|
|
s.apiProblem(w, http.StatusUnprocessableEntity, "configuration_apply_failed", "The configuration could not be applied.")
|
|
return
|
|
}
|
|
s.runLifecycleAction(w, r, s.lifecycle.Start)
|
|
}
|
|
|
|
func (s *server) instanceStart(w http.ResponseWriter, r *http.Request) {
|
|
s.lifecycleAction(w, r, authorization.PermissionInstanceStart, "start.completed", s.lifecycle.Start)
|
|
}
|
|
|
|
func (s *server) instanceStop(w http.ResponseWriter, r *http.Request) {
|
|
s.lifecycleAction(w, r, authorization.PermissionInstanceStop, "stop.completed", s.lifecycle.Stop)
|
|
}
|
|
|
|
func (s *server) instanceRestart(w http.ResponseWriter, r *http.Request) {
|
|
s.lifecycleAction(w, r, authorization.PermissionInstanceRestart, "start.completed", s.lifecycle.Restart)
|
|
}
|
|
|
|
func (s *server) lifecycleAction(w http.ResponseWriter, r *http.Request, permission, eventType string, action func(context.Context, string) (instance.OperationResult, error)) {
|
|
if _, ok := s.requireInstancePermission(w, r, permission); !ok {
|
|
return
|
|
}
|
|
s.runLifecycleAction(w, r, func(ctx context.Context, id string) (instance.OperationResult, error) {
|
|
result, err := action(ctx, id)
|
|
if err == nil {
|
|
s.queueNotification(r, notification.Event{Type: eventType, Title: "Instance lifecycle action completed", Message: "The requested instance action completed.", Action: strings.TrimSuffix(eventType, ".completed")})
|
|
}
|
|
return result, err
|
|
})
|
|
}
|
|
|
|
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 {
|
|
code := "DGM-DOCKER-001"
|
|
if strings.HasPrefix(err.Error(), "DGM-") {
|
|
code = strings.SplitN(err.Error(), ":", 2)[0]
|
|
}
|
|
s.queueNotification(r, notification.Event{Type: "start.failed", Title: "Instance lifecycle action failed", Message: "The instance operation failed. Code: " + code, Action: "start", OperationID: result.OperationID, Severity: "error"})
|
|
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"
|
|
if strings.HasPrefix(err.Error(), "DGM-") {
|
|
code = strings.SplitN(err.Error(), ":", 2)[0]
|
|
}
|
|
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.")
|
|
s.logger.Error("instance lifecycle failed", "event", "instance.lifecycle.failed", "error_code", code, "error", err)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
identity := instance.RuntimeIdentity{UID: instance.DefaultGameContainerUID, GID: instance.DefaultGameContainerGID}
|
|
if configured, ok := s.repository.(instance.ConfigurationRepository); ok {
|
|
identity, err = configured.GetGameContainerRuntimeIdentity(r.Context())
|
|
if err != nil {
|
|
s.apiProblem(w, http.StatusInternalServerError, "settings_unavailable", "The game-container runtime settings are unavailable.")
|
|
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), RuntimeIdentity: &identity,
|
|
})
|
|
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.queueNotification(r, notification.Event{Type: "backup.failed", Title: "Backup failed", Message: "The manual backup failed."})
|
|
s.backupProblem(w, err)
|
|
return
|
|
}
|
|
s.queueNotification(r, notification.Event{Type: "backup.completed", Title: "Backup completed", Message: "The manual backup completed.", OperationID: value.ID})
|
|
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 func() { _ = 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.queueNotification(r, notification.Event{Type: "restore.failed", Title: "Restore failed", Message: "The backup restore failed."})
|
|
s.backupProblem(w, err)
|
|
return
|
|
}
|
|
s.queueNotification(r, notification.Event{Type: "restore.completed", Title: "Restore completed", Message: "The backup restore completed."})
|
|
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"`
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
Role string `json:"role"`
|
|
Disabled bool `json:"disabled"`
|
|
}
|
|
if !s.decodeAPIJSON(w, r, &request) {
|
|
return
|
|
}
|
|
user, err := s.auth.CreateUserWithEmail(r.Context(), request.Username, request.Email, request.Password, request.Role, request.Disabled)
|
|
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) userUpdate(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 {
|
|
Email string `json:"email"`
|
|
Role string `json:"role"`
|
|
Disabled bool `json:"disabled"`
|
|
}
|
|
if !s.decodeAPIJSON(w, r, &request) {
|
|
return
|
|
}
|
|
if err := s.auth.UpdateUser(r.Context(), r.PathValue("id"), request.Email, request.Role, request.Disabled); err != nil {
|
|
status, code := http.StatusUnprocessableEntity, "invalid_user"
|
|
if errors.Is(err, auth.ErrLastAdmin) {
|
|
status, code = http.StatusConflict, "last_administrator"
|
|
}
|
|
s.apiProblem(w, status, code, "The user could not be updated.")
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
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.queueNotification(r, notification.Event{Type: "installation_request.required", Title: "Installation request submitted", Message: "An installation request requires administrator review."})
|
|
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.queueNotification(r, notification.Event{Type: "installation_request.completed", Title: "Installation request reviewed", Message: "An installation request was reviewed."})
|
|
s.apiJSON(w, http.StatusOK, reviewed)
|
|
}
|
|
|
|
func (s *server) queueNotification(r *http.Request, event notification.Event) {
|
|
if s.notifications == nil {
|
|
return
|
|
}
|
|
if event.Timestamp.IsZero() {
|
|
event.Timestamp = time.Now().UTC()
|
|
}
|
|
if event.InstanceID == "" && s.repository != nil {
|
|
if value, err := s.repository.GetInstance(r.Context(), r.PathValue("id")); err == nil {
|
|
event.InstanceID, event.InstanceName = value.ID, value.Preview.DisplayName
|
|
event.Game, event.Image = value.Preview.Game.Name, value.Preview.Game.ArtworkURL
|
|
}
|
|
}
|
|
if err := s.notifications.Queue(r.Context(), event); err != nil {
|
|
s.logger.Warn("notification queue failed", "event", "notification.queue.failed")
|
|
}
|
|
}
|
|
|
|
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)
|
|
language := s.language(r, "")
|
|
s.render(w, http.StatusOK, "setup.html", pageData{Title: localized(language, "setup.title"), Language: language, 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.BootstrapAdminWithLanguage(r.Context(), r.FormValue("username"), r.FormValue("email"), r.FormValue("password"), r.FormValue("language"))
|
|
if err != nil {
|
|
if errors.Is(err, auth.ErrBootstrapComplete) {
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
return
|
|
}
|
|
token := s.anonymousCSRF(w, r)
|
|
language := s.language(r, "")
|
|
s.render(w, http.StatusUnprocessableEntity, "setup.html", pageData{Title: localized(language, "setup.title"), Language: language, CSRFToken: token, Error: err.Error()})
|
|
return
|
|
}
|
|
s.clearCookie(w, r, 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)
|
|
language := s.language(r, "")
|
|
s.render(w, http.StatusOK, "login.html", pageData{Title: localized(language, "login.title"), Language: language, 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")
|
|
if s.audit != nil {
|
|
_ = s.audit.Record(r.Context(), audit.Event{ActorLabel: "anonymous", Action: "auth.login.blocked", Outcome: "denied", Summary: map[string]string{"reason_code": "rate_limited"}})
|
|
}
|
|
}
|
|
token := s.anonymousCSRF(w, r)
|
|
s.render(w, status, "login.html", pageData{Title: message("login.title"), CSRFToken: token, Error: message("error.credentials")})
|
|
return
|
|
}
|
|
if s.audit != nil {
|
|
if actor, actorErr := s.auth.Authenticate(r.Context(), session.Token); actorErr == nil {
|
|
_ = s.audit.Record(r.Context(), audit.Event{ActorID: actor.ID, ActorLabel: actor.Username, Action: "auth.login", Outcome: "allowed"})
|
|
}
|
|
}
|
|
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
|
|
}
|
|
}
|
|
s.setCookie(w, r, sessionCookie, session.Token, session.ExpiresAt, true)
|
|
s.setCookie(w, r, 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
|
|
}
|
|
s.clearCookie(w, r, sessionCookie)
|
|
s.clearCookie(w, r, csrfCookie)
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
}
|
|
|
|
func (s *server) home(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
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: localized(s.language(r, user.Language), "dashboard.title"), Language: s.language(r, user.Language), User: user, CSRFToken: csrf.Value, IsAdmin: user.Role == "admin", ActivePage: "dashboard", StatusCounts: map[string]int{}}
|
|
if s.repository != nil {
|
|
for _, current := range mustLifecycleInstances(r.Context(), s.repository) {
|
|
if !data.IsAdmin && s.permissions.Require(r.Context(), user, current.ID, authorization.PermissionInstanceView) != nil {
|
|
continue
|
|
}
|
|
data.Instances = append(data.Instances, current)
|
|
switch current.LifecycleState {
|
|
case "online", "running":
|
|
data.StatusCounts["running"]++
|
|
case "stopped", "draft":
|
|
data.StatusCounts["stopped"]++
|
|
case "update", "updating":
|
|
data.StatusCounts["updating"]++
|
|
case "error", "degraded", "intervention_required":
|
|
data.StatusCounts["error"]++
|
|
}
|
|
}
|
|
}
|
|
s.dashboardData(r.Context(), &data)
|
|
s.render(w, http.StatusOK, "home.html", data)
|
|
}
|
|
|
|
func (s *server) catalogPage(w http.ResponseWriter, r *http.Request) {
|
|
data, ok := s.catalogPageData(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
s.render(w, http.StatusOK, "catalog.html", data)
|
|
}
|
|
|
|
func (s *server) catalogDetailPage(w http.ResponseWriter, r *http.Request) {
|
|
data, ok := s.catalogPageData(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
id := r.PathValue("id")
|
|
for _, summary := range data.Catalog {
|
|
if summary.ID != id {
|
|
continue
|
|
}
|
|
snapshot, err := s.repository.Get(r.Context(), summary.ID, summary.Version)
|
|
if err != nil {
|
|
break
|
|
}
|
|
data.CatalogDetail = &snapshot.Template
|
|
s.render(w, http.StatusOK, "catalog-detail.html", data)
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
}
|
|
|
|
func (s *server) deploymentPage(w http.ResponseWriter, r *http.Request) {
|
|
data, snapshot, ok := s.deploymentData(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
values := map[string]string{}
|
|
for _, field := range snapshot.Template.Configuration.Fields {
|
|
if field.Default != nil && field.Type != "secret" {
|
|
values[field.ID] = fmt.Sprint(field.Default)
|
|
}
|
|
}
|
|
data.Deployment = &deploymentPage{Template: &snapshot.Template, Values: values}
|
|
s.render(w, http.StatusOK, "deployment.html", data)
|
|
}
|
|
|
|
func (s *server) deploymentSubmit(w http.ResponseWriter, r *http.Request) {
|
|
data, snapshot, ok := s.deploymentData(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if s.lifecycle == nil || s.serversRoot == "" {
|
|
s.problem(w, http.StatusServiceUnavailable, "Deployment is unavailable.")
|
|
return
|
|
}
|
|
session, err := r.Cookie(sessionCookie)
|
|
if err != nil {
|
|
s.problem(w, http.StatusForbidden, message("error.csrf"))
|
|
return
|
|
}
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxDeploymentUploadBytes)
|
|
if err := r.ParseMultipartForm(1 << 20); err != nil || !s.auth.ValidateCSRF(r.Context(), session.Value, r.FormValue("csrf_token")) {
|
|
s.problem(w, http.StatusForbidden, message("error.csrf"))
|
|
return
|
|
}
|
|
defer func() {
|
|
if r.MultipartForm != nil {
|
|
_ = r.MultipartForm.RemoveAll()
|
|
}
|
|
}()
|
|
values, secrets := map[string]string{}, map[string]string{}
|
|
for _, field := range snapshot.Template.Configuration.Fields {
|
|
name := "config_" + field.ID
|
|
if field.Type == "secret" {
|
|
secrets[field.ID] = r.FormValue(name)
|
|
} else {
|
|
values[field.ID] = r.FormValue(name)
|
|
}
|
|
}
|
|
data.Deployment = &deploymentPage{Template: &snapshot.Template, Values: values}
|
|
name := strings.TrimSpace(r.FormValue("display_name"))
|
|
slug := instance.Slugify(name)
|
|
request := instance.PreviewRequest{DisplayName: name, Description: r.FormValue("description"), Slug: slug, HostPorts: map[string]int{}, MountPaths: map[string]string{}, Resources: snapshot.Template.Requirements.Recommended, DataOrigin: "new", BackupRetention: 7, Configuration: values, Secrets: secrets, PublicBaseURL: publicBaseURL(r)}
|
|
for _, port := range snapshot.Template.Container.Ports {
|
|
if port.Publish {
|
|
request.HostPorts[port.ID] = port.ContainerPort
|
|
}
|
|
}
|
|
for _, mount := range snapshot.Template.Storage.Mounts {
|
|
request.MountPaths[mount.ID] = filepath.Join(s.serversRoot, slug, mount.ID)
|
|
}
|
|
if file, header, fileErr := r.FormFile("backup"); fileErr == nil {
|
|
defer file.Close()
|
|
format := importFormat(header.Filename)
|
|
maximum := int64(snapshot.Template.Imports.MaxExtractedSizeGB) << 30
|
|
if !snapshot.Template.Imports.Supported || format == "" || s.imports == nil {
|
|
data.Deployment.Error = "The selected backup cannot be imported."
|
|
s.render(w, 422, "deployment.html", data)
|
|
return
|
|
}
|
|
actor, _ := s.currentUser(r)
|
|
imp, stageErr := s.imports.Stage(r.Context(), actor.ID, format, file, importexport.Policy{TemplateID: snapshot.Template.ID, TemplateVersion: snapshot.Template.Version, AcceptedFormats: snapshot.Template.Imports.AcceptedFormats, MaxExpandedBytes: maximum, RequiredPaths: snapshot.Template.Imports.RequiredPaths})
|
|
if stageErr != nil {
|
|
data.Deployment.Error = "The backup could not be validated."
|
|
s.render(w, 422, "deployment.html", data)
|
|
return
|
|
}
|
|
request.DataOrigin, request.ImportID = "import", imp.ID
|
|
} else if fileErr != http.ErrMissingFile {
|
|
data.Deployment.Error = "The backup upload is invalid."
|
|
s.render(w, 422, "deployment.html", data)
|
|
return
|
|
}
|
|
if configured, ok := s.repository.(instance.ConfigurationRepository); ok {
|
|
identity, identityErr := configured.GetGameContainerRuntimeIdentity(r.Context())
|
|
if identityErr != nil {
|
|
data.Deployment.Error = "The game-container runtime settings are unavailable."
|
|
s.render(w, 500, "deployment.html", data)
|
|
return
|
|
}
|
|
request.RuntimeIdentity = &identity
|
|
}
|
|
preview, buildErr := instance.BuildPreview(snapshot, request)
|
|
if buildErr != nil {
|
|
data.Deployment.Error = "Please correct the deployment settings."
|
|
s.render(w, 422, "deployment.html", data)
|
|
return
|
|
}
|
|
id := randomToken()
|
|
if err := s.repository.CreateDraft(r.Context(), instance.Draft{ID: id, Preview: preview}); err != nil {
|
|
data.Deployment.Error = "This instance name is already in use."
|
|
s.render(w, 409, "deployment.html", data)
|
|
return
|
|
}
|
|
if len(secrets) != 0 {
|
|
store, ok := s.repository.(instance.SecretRepository)
|
|
if !ok || store.SaveInstanceSecrets(r.Context(), id, secrets) != nil {
|
|
data.Deployment.Error = "The secure configuration store is unavailable."
|
|
s.render(w, 502, "deployment.html", data)
|
|
return
|
|
}
|
|
}
|
|
operation, err := s.lifecycle.BeginInstall(r.Context(), id)
|
|
if err != nil {
|
|
data.Deployment.Error = "The deployment could not be queued."
|
|
s.render(w, 502, "deployment.html", data)
|
|
return
|
|
}
|
|
// Everything needed by the worker is now persisted (draft, encrypted secrets
|
|
// and validated import); it never retains the HTTP request or multipart files.
|
|
go s.runDeployment(operation.OperationID, id, preview, name)
|
|
if s.audit != nil {
|
|
actor, _ := s.currentUser(r)
|
|
_ = s.audit.Record(r.Context(), audit.Event{ActorID: actor.ID, InstanceID: id, Action: "instance.deploy", Outcome: "allowed", Summary: map[string]string{"target_name": name}})
|
|
}
|
|
if r.Header.Get("Accept") == "application/json" {
|
|
s.apiJSON(w, http.StatusAccepted, map[string]string{"operation_id": operation.OperationID, "instance_id": id})
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/instances/"+id, http.StatusSeeOther)
|
|
}
|
|
|
|
func (s *server) runDeployment(operationID, id string, preview instance.Preview, name string) {
|
|
ctx := context.Background()
|
|
fail := func(step string, err error) {
|
|
_, _ = s.lifecycle.FailInstall(ctx, operationID, id, step, err)
|
|
s.logger.Error("deployment worker failed", "operation_id", operationID, "instance_id", id, "step", step, "error", err)
|
|
s.queueDeploymentFailure(operationID, preview.Game.Name, name)
|
|
}
|
|
defer func() {
|
|
if recovered := recover(); recovered != nil {
|
|
fail("internal", fmt.Errorf("deployment worker panic"))
|
|
}
|
|
}()
|
|
s.lifecycle.MarkStep(ctx, operationID, "validation", "success")
|
|
s.lifecycle.MarkStep(ctx, operationID, "preparation", "success")
|
|
if _, err := s.lifecycle.InstallOperation(ctx, id, operationID); err != nil {
|
|
s.queueDeploymentFailure(operationID, preview.Game.Name, name)
|
|
return
|
|
}
|
|
if preview.DataOrigin == "import" {
|
|
s.lifecycle.MarkStep(ctx, operationID, "import", "running")
|
|
mountPath := ""
|
|
for _, mount := range preview.Mounts {
|
|
if mount.ID == preview.Import.DestinationMount {
|
|
mountPath = mount.HostPath
|
|
break
|
|
}
|
|
}
|
|
if mountPath == "" || s.imports == nil {
|
|
fail("import", errors.New("import destination unavailable"))
|
|
return
|
|
}
|
|
if err := s.imports.ApplyToInstance(ctx, preview.Import.ID, id, preview.Template.ID, preview.Template.Version, mountPath, preview.Import.DestinationRelativePath); err != nil {
|
|
fail("import", err)
|
|
return
|
|
}
|
|
s.lifecycle.MarkStep(ctx, operationID, "import", "success")
|
|
} else {
|
|
s.lifecycle.MarkStep(ctx, operationID, "import", "success")
|
|
}
|
|
s.lifecycle.MarkStep(ctx, operationID, "configuration", "running")
|
|
if err := s.applyDeploymentConfiguration(ctx, id, preview); err != nil {
|
|
fail("configuration", err)
|
|
return
|
|
}
|
|
s.lifecycle.MarkStep(ctx, operationID, "configuration", "success")
|
|
result, err := s.lifecycle.StartInstall(ctx, id, operationID)
|
|
if err != nil {
|
|
s.queueDeploymentFailure(operationID, preview.Game.Name, name)
|
|
return
|
|
}
|
|
if err := s.lifecycle.CompleteInstall(ctx, result); err != nil {
|
|
fail("verification", err)
|
|
}
|
|
}
|
|
|
|
func (s *server) queueDeploymentFailure(operationID, game, name string) {
|
|
if s.notifications != nil {
|
|
_ = s.notifications.Queue(context.Background(), notification.Event{Type: "start.failed", Title: "Instance deployment failed", Message: "The instance deployment failed. Code: DGM-START-001", Game: game, InstanceName: name, Action: "start", OperationID: operationID, Severity: "error"})
|
|
}
|
|
}
|
|
|
|
// applyDeploymentConfiguration intentionally runs after restore: values chosen
|
|
// in DoGaMa are authoritative over any configuration contained in an import.
|
|
func (s *server) applyDeploymentConfiguration(ctx context.Context, instanceID string, preview instance.Preview) error {
|
|
secrets := map[string]string{}
|
|
if len(preview.ResolvedConfiguration.INI) != 0 {
|
|
store, ok := s.repository.(instance.SecretRepository)
|
|
if !ok {
|
|
return errors.New("secure configuration store is unavailable")
|
|
}
|
|
var err error
|
|
secrets, err = store.LoadInstanceSecrets(ctx, instanceID)
|
|
if err != nil {
|
|
return errors.New("configuration secret is unavailable")
|
|
}
|
|
}
|
|
return instance.ApplyINI(preview.Mounts, preview.ResolvedConfiguration.INI, secrets)
|
|
}
|
|
|
|
func (s *server) deploymentData(w http.ResponseWriter, r *http.Request) (pageData, catalog.Snapshot, bool) {
|
|
data, ok := s.catalogPageData(w, r)
|
|
if !ok {
|
|
return pageData{}, catalog.Snapshot{}, false
|
|
}
|
|
if !data.IsAdmin {
|
|
s.problem(w, http.StatusForbidden, "Administrator access is required.")
|
|
return pageData{}, catalog.Snapshot{}, false
|
|
}
|
|
for _, summary := range data.Catalog {
|
|
if summary.ID == r.PathValue("id") {
|
|
snapshot, err := s.repository.Get(r.Context(), summary.ID, summary.Version)
|
|
if err == nil {
|
|
return data, snapshot, true
|
|
}
|
|
break
|
|
}
|
|
}
|
|
http.NotFound(w, r)
|
|
return pageData{}, catalog.Snapshot{}, false
|
|
}
|
|
|
|
func importFormat(name string) string {
|
|
lower := strings.ToLower(name)
|
|
switch {
|
|
case strings.HasSuffix(lower, ".tar.gz"):
|
|
return "tar.gz"
|
|
case strings.HasSuffix(lower, ".tar.zst"):
|
|
return "tar.zst"
|
|
case strings.HasSuffix(lower, ".zip"):
|
|
return "zip"
|
|
case strings.HasSuffix(lower, ".tar"):
|
|
return "tar"
|
|
}
|
|
return ""
|
|
}
|
|
func publicBaseURL(r *http.Request) string {
|
|
if r.TLS != nil {
|
|
return "https://" + r.Host
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (s *server) catalogScanForm(w http.ResponseWriter, r *http.Request) {
|
|
if !s.requireBootstrap(w, r, false) {
|
|
return
|
|
}
|
|
user, err := s.currentUser(r)
|
|
if err != nil || user.Role != "admin" {
|
|
s.problem(w, http.StatusForbidden, "Administrator access is required.")
|
|
return
|
|
}
|
|
session, err := r.Cookie(sessionCookie)
|
|
if err != nil || !s.parseForm(w, r) || !s.auth.ValidateCSRF(r.Context(), session.Value, r.FormValue("csrf_token")) {
|
|
s.problem(w, http.StatusForbidden, message("error.csrf"))
|
|
return
|
|
}
|
|
data, ok := s.catalogPageData(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if s.catalogScan == nil {
|
|
s.problem(w, http.StatusServiceUnavailable, "Catalog scanning is unavailable.")
|
|
return
|
|
}
|
|
result, scanErr := s.catalogScan(r.Context())
|
|
if scanErr != nil {
|
|
s.problem(w, http.StatusInternalServerError, message("error.internal"))
|
|
return
|
|
}
|
|
data.CatalogScan = &result
|
|
data.Catalog, _ = s.repository.List(r.Context())
|
|
s.render(w, http.StatusOK, "catalog.html", data)
|
|
}
|
|
|
|
func (s *server) catalogPageData(w http.ResponseWriter, r *http.Request) (pageData, bool) {
|
|
if !s.requireBootstrap(w, r, false) {
|
|
return pageData{}, false
|
|
}
|
|
user, err := s.currentUser(r)
|
|
if err != nil {
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
return pageData{}, false
|
|
}
|
|
csrf, err := r.Cookie(csrfCookie)
|
|
if err != nil {
|
|
s.problem(w, http.StatusForbidden, message("error.csrf"))
|
|
return pageData{}, false
|
|
}
|
|
data := pageData{Title: localized(s.language(r, user.Language), "catalog.title"), Language: s.language(r, user.Language), User: user, CSRFToken: csrf.Value, IsAdmin: user.Role == "admin", ActivePage: "catalog", CatalogScanner: s.catalogScan != nil}
|
|
if s.repository != nil {
|
|
data.Catalog, err = s.repository.List(r.Context())
|
|
}
|
|
if err != nil {
|
|
s.problem(w, http.StatusInternalServerError, message("error.internal"))
|
|
return pageData{}, false
|
|
}
|
|
return data, true
|
|
}
|
|
|
|
func (s *server) dashboardData(ctx context.Context, data *pageData) {
|
|
status := dashboardSystemStatus{}
|
|
if s.lifecycle != nil {
|
|
status.AgentAvailable = true
|
|
healthCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
|
status.AgentOnline = s.lifecycle.AgentHealthy(healthCtx) == nil
|
|
cancel()
|
|
}
|
|
if database, ok := s.repository.(interface{ Ping(context.Context) error }); ok {
|
|
status.DatabaseAvailable = true
|
|
status.DatabaseHealthy = database.Ping(ctx) == nil
|
|
}
|
|
if s.backups != nil {
|
|
if storage, err := s.backups.StorageStatus(); err == nil {
|
|
status.StorageAvailable, status.StorageUsed, status.StorageTotal = true, storage.UsedBytes, storage.TotalBytes
|
|
}
|
|
}
|
|
if latest, ok := s.repository.(interface {
|
|
LatestBackup(context.Context) (backup.Backup, error)
|
|
}); ok {
|
|
if value, err := latest.LatestBackup(ctx); err == nil {
|
|
status.BackupAvailable = true
|
|
status.LatestBackup, _ = time.Parse(time.RFC3339Nano, value.CompletedAt)
|
|
}
|
|
}
|
|
if s.audit != nil {
|
|
if policy, err := s.audit.Policy(ctx); err == nil {
|
|
status.AuditAvailable, status.AuditRetention = true, policy.RetentionDays
|
|
}
|
|
events, err := s.audit.List(ctx, audit.Filter{ActionPrefixes: []string{"instance.", "backup."}, Limit: 8})
|
|
if err == nil {
|
|
byID := make(map[string]instance.StoredInstance, len(data.Instances))
|
|
for _, current := range data.Instances {
|
|
byID[current.ID] = current
|
|
}
|
|
for _, event := range events {
|
|
activity := dashboardActivity{Action: event.Action, Outcome: event.Outcome, Actor: event.ActorLabel, Occurred: event.OccurredAt, Language: data.Language, Time: auditEventViewFrom(event, data.Language, s.auditLocation).Time}
|
|
current, exists := byID[event.InstanceID]
|
|
if exists {
|
|
activity.Instance, activity.Game = current.Preview.DisplayName, current.Preview.Game.Name
|
|
} else {
|
|
activity.Instance, activity.Game = event.Summary["instance_name"], event.Summary["game_name"]
|
|
}
|
|
if activity.Instance != "" || activity.Game != "" {
|
|
data.RecentActivity = append(data.RecentActivity, activity)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
data.SystemStatus = status
|
|
}
|
|
|
|
func (s *server) auditPage(w http.ResponseWriter, r *http.Request) {
|
|
data, ok := s.adminPageData(w, r, "Audit", "audit")
|
|
if !ok {
|
|
return
|
|
}
|
|
s.populateAuditPage(r, &data)
|
|
s.render(w, http.StatusOK, "audit.html", data)
|
|
}
|
|
|
|
func (s *server) settingsPage(w http.ResponseWriter, r *http.Request) {
|
|
data, ok := s.adminPageData(w, r, "Administration", "administration")
|
|
if !ok {
|
|
return
|
|
}
|
|
if s.repository != nil {
|
|
if configured, ok := s.repository.(instance.ConfigurationRepository); ok {
|
|
identity, identityErr := configured.GetGameContainerRuntimeIdentity(r.Context())
|
|
if identityErr == nil {
|
|
data.GameContainerUID, data.GameContainerGID = identity.UID, identity.GID
|
|
}
|
|
if labels, labelErr := configured.GetGlobalLabels(r.Context()); labelErr == nil {
|
|
data.GlobalLabels = instance.FormatLabels(labels)
|
|
}
|
|
}
|
|
if s.notifications != nil {
|
|
data.Channels, _ = s.notifications.List(r.Context())
|
|
data.NotificationLanguage, _ = s.notifications.Language(r.Context())
|
|
}
|
|
}
|
|
if s.audit != nil {
|
|
data.AuditPolicy, _ = s.audit.Policy(r.Context())
|
|
}
|
|
if settings, ok := s.repository.(interface {
|
|
GetWebAccessPolicy(context.Context) (webaccess.Policy, error)
|
|
}); ok {
|
|
policy, _ := settings.GetWebAccessPolicy(r.Context())
|
|
data.RequireHTTPS, data.CanonicalURL = policy.RequireHTTPS, policy.CanonicalURL
|
|
}
|
|
s.render(w, http.StatusOK, "settings.html", data)
|
|
}
|
|
|
|
func (s *server) adminPageData(w http.ResponseWriter, r *http.Request, title, active string) (pageData, bool) {
|
|
if !s.requireBootstrap(w, r, false) {
|
|
return pageData{}, false
|
|
}
|
|
user, err := s.currentUser(r)
|
|
if err != nil {
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
return pageData{}, false
|
|
}
|
|
if user.Role != "admin" {
|
|
s.problem(w, http.StatusForbidden, "Administrator access is required.")
|
|
return pageData{}, false
|
|
}
|
|
csrf, err := r.Cookie(csrfCookie)
|
|
if err != nil {
|
|
s.problem(w, http.StatusForbidden, message("error.csrf"))
|
|
return pageData{}, false
|
|
}
|
|
return pageData{Title: localized(s.language(r, user.Language), strings.ToLower(active)+".title"), Language: s.language(r, user.Language), Languages: supportedLanguageOptions(), User: user, CSRFToken: csrf.Value, IsAdmin: true, ActivePage: active}, true
|
|
}
|
|
|
|
func statusClass(state string) string {
|
|
switch state {
|
|
case "online", "running":
|
|
return "running"
|
|
case "stopped", "draft":
|
|
return "stopped"
|
|
case "starting", "stopping", "update", "updating", "installing", "backup", "restore", "deleting":
|
|
return "warning"
|
|
case "error", "degraded", "intervention_required":
|
|
return "error"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
func statusLabel(state string) string {
|
|
if state == "online" {
|
|
return "Running"
|
|
}
|
|
if state == "" {
|
|
return "Unknown"
|
|
}
|
|
return strings.ToUpper(state[:1]) + strings.ReplaceAll(state[1:], "_", " ")
|
|
}
|
|
|
|
func activityLabel(action string) string {
|
|
return strings.ReplaceAll(action, ".", " ")
|
|
}
|
|
|
|
func relativeTime(value time.Time) string {
|
|
if value.IsZero() {
|
|
return ""
|
|
}
|
|
delta := time.Since(value)
|
|
if delta < time.Minute {
|
|
return "now"
|
|
}
|
|
if delta < time.Hour {
|
|
return strconv.Itoa(int(delta.Minutes())) + "m"
|
|
}
|
|
if delta < 24*time.Hour {
|
|
return strconv.Itoa(int(delta.Hours())) + "h"
|
|
}
|
|
return strconv.Itoa(int(delta.Hours()/24)) + "d"
|
|
}
|
|
|
|
func storagePercent(used, total uint64) int {
|
|
if total == 0 {
|
|
return 0
|
|
}
|
|
return int(used * 100 / total)
|
|
}
|
|
|
|
func formatBytes(value uint64) string {
|
|
units := []string{"B", "KB", "MB", "GB", "TB"}
|
|
amount := float64(value)
|
|
unit := 0
|
|
for amount >= 1024 && unit < len(units)-1 {
|
|
amount /= 1024
|
|
unit++
|
|
}
|
|
if unit == 0 {
|
|
return strconv.FormatUint(value, 10) + " " + units[unit]
|
|
}
|
|
return strconv.FormatFloat(amount, 'f', 1, 64) + " " + units[unit]
|
|
}
|
|
|
|
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()
|
|
s.setCookie(w, r, 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) {
|
|
data.Languages = supportedLanguageOptions()
|
|
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.css")
|
|
if err != nil {
|
|
http.Error(w, "Not found.", http.StatusNotFound)
|
|
return
|
|
}
|
|
defer func() { _ = body.Close() }()
|
|
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
|
_, _ = io.Copy(w, body)
|
|
}
|
|
|
|
func (s *server) mobileStylesheet(w http.ResponseWriter, _ *http.Request) {
|
|
body, err := assets.Open("static/mobile-fixes.css")
|
|
if err != nil {
|
|
http.Error(w, "Not found.", http.StatusNotFound)
|
|
return
|
|
}
|
|
defer func() { _ = body.Close() }()
|
|
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
|
_, _ = io.Copy(w, body)
|
|
}
|
|
|
|
func (s *server) themeStylesheet(w http.ResponseWriter, _ *http.Request) {
|
|
body, err := assets.Open("static/theme.css")
|
|
if err != nil {
|
|
http.Error(w, "Not found.", http.StatusNotFound)
|
|
return
|
|
}
|
|
defer func() { _ = body.Close() }()
|
|
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
|
_, _ = io.Copy(w, body)
|
|
}
|
|
|
|
func (s *server) icons(w http.ResponseWriter, _ *http.Request) {
|
|
body, err := assets.Open("static/icons.svg")
|
|
if err != nil {
|
|
http.Error(w, "Not found.", http.StatusNotFound)
|
|
return
|
|
}
|
|
defer func() { _ = body.Close() }()
|
|
|
|
w.Header().Set("Content-Type", "image/svg+xml")
|
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
|
_, _ = io.Copy(w, body)
|
|
}
|
|
|
|
func (s *server) favicon(w http.ResponseWriter, _ *http.Request) {
|
|
body, err := assets.Open("static/favicon.ico")
|
|
if err != nil {
|
|
http.Error(w, "Not found.", http.StatusNotFound)
|
|
return
|
|
}
|
|
defer func() { _ = body.Close() }()
|
|
w.Header().Set("Content-Type", "image/x-icon")
|
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
|
_, _ = io.Copy(w, body)
|
|
}
|
|
|
|
func (s *server) logo(w http.ResponseWriter, _ *http.Request) {
|
|
body, err := assets.Open("static/dogama-logo.png")
|
|
if err != nil {
|
|
http.Error(w, "Not found.", http.StatusNotFound)
|
|
return
|
|
}
|
|
defer func() { _ = body.Close() }()
|
|
w.Header().Set("Content-Type", "image/png")
|
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
|
_, _ = io.Copy(w, body)
|
|
}
|
|
|
|
func (s *server) brandLogo(w http.ResponseWriter, _ *http.Request) {
|
|
body, err := assets.Open("static/dogama.png")
|
|
if err != nil {
|
|
http.Error(w, "Not found.", http.StatusNotFound)
|
|
return
|
|
}
|
|
defer func() { _ = body.Close() }()
|
|
w.Header().Set("Content-Type", "image/png")
|
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
|
_, _ = io.Copy(w, body)
|
|
}
|
|
|
|
func (s *server) javascript(w http.ResponseWriter, _ *http.Request) {
|
|
body, err := assets.Open("static/app.js")
|
|
if err != nil {
|
|
http.Error(w, "Not found.", http.StatusNotFound)
|
|
return
|
|
}
|
|
defer func() { _ = body.Close() }()
|
|
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
|
_, _ = 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 'none'; base-uri 'none'; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self'; script-src 'self'; style-src '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=()")
|
|
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
|
|
if s.webAccessPolicy(r).RequireHTTPS && requestScheme(r) == "https" {
|
|
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
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 { return localized(defaultLanguage, key) }
|