Files
DoGaMa-serv/internal/web/server.go
T
2026-08-26 22:35:42 +02:00

2649 lines
100 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"
"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
avatarRoot 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
SessionPolicy auth.SessionPolicy
IsAdmin bool
Channels []notification.Channel
NotificationForms map[string]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
HostPorts 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)
concrete.server.avatarRoot = filepath.Join(concrete.server.serversRoot, ".dogama", "avatars")
}
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, "initials": userInitials, "statusClass": statusClass, "statusLabel": statusLabel, "activityLabel": activityLabel, "relativeTime": relativeTime, "storagePercent": storagePercent, "formatBytes": formatBytes, "divDuration": func(value time.Duration, divisor int64) int64 { return int64(value) / divisor }, "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, avatarRoot: filepath.Join(os.TempDir(), "dogama-profile-avatars")}
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 /public/template-assets/{id}/{version}/{asset}", s.publicTemplateAsset)
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)
mux.HandleFunc("GET /api/v1/admin/session-policy", s.sessionPolicyGet)
mux.HandleFunc("PUT /api/v1/admin/session-policy", s.sessionPolicyPut)
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/session-policy", s.sessionPolicyForm)
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("GET /account/avatar", s.accountAvatar)
mux.HandleFunc("POST /account/notifications", s.accountNotificationsForm)
mux.HandleFunc("POST /account/avatar", s.accountAvatarUpload)
mux.HandleFunc("POST /account/avatar/delete", s.accountAvatarDelete)
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]+)*$`)
var publicVersionPattern = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$`)
func templateAssetURL(id, version, field string) string {
return "/public/template-assets/" + id + "/" + version + "/" + field
}
func (s *server) publicGameIcon(w http.ResponseWriter, r *http.Request) {
s.publicGameAsset(w, r, "logo")
}
func (s *server) publicGameArtwork(w http.ResponseWriter, r *http.Request) {
s.publicGameAsset(w, r, "image")
}
func (s *server) publicGameAsset(w http.ResponseWriter, r *http.Request, field string) {
gameID := r.PathValue("gameID")
if !publicGameIDPattern.MatchString(gameID) || s.repository == nil {
http.NotFound(w, r)
return
}
summaries, err := s.repository.List(r.Context())
if err != nil {
http.NotFound(w, r)
return
}
for _, summary := range summaries {
if summary.GameID != gameID {
continue
}
snapshot, getErr := s.repository.Get(r.Context(), summary.ID, summary.Version)
if getErr != nil {
break
}
body, ok := snapshot.AssetFiles[field]
if !ok {
break
}
serveTemplateAsset(w, body)
return
}
http.NotFound(w, r)
}
func (s *server) publicTemplateAsset(w http.ResponseWriter, r *http.Request) {
id, version, field := r.PathValue("id"), r.PathValue("version"), r.PathValue("asset")
if !publicGameIDPattern.MatchString(id) || !publicVersionPattern.MatchString(version) || (field != "logo" && field != "image" && field != "poster") || s.repository == nil {
http.NotFound(w, r)
return
}
snapshot, err := s.repository.Get(r.Context(), id, version)
if err != nil {
http.NotFound(w, r)
return
}
body, ok := snapshot.AssetFiles[field]
if !ok {
http.NotFound(w, r)
return
}
serveTemplateAsset(w, body)
}
func serveTemplateAsset(w http.ResponseWriter, body []byte) {
contentType := http.DetectContentType(body)
if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/gif" && contentType != "image/webp" {
http.Error(w, "unsupported artwork format", http.StatusUnsupportedMediaType)
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 := requireAdmin(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 := requireAdmin(actor); err != nil {
s.problem(w, http.StatusForbidden, "Administrator access 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
}
for index := range templates {
templates[index].Image = templateAssetURL(templates[index].ID, templates[index].Version, "image")
}
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 := requireAdmin(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 := requireAdmin(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 := requireAdmin(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 := requireAdmin(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.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
}
snapshot.Template.Game.Artwork.Logo = templateAssetURL(snapshot.Template.ID, snapshot.Template.Version, "logo")
snapshot.Template.Game.Artwork.Image = templateAssetURL(snapshot.Template.ID, snapshot.Template.Version, "image")
snapshot.Template.Game.Artwork.Poster = templateAssetURL(snapshot.Template.ID, snapshot.Template.Version, "poster")
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, HostPorts: deploymentHostPortValues(snapshot.Template)}
s.render(w, http.StatusOK, "deployment.html", data)
}
func deploymentHostPortValues(template catalog.Template) map[string]string {
values := make(map[string]string)
for _, port := range template.Container.Ports {
if port.Publish {
values[port.ID] = strconv.Itoa(port.ContainerPort)
}
}
return values
}
func parseDeploymentHostPorts(r *http.Request, template catalog.Template) (map[string]int, map[string]string, error) {
hostPorts := map[string]int{}
displayValues := deploymentHostPortValues(template)
used := map[string]string{}
for _, port := range template.Container.Ports {
if !port.Publish {
continue
}
value := strings.TrimSpace(r.FormValue("host_port_" + port.ID))
displayValues[port.ID] = value
parsed, err := strconv.Atoi(value)
if err != nil || parsed < 1 || parsed > 65535 {
return nil, displayValues, fmt.Errorf("host port for %s must be an integer between 1 and 65535", port.ID)
}
key := fmt.Sprintf("%s/%d", port.Protocol, parsed)
if previous, exists := used[key]; exists {
return nil, displayValues, fmt.Errorf("host port %d/%s is used by both %s and %s", parsed, port.Protocol, previous, port.ID)
}
used[key] = port.ID
hostPorts[port.ID] = parsed
}
return hostPorts, displayValues, nil
}
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)
}
}
hostPorts, displayHostPorts, hostPortErr := parseDeploymentHostPorts(r, snapshot.Template)
data.Deployment = &deploymentPage{Template: &snapshot.Template, Values: values, HostPorts: displayHostPorts}
if hostPortErr != nil {
data.Deployment.Error = hostPortErr.Error()
s.render(w, http.StatusUnprocessableEntity, "deployment.html", data)
return
}
name := strings.TrimSpace(r.FormValue("display_name"))
slug := instance.Slugify(name)
request := instance.PreviewRequest{DisplayName: name, Description: r.FormValue("description"), Slug: slug, HostPorts: hostPorts, MountPaths: map[string]string{}, Resources: snapshot.Template.Requirements.Recommended, DataOrigin: "new", BackupRetention: 7, Configuration: values, Secrets: secrets, PublicBaseURL: publicBaseURL(r)}
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 {
snapshot.Template.Game.Artwork.Logo = templateAssetURL(snapshot.Template.ID, snapshot.Template.Version, "logo")
snapshot.Template.Game.Artwork.Image = templateAssetURL(snapshot.Template.ID, snapshot.Template.Version, "image")
snapshot.Template.Game.Artwork.Poster = templateAssetURL(snapshot.Template.ID, snapshot.Template.Version, "poster")
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())
for index := range data.Catalog {
data.Catalog[index].Image = templateAssetURL(data.Catalog[index].ID, data.Catalog[index].Version, "image")
}
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
}
for index := range data.Catalog {
data.Catalog[index].Image = templateAssetURL(data.Catalog[index].ID, data.Catalog[index].Version, "image")
}
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.NotificationForms = map[string]notification.Channel{
"email": {Type: "email", Name: "SMTP email", FormAction: "/admin/notification-channels", Config: map[string]string{"port": "587", "tls_mode": "starttls"}},
"discord": {Type: "discord", Name: "Discord", FormAction: "/admin/notification-channels"},
"gotify": {Type: "gotify", Name: "Gotify", FormAction: "/admin/notification-channels"},
}
for _, channel := range data.Channels {
if _, ok := data.NotificationForms[channel.Type]; ok {
data.NotificationForms[channel.Type] = channel
}
}
data.NotificationLanguage, _ = s.notifications.Language(r.Context())
}
}
if s.audit != nil {
data.AuditPolicy, _ = s.audit.Policy(r.Context())
}
if err := s.populateAdminUsers(r, &data); err != nil {
s.problem(w, http.StatusInternalServerError, "The users are unavailable.")
return
}
if s.repository != nil {
data.TemplateRepositories, _ = s.repository.ListTemplateRepositories(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
}
data.SessionPolicy, _ = s.auth.SessionPolicy(r.Context())
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) }