Merge pull request 'Feat/ui redesign pre m10' (#12) from feat/ui-redesign-pre-m10 into main

Reviewed-on: #12
This commit was merged in pull request #12.
This commit is contained in:
2026-08-09 20:28:18 +02:00
24 changed files with 646 additions and 77 deletions
+2 -1
View File
@@ -41,11 +41,12 @@ Stop and report any request that would weaken these boundaries.
## Git workflow
- Git delivery is mandatory for every implementation or milestone: create a dedicated feature branch from an up-to-date `main`, make logical commits, push the branch with the `codex` Gitea account, and open a pull request to `main`.
- Work only on a non-`main` feature branch. If the task starts on `main`, create or request a working branch before editing.
- Never modify, commit on, merge into, rebase, reset, delete or push `main`.
- Develop each milestone on its own dedicated working branch.
- After a milestone's validations and commits, always push its working branch to Gitea using the `codex` account. The milestone is not complete until the remote branch exists.
- After pushing, create a pull request from the working branch to `main` when the available tools permit it. If automatic creation is unavailable, provide the URL or exact information needed to open it immediately.
- After pushing, open a pull request from the working branch to `main`. If automatic creation is technically unavailable, provide the exact creation URL and information immediately and report the blocker.
- Never approve or merge a Gitea pull request.
- Do not alter remotes, credentials or repository-wide Git configuration unless explicitly requested.
- Never use destructive recovery commands such as `git reset --hard`, `git clean`, or checkout-based restoration without explicit approval and a verified target list.
+1 -1
View File
@@ -14,7 +14,7 @@ The image tag and upstream API may change. Catalog maintainers must verify and r
## Reference limitations
- Artwork URLs are intentionally absent until a catalog maintainer selects suitable source files and attribution. DoGaMa caches and converts accepted raster artwork locally.
- The template ships separate local logo and horizontal artwork assets. The source URL and attribution remain in the template so the cached raster can be audited without loading third-party content in the UI.
- The schema's storage sizes are conservative product defaults because upstream specifies SSD performance but not a fixed disk-size requirement.
- Local hosted-world migration may require player identity conversion. The generic V1 importer detects structure and warns; it does not silently convert identities.
- The checked-in module manifest is a source example. It becomes installable only after `module.wasm` is built and its real SHA-256 replaces the all-zero placeholder.
Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

+4 -1
View File
@@ -1,6 +1,6 @@
schema_version: 1
id: palworld-official
version: 1.0.0
version: 1.1.0
source:
type: official
@@ -12,6 +12,9 @@ game:
description: Official Palworld dedicated server reference for DoGaMa.
website: https://www.palworldgame.com/
artwork:
logo: assets/icon.png
image: assets/banner.jpg
poster_source_url: https://cdn.getshifter.co/4cf51f4bd2c52300046e22057221adc8e88f21a9/uploads/2026/07/img-youtube-thumb-04.jpg
attribution: Palworld and related artwork are property of Pocketpair, Inc.; no affiliation is implied.
requirements:
+6 -3
View File
@@ -4,7 +4,7 @@ Read this compact operational baseline before starting a milestone. Open detaile
## Baseline
- Current reference: milestone 9 working branch after merged milestone 8 baseline `1e226d3`.
- Current reference: pre-milestone-10 UI redesign branch from merged milestone 9 baseline `d68d97d`.
- Released SQLite migrations: `0001` through `0009`; never rewrite them.
- Roadmap milestones 1-9 are implemented.
@@ -24,13 +24,15 @@ Read this compact operational baseline before starting a milestone. Open detaile
- Per-instance memberships, overrides and installation requests with backend authorization.
- Backup scheduling/retention, safe imports, export and restore with safety backups.
- Sandboxed WASM runtime and normalized module API with Palworld reference adapter.
- Game-container configuration: global and per-instance labels, safe label variables, derived instance slug, immutable Docker-user selection, tracked/pinned image tags, immediate or deferred container recreation, and public game-icon route.
- Game-container configuration: global and per-instance labels, safe label variables, derived instance slug, immutable Docker-user selection, tracked/pinned image tags, immediate or deferred container recreation, and public game-logo/artwork routes.
- Controlled digest-aware game updates with confirmation, policy-driven pre-update backups, readiness verification, mod warnings and automatic container-plan rollback.
- Redacted configuration history retained to the latest 10 revisions, with pinned-template revalidation and immediate or deferred rollback.
- Declarative Steam Workshop item configuration with numeric-ID validation, stable ordering and backend `mods.manage` enforcement.
- Encrypted write-only SMTP, generic HTTPS webhook and Discord channels with event filters, queued test delivery, bounded retry and redacted terminal errors.
- SSRF-resistant HTTPS webhook delivery with redirect/address revalidation, event IDs, timestamps and optional HMAC-SHA256 signatures.
- Compact allow-listed audit events for authentication and significant mutations, administrator filtering, bounded manual purge, daily retention and maximum-count enforcement.
- Responsive server-rendered application shell with the official square DoGaMa logo, synthwave-derived design tokens, aligned permission-aware navigation, searchable real instance cards and state summaries above the server grid.
- Dedicated administrator Audit and Settings pages; notification channels, audit retention/purge and game-container labels retain their existing backend contracts outside the Dashboard.
## Durable decisions
@@ -40,6 +42,7 @@ Read this compact operational baseline before starting a milestone. Open detaile
- `dogama.*` and `io.dogama.*` are reserved label namespaces.
- Label values support only the explicit allowlist in `internal/instance/container_config.go`; unknown variables are errors, not arbitrary templates.
- `{{game.icon_url}}` is the public icon for the game. `{{instance.slug}}` remains supported.
- Template game artwork contains separate required local `logo` and horizontal `image` assets; template validation rejects missing files. Deployment previews expose distinct logo and artwork URLs while retaining `icon_url` as a compatible logo alias. Palworld template `1.1.0` is the first snapshot with this contract.
- Instance slugs are derived from the display name, not canonical IDs. Accents are normalized to ASCII; whitespace, `/`, punctuation and special characters become safe hyphen separators; repeated and edge hyphens are removed.
- Docker user mode is fixed at creation to DoGaMa UID/GID, custom numeric UID/GID, or image-defined user. Never perform automatic recursive ownership changes.
- A pinned image tag is an explicit mutable tag, not an immutable digest. Tracked mode follows the template's declared default tag.
@@ -55,7 +58,7 @@ Read this compact operational baseline before starting a milestone. Open detaile
- Release hardening remains roadmap work.
- Scheduled backup outcomes and repeated authentication blocks are audited/logged, but broader scheduler-origin notification coverage remains intentionally limited to events emitted by implemented workflows.
- The web interface is intentionally modest; several advanced workflows are API-first.
- Instance detail, catalog and backup management remain API-first; their sidebar entries are deliberately disabled until corresponding web pages exist, so navigation does not imply unavailable routes.
- Linux is the deployment target. Native Windows execution of the full Go suite is blocked by Unix `Statfs` code; use Linux/WSL/CI for complete execution.
- `staticcheck`, `golangci-lint` and Python specification dependencies may not be installed on every development host; report missing tooling rather than silently skipping or installing it.
+12
View File
@@ -47,6 +47,13 @@ type Template struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Artwork struct {
Logo string `json:"logo"`
Image string `json:"image"`
PosterSourceURL string `json:"poster_source_url,omitempty"`
LogoSourceURL string `json:"logo_source_url,omitempty"`
Attribution string `json:"attribution"`
} `json:"artwork"`
} `json:"game"`
Requirements struct {
Minimum Resources `json:"minimum"`
@@ -293,6 +300,11 @@ func validationErrors(err error, document *yaml.Node) error {
func crossValidate(template Template, assetRoot string, source fs.FS) []ValidationIssue {
var issues []ValidationIssue
for field, asset := range map[string]string{"logo": template.Game.Artwork.Logo, "image": template.Game.Artwork.Image} {
if _, err := fs.Stat(source, path.Join(assetRoot, asset)); err != nil {
issues = append(issues, ValidationIssue{Path: "/game/artwork/" + field, Message: "artwork asset is missing"})
}
}
ports := make(map[string]Port)
for _, port := range template.Container.Ports {
if _, exists := ports[port.ID]; exists {
+24 -1
View File
@@ -21,6 +21,28 @@ func TestBuiltInCatalogValidatesDeterministically(t *testing.T) {
if len(first) != 1 || first[0].Template.ID != "palworld-official" || first[0].Digest != second[0].Digest || first[0].CanonicalYAML != second[0].CanonicalYAML {
t.Fatalf("catalog snapshots = %#v, %#v", first, second)
}
if first[0].Template.Game.Artwork.Logo != "assets/icon.png" || first[0].Template.Game.Artwork.Image != "assets/banner.jpg" {
t.Fatalf("catalog artwork = %#v", first[0].Template.Game.Artwork)
}
}
func TestCrossValidationRejectsMissingArtworkAsset(t *testing.T) {
body, err := catalogdata.Files.ReadFile("palworld/template.yaml")
if err != nil {
t.Fatal(err)
}
body = []byte(strings.Replace(string(body), "image: assets/banner.jpg", "image: assets/missing.jpg", 1))
_, err = catalog.Validate(body, "palworld", catalogdata.Files)
var validation *catalog.ValidationErrors
if !errors.As(err, &validation) {
t.Fatalf("validation error = %#v", err)
}
for _, issue := range validation.Issues {
if issue.Path == "/game/artwork/image" {
return
}
}
t.Fatalf("issues = %#v", validation.Issues)
}
func TestSchemaErrorsContainFieldPathAndLine(t *testing.T) {
@@ -44,7 +66,8 @@ func TestCrossValidationRejectsUnknownBackupMount(t *testing.T) {
if err != nil {
t.Fatal(err)
}
body = []byte(strings.Replace(string(body), " - saved\n restart_after_backup", " - missing\n restart_after_backup", 1))
normalized := strings.ReplaceAll(string(body), "\r\n", "\n")
body = []byte(strings.Replace(normalized, " - saved\n restart_after_backup", " - missing\n restart_after_backup", 1))
_, err = catalog.Validate(body, "palworld", catalogdata.Files)
var validation *catalog.ValidationErrors
if !errors.As(err, &validation) {
+1 -1
View File
@@ -50,7 +50,7 @@ func TestStageValidZIPAndRejectTraversal(t *testing.T) {
if err != nil {
t.Fatal(err)
}
policy := importexport.Policy{TemplateID: "palworld-official", TemplateVersion: "1.0.0", AcceptedFormats: []string{"zip"}, MaxExpandedBytes: 1 << 20, RequiredPaths: []string{"Level.sav", "Players"}}
policy := importexport.Policy{TemplateID: "palworld-official", TemplateVersion: "1.1.0", AcceptedFormats: []string{"zip"}, MaxExpandedBytes: 1 << 20, RequiredPaths: []string{"Level.sav", "Players"}}
valid := zipBytes(t, map[string]string{"Save/Level.sav": "world", "Save/Players/player.sav": "player"})
result, err := service.Stage(ctx, actor.ID, "zip", bytes.NewReader(valid), policy)
if err != nil {
+12 -4
View File
@@ -79,9 +79,11 @@ type UpdatePolicy struct {
}
type GameReference struct {
ID string `json:"id"`
Name string `json:"name"`
IconURL string `json:"icon_url"`
ID string `json:"id"`
Name string `json:"name"`
IconURL string `json:"icon_url"`
LogoURL string `json:"logo_url"`
ArtworkURL string `json:"artwork_url"`
}
type TemplateReference struct {
@@ -252,7 +254,7 @@ func BuildPreview(snapshot catalog.Snapshot, request PreviewRequest) (Preview, e
Backup: BackupPreview{Strategy: snapshot.Template.Backup.Strategy, SourceMounts: append([]string(nil), snapshot.Template.Backup.SourceMounts...), RetentionCount: request.BackupRetention},
Import: ImportPreview{ID: request.ImportID, DestinationMount: snapshot.Template.Imports.DestinationMount, DestinationRelativePath: snapshot.Template.Imports.DestinationRelativePath},
CustomLabels: customLabels, DockerUser: request.DockerUser, DockerUserValue: userValue, ImageTag: tag, TemplateDefaultTag: snapshot.Template.Container.Tag,
Game: GameReference{ID: snapshot.Template.Game.ID, Name: snapshot.Template.Game.Name, IconURL: strings.TrimRight(request.PublicBaseURL, "/") + "/public/game-icons/" + snapshot.Template.Game.ID},
Game: gameReference(snapshot.Template.Game.ID, snapshot.Template.Game.Name, request.PublicBaseURL),
Mods: ModsConfiguration{Supported: snapshot.Template.Mods.Supported, Provider: snapshot.Template.Mods.Provider, DestinationMount: snapshot.Template.Mods.DestinationMount, RestartRequired: snapshot.Template.Mods.RestartRequired, Items: []string{}},
UpdatePolicy: UpdatePolicy{BackupBeforeUpdate: snapshot.Template.Updates.BackupBeforeUpdate, RollbackOnFailure: snapshot.Template.Updates.RollbackOnFailure, Automatic: false, HealthTimeoutSeconds: snapshot.Template.Updates.HealthTimeoutSeconds},
}
@@ -273,6 +275,12 @@ func BuildPreview(snapshot catalog.Snapshot, request PreviewRequest) (Preview, e
return preview, nil
}
func gameReference(id, name, publicBaseURL string) GameReference {
baseURL := strings.TrimRight(publicBaseURL, "/")
logoURL := baseURL + "/public/game-icons/" + id
return GameReference{ID: id, Name: name, IconURL: logoURL, LogoURL: logoURL, ArtworkURL: baseURL + "/public/game-artwork/" + id}
}
// DeploymentPlan converts a persisted preview into the only container plan
// accepted by the restricted agent. The digest binds every privileged field.
func (p Preview) DeploymentPlan(instanceID string) (agentwire.DeploymentPlan, error) {
+3
View File
@@ -33,6 +33,9 @@ func TestBuildPreviewIsDeterministicAndRedactsSecrets(t *testing.T) {
if first.PlanDigest != second.PlanDigest || first.CanonicalJSON != second.CanonicalJSON {
t.Fatal("preview is not deterministic")
}
if first.Game.LogoURL != "/public/game-icons/palworld" || first.Game.ArtworkURL != "/public/game-artwork/palworld" || first.Game.IconURL != first.Game.LogoURL {
t.Fatalf("game artwork URLs = %#v", first.Game)
}
for _, setting := range first.Settings {
if setting.Secret && setting.Default != nil {
t.Fatalf("secret default leaked: %#v", setting)
+5 -5
View File
@@ -193,7 +193,7 @@ func (s *server) notificationForm(w http.ResponseWriter, r *http.Request) {
return
}
s.recordAudit(r, actor, "notification.channel.update", "allowed", map[string]string{"target_id": value.ID, "channel_type": value.Type})
http.Redirect(w, r, "/", http.StatusSeeOther)
http.Redirect(w, r, "/settings#notifications", http.StatusSeeOther)
}
func (s *server) notificationTestForm(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, false)
@@ -205,7 +205,7 @@ func (s *server) notificationTestForm(w http.ResponseWriter, r *http.Request) {
return
}
s.recordAudit(r, actor, "notification.channel.test", "allowed", map[string]string{"target_id": r.PathValue("id"), "event_type": "notification.test"})
http.Redirect(w, r, "/", http.StatusSeeOther)
http.Redirect(w, r, "/settings#notifications", http.StatusSeeOther)
}
func (s *server) notificationDeleteForm(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, false)
@@ -217,7 +217,7 @@ func (s *server) notificationDeleteForm(w http.ResponseWriter, r *http.Request)
return
}
s.recordAudit(r, actor, "notification.channel.delete", "allowed", map[string]string{"target_id": r.PathValue("id")})
http.Redirect(w, r, "/", http.StatusSeeOther)
http.Redirect(w, r, "/settings#notifications", http.StatusSeeOther)
}
func (s *server) auditPolicyForm(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, false)
@@ -231,7 +231,7 @@ func (s *server) auditPolicyForm(w http.ResponseWriter, r *http.Request) {
return
}
s.recordAudit(r, actor, "audit.policy.update", "allowed", nil)
http.Redirect(w, r, "/", http.StatusSeeOther)
http.Redirect(w, r, "/settings#audit", http.StatusSeeOther)
}
func (s *server) auditPurgeForm(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, false)
@@ -253,5 +253,5 @@ func (s *server) auditPurgeForm(w http.ResponseWriter, r *http.Request) {
return
}
s.recordAudit(r, actor, "audit.purge", "allowed", map[string]string{"deleted_count": strconv.FormatInt(n, 10)})
http.Redirect(w, r, "/", http.StatusSeeOther)
http.Redirect(w, r, "/settings#audit", http.StatusSeeOther)
}
+153 -14
View File
@@ -36,7 +36,7 @@ const (
maxFormBytes = 64 << 10
)
//go:embed templates/*.html static/*.css
//go:embed templates/*.html static/*
var assets embed.FS
var englishMessages = map[string]string{
@@ -94,6 +94,9 @@ type pageData struct {
AuditInstance string
AuditAction string
AuditOutcome string
ActivePage string
Instances []instance.StoredInstance
StatusCounts map[string]int
}
// NewHandler constructs the complete HTTP application.
@@ -135,7 +138,7 @@ func newHandlerWithImports(authService *auth.Service, repository repository, lif
}
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) {
templates, err := template.New("views").Funcs(template.FuncMap{"msg": message}).ParseFS(assets, "templates/*.html")
templates, err := template.New("views").Funcs(template.FuncMap{"msg": message, "statusClass": statusClass, "statusLabel": statusLabel}).ParseFS(assets, "templates/*.html")
if err != nil {
return nil, err
}
@@ -146,6 +149,7 @@ func newHandlerServices(authService *auth.Service, repository repository, lifecy
mux := http.NewServeMux()
if repository != nil {
mux.HandleFunc("GET /public/game-icons/{gameID}", s.publicGameIcon)
mux.HandleFunc("GET /public/game-artwork/{gameID}", s.publicGameArtwork)
mux.HandleFunc("GET /api/v1/catalog", s.catalogList)
mux.HandleFunc("POST /api/v1/instances/preview", s.instancePreview)
mux.HandleFunc("POST /api/v1/instances/drafts", s.instanceDraft)
@@ -204,6 +208,12 @@ func newHandlerServices(authService *auth.Service, repository repository, lifecy
}
}
mux.HandleFunc("GET /static/app.v1.css", s.stylesheet)
mux.HandleFunc("GET /static/app.v2.css", s.stylesheet)
mux.HandleFunc("GET /static/theme.v1.css", s.themeStylesheet)
mux.HandleFunc("GET /static/theme.v2.css", s.themeStylesheet)
mux.HandleFunc("GET /static/app.v1.js", s.javascript)
mux.HandleFunc("GET /static/app.v2.js", s.javascript)
mux.HandleFunc("GET /static/dogama-logo.png", s.logo)
mux.HandleFunc("GET /setup", s.setupForm)
mux.HandleFunc("POST /setup", s.setupSubmit)
mux.HandleFunc("GET /login", s.loginForm)
@@ -215,6 +225,8 @@ func newHandlerServices(authService *auth.Service, repository repository, lifecy
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("GET /audit", s.auditPage)
mux.HandleFunc("GET /settings", s.settingsPage)
mux.HandleFunc("GET /", s.home)
return s.securityHeaders(s.auditRequests(mux)), nil
}
@@ -316,17 +328,25 @@ func auditAction(method, path string) string {
var publicGameIDPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
func (s *server) publicGameIcon(w http.ResponseWriter, r *http.Request) {
s.publicGameAsset(w, r, "palworld/assets/icon.png", "image/png")
}
func (s *server) publicGameArtwork(w http.ResponseWriter, r *http.Request) {
s.publicGameAsset(w, r, "palworld/assets/banner.jpg", "image/jpeg")
}
func (s *server) publicGameAsset(w http.ResponseWriter, r *http.Request, assetPath, contentType string) {
gameID := r.PathValue("gameID")
if !publicGameIDPattern.MatchString(gameID) || gameID != "palworld" {
http.NotFound(w, r)
return
}
body, err := catalogdata.Files.ReadFile("palworld/assets/icon.png")
body, err := catalogdata.Files.ReadFile(assetPath)
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "image/png")
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)
@@ -459,7 +479,7 @@ func (s *server) globalLabelsForm(w http.ResponseWriter, r *http.Request) {
}
}
}
http.Redirect(w, r, "/", http.StatusSeeOther)
http.Redirect(w, r, "/settings#containers", http.StatusSeeOther)
}
func mustLifecycleInstances(ctx context.Context, repository repository) []instance.StoredInstance {
@@ -1398,6 +1418,10 @@ func (s *server) logout(w http.ResponseWriter, r *http.Request) {
}
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
}
@@ -1411,8 +1435,44 @@ func (s *server) home(w http.ResponseWriter, r *http.Request) {
s.problem(w, http.StatusForbidden, message("error.csrf"))
return
}
data := pageData{Title: message("dashboard.title"), User: user, CSRFToken: csrf.Value, IsAdmin: user.Role == "admin"}
if data.IsAdmin && s.repository != nil {
data := pageData{Title: message("dashboard.title"), 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)
class := statusClass(current.LifecycleState)
if class == "running" || class == "stopped" || class == "error" {
data.StatusCounts[class]++
}
if current.LifecycleState == "update" || current.LifecycleState == "updating" {
data.StatusCounts["updating"]++
}
}
}
s.render(w, http.StatusOK, "home.html", data)
}
func (s *server) auditPage(w http.ResponseWriter, r *http.Request) {
data, ok := s.adminPageData(w, r, "Audit", "audit")
if !ok {
return
}
if s.audit != nil {
data.AuditActor, data.AuditInstance = r.URL.Query().Get("actor_id"), r.URL.Query().Get("instance_id")
data.AuditAction, data.AuditOutcome = r.URL.Query().Get("action"), r.URL.Query().Get("outcome")
data.AuditEvents, _ = s.audit.List(r.Context(), audit.Filter{ActorID: data.AuditActor, InstanceID: data.AuditInstance, Action: data.AuditAction, Outcome: data.AuditOutcome, Limit: 50})
}
s.render(w, http.StatusOK, "audit.html", data)
}
func (s *server) settingsPage(w http.ResponseWriter, r *http.Request) {
data, ok := s.adminPageData(w, r, "Settings", "settings")
if !ok {
return
}
if s.repository != nil {
if configured, ok := s.repository.(instance.ConfigurationRepository); ok {
if labels, labelErr := configured.GetGlobalLabels(r.Context()); labelErr == nil {
data.GlobalLabels = instance.FormatLabels(labels)
@@ -1421,14 +1481,57 @@ func (s *server) home(w http.ResponseWriter, r *http.Request) {
if s.notifications != nil {
data.Channels, _ = s.notifications.List(r.Context())
}
if s.audit != nil {
data.AuditActor, data.AuditInstance = r.URL.Query().Get("actor_id"), r.URL.Query().Get("instance_id")
data.AuditAction, data.AuditOutcome = r.URL.Query().Get("action"), r.URL.Query().Get("outcome")
data.AuditEvents, _ = s.audit.List(r.Context(), audit.Filter{ActorID: data.AuditActor, InstanceID: data.AuditInstance, Action: data.AuditAction, Outcome: data.AuditOutcome, Limit: 50})
data.AuditPolicy, _ = s.audit.Policy(r.Context())
}
}
s.render(w, http.StatusOK, "home.html", data)
if s.audit != nil {
data.AuditPolicy, _ = s.audit.Policy(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: title, 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 (s *server) currentUser(r *http.Request) (auth.User, error) {
@@ -1505,6 +1608,42 @@ func (s *server) stylesheet(w http.ResponseWriter, _ *http.Request) {
_, _ = io.Copy(w, body)
}
func (s *server) themeStylesheet(w http.ResponseWriter, _ *http.Request) {
body, err := assets.Open("static/theme.v1.css")
if err != nil {
http.Error(w, "Not found.", http.StatusNotFound)
return
}
defer body.Close()
w.Header().Set("Content-Type", "text/css; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=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 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.v1.js")
if err != nil {
http.Error(w, "Not found.", http.StatusNotFound)
return
}
defer 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)
}
+108 -5
View File
@@ -81,6 +81,11 @@ func TestCatalogPreviewAndDraftAPIAuthorization(t *testing.T) {
if icon.Header().Get("Content-Type") != "image/png" {
t.Fatalf("icon content type = %q", icon.Header().Get("Content-Type"))
}
artwork := request(t, handler, http.MethodGet, "/public/game-artwork/palworld", nil)
assertStatus(t, artwork, http.StatusOK)
if artwork.Header().Get("Content-Type") != "image/jpeg" || artwork.Body.Len() < 100000 {
t.Fatalf("artwork response: type=%q size=%d", artwork.Header().Get("Content-Type"), artwork.Body.Len())
}
traversal := request(t, handler, http.MethodGet, "/public/game-icons/..%2Fprivate", nil)
if traversal.Code == http.StatusOK {
t.Fatal("icon traversal accepted")
@@ -94,7 +99,7 @@ func TestCatalogPreviewAndDraftAPIAuthorization(t *testing.T) {
t.Fatalf("catalog response = %s", catalogResponse.Body.String())
}
payload, _ := json.Marshal(map[string]any{
"template_id": "palworld-official", "template_version": "1.0.0",
"template_id": "palworld-official", "template_version": "1.1.0",
"display_name": "Family Palworld", "slug": "family-palworld",
"host_ports": map[string]int{"game": 8211},
"mount_paths": map[string]string{"saved": "/srv/game-servers/family-palworld/saved"},
@@ -180,8 +185,106 @@ func TestNotificationAndAuditAdministration(t *testing.T) {
}
home := request(t, handler, http.MethodGet, "/", []*http.Cookie{cookie, &http.Cookie{Name: csrfCookie, Value: session.CSRFToken}})
assertStatus(t, home, http.StatusOK)
if !strings.Contains(home.Body.String(), "Notification channels") || !strings.Contains(home.Body.String(), "Recent audit events") {
t.Fatal("administration UI sections missing")
if !strings.Contains(home.Body.String(), "Game servers") || strings.Contains(home.Body.String(), "Notification channels") {
t.Fatal("dashboard was not separated from administration settings")
}
settings := request(t, handler, http.MethodGet, "/settings", []*http.Cookie{cookie, &http.Cookie{Name: csrfCookie, Value: session.CSRFToken}})
assertStatus(t, settings, http.StatusOK)
if !strings.Contains(settings.Body.String(), "Notification channels") || !strings.Contains(settings.Body.String(), "Game-container labels") {
t.Fatal("settings UI sections missing")
}
auditPage := request(t, handler, http.MethodGet, "/audit", []*http.Cookie{cookie, &http.Cookie{Name: csrfCookie, Value: session.CSRFToken}})
assertStatus(t, auditPage, http.StatusOK)
if !strings.Contains(auditPage.Body.String(), "Recent") && !strings.Contains(auditPage.Body.String(), "Review significant") {
t.Fatal("audit UI missing")
}
}
func TestApplicationNavigationHidesAdministrationFromRegularUsers(t *testing.T) {
ctx := context.Background()
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
repository := sqlite.NewRepository(db)
authService := auth.New(db)
if err := authService.BootstrapAdmin(ctx, "admin", "correct horse battery staple"); err != nil {
t.Fatal(err)
}
user, err := authService.CreateUser(ctx, "player", "correct horse battery staple", "user")
if err != nil {
t.Fatal(err)
}
session, err := authService.Login(ctx, user.Username, "correct horse battery staple", "192.0.2.2:1234")
if err != nil {
t.Fatal(err)
}
handler, err := NewHandlerWithRepository(authService, repository, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
cookies := []*http.Cookie{{Name: sessionCookie, Value: session.Token}, {Name: csrfCookie, Value: session.CSRFToken}}
home := request(t, handler, http.MethodGet, "/", cookies)
assertStatus(t, home, http.StatusOK)
if !strings.Contains(home.Body.String(), `class="nav-instances disabled"`) || !strings.Contains(home.Body.String(), `class="nav-catalog disabled"`) || !strings.Contains(home.Body.String(), `class="nav-backups disabled"`) {
t.Fatal("planned navigation entries are not represented as disabled items")
}
if strings.Contains(home.Body.String(), "href=\"/audit\"") || strings.Contains(home.Body.String(), "href=\"/settings\"") {
t.Fatal("regular user received administrator navigation")
}
assertStatus(t, request(t, handler, http.MethodGet, "/audit", cookies), http.StatusForbidden)
assertStatus(t, request(t, handler, http.MethodGet, "/settings", cookies), http.StatusForbidden)
}
func TestOfficialLogoIsServed(t *testing.T) {
db, err := sqlite.Open(context.Background(), filepath.Join(t.TempDir(), "dogama.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
handler, err := NewHandler(auth.New(db), slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
response := request(t, handler, http.MethodGet, "/static/dogama-logo.png", nil)
assertStatus(t, response, http.StatusOK)
if response.Header().Get("Content-Type") != "image/png" || response.Body.Len() < 1000 {
t.Fatal("official logo asset missing")
}
javascript := request(t, handler, http.MethodGet, "/static/app.v1.js", nil)
assertStatus(t, javascript, http.StatusOK)
if !strings.Contains(javascript.Body.String(), "nav-open") || !strings.Contains(javascript.Body.String(), "instance-search") {
t.Fatal("responsive navigation or instance search script missing")
}
assertStatus(t, request(t, handler, http.MethodGet, "/static/app.v2.css", nil), http.StatusOK)
assertStatus(t, request(t, handler, http.MethodGet, "/static/app.v2.js", nil), http.StatusOK)
assertStatus(t, request(t, handler, http.MethodGet, "/static/theme.v2.css", nil), http.StatusOK)
theme := request(t, handler, http.MethodGet, "/static/theme.v1.css", nil)
assertStatus(t, theme, http.StatusOK)
if !strings.Contains(theme.Body.String(), "dogama-logo.png") {
t.Fatal("brand-derived application background missing")
}
}
func TestInstanceStatusPresentationUsesExistingLifecycleStates(t *testing.T) {
tests := map[string]string{
"online": "running",
"stopped": "stopped",
"installing": "warning",
"backup": "warning",
"restore": "warning",
"update": "warning",
"intervention_required": "error",
"unknown": "unknown",
}
for state, expected := range tests {
if actual := statusClass(state); actual != expected {
t.Errorf("statusClass(%q) = %q, want %q", state, actual, expected)
}
if label := statusLabel(state); label == "" {
t.Errorf("statusLabel(%q) is empty", state)
}
}
}
@@ -366,7 +469,7 @@ func TestInstanceAuthorizationAndInstallationRequestWorkflow(t *testing.T) {
playerCookie := &http.Cookie{Name: sessionCookie, Value: playerSession.Token}
draftPayload, _ := json.Marshal(map[string]any{
"template_id": "palworld-official", "template_version": "1.0.0",
"template_id": "palworld-official", "template_version": "1.1.0",
"display_name": "Authorization Test", "slug": "authorization-test",
"host_ports": map[string]int{"game": 8211},
"mount_paths": map[string]string{"saved": "/srv/game-servers/authorization-test/saved"},
@@ -405,7 +508,7 @@ func TestInstanceAuthorizationAndInstallationRequestWorkflow(t *testing.T) {
substitution := request(t, handler, http.MethodGet, "/api/v1/instances/not-the-member-instance", []*http.Cookie{playerCookie})
assertStatus(t, substitution, http.StatusForbidden)
requestPayload := []byte(`{"template_id":"palworld-official","template_version":"1.0.0","suggested_name":"Friends","player_estimate":8,"desired_schedule":"evenings","mods_requested":true,"message":"Private group"}`)
requestPayload := []byte(`{"template_id":"palworld-official","template_version":"1.1.0","suggested_name":"Friends","player_estimate":8,"desired_schedule":"evenings","mods_requested":true,"message":"Private group"}`)
installationRequest := jsonRequest(t, handler, "/api/v1/installation-requests", requestPayload, playerCookie, playerSession.CSRFToken)
assertStatus(t, installationRequest, http.StatusCreated)
var createdRequest struct {
File diff suppressed because one or more lines are too long
+29
View File
@@ -0,0 +1,29 @@
document.querySelectorAll(".menu-button").forEach((button) => {
button.addEventListener("click", () => {
const open = document.body.classList.toggle("nav-open");
button.setAttribute("aria-expanded", String(open));
});
});
const instanceSearch = document.querySelector("#instance-search");
if (instanceSearch) {
const cards = [...document.querySelectorAll("[data-instance-card]")];
const empty = document.querySelector("[data-search-empty]");
const filter = () => {
const query = instanceSearch.value.trim().toLocaleLowerCase();
let visible = 0;
cards.forEach((card) => {
const matches = card.dataset.search.toLocaleLowerCase().includes(query);
card.hidden = !matches;
visible += Number(matches);
});
empty.hidden = visible !== 0;
};
instanceSearch.addEventListener("input", filter);
document.addEventListener("keydown", (event) => {
if (event.key === "/" && document.activeElement !== instanceSearch) {
event.preventDefault();
instanceSearch.focus();
}
});
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+258
View File
@@ -0,0 +1,258 @@
.app-main {
isolation: isolate;
position: relative;
}
.app-main::before {
background: url("/static/dogama-logo.png") center / min(58vw, 720px) no-repeat;
content: "";
filter: saturate(.8);
inset: 0;
opacity: .025;
pointer-events: none;
position: fixed;
z-index: 0;
}
.app-main::after {
background-image:
linear-gradient(90deg, #20d9f312 1px, transparent 1px),
linear-gradient(#f02fb80d 1px, transparent 1px);
background-size: 64px 64px;
bottom: 0;
content: "";
height: 34vh;
left: 220px;
mask-image: linear-gradient(transparent, #000);
opacity: .45;
perspective: 400px;
pointer-events: none;
position: fixed;
right: 0;
transform: perspective(380px) rotateX(62deg) scale(1.25);
transform-origin: bottom;
z-index: 0;
}
.app-main > * {
position: relative;
z-index: 1;
}
.sidebar {
padding: 26px 8px 18px;
width: 200px;
}
.app-main {
margin-left: 200px;
}
.app-main::after {
left: 200px;
}
.brand {
gap: 8px;
padding: 0 8px 28px;
}
.brand img {
height: 76px;
width: 76px;
}
.brand strong {
font-size: 1.45rem;
}
.brand small {
display: none !important;
}
.sidebar nav {
gap: 2px;
margin: 8px -8px 0;
}
.sidebar nav a,
.sidebar nav span.disabled {
align-items: center;
display: flex;
gap: 11px;
min-height: 52px;
padding-left: 24px;
padding-right: 20px;
}
.sidebar nav a.active {
border-left-color: var(--accent-cyan);
border-radius: 0 10px 10px 0;
border-right-color: var(--accent-magenta);
box-shadow: inset 4px 0 var(--accent-cyan), inset -3px 0 var(--accent-magenta), 0 0 22px #cf2fc326;
}
.sidebar nav a::before,
.sidebar nav span.disabled::before {
color: #a9b4ca;
display: grid;
flex: 0 0 22px;
font-size: 1rem;
height: 22px;
place-items: center;
width: 22px;
}
.sidebar nav a.active::before {
color: var(--accent-cyan);
}
.nav-instances::before { content: "\25A4"; }
.nav-catalog::before { content: "\2667"; }
.nav-backups::before { content: "\25A3"; }
.nav-settings::after { content: "\203A"; margin-left: auto; }
.sidebar nav span.disabled {
border: 1px solid transparent;
border-radius: 10px;
color: #a5aec2;
cursor: not-allowed;
font-weight: 650;
opacity: .72;
padding-bottom: 11px;
padding-top: 11px;
}
.account-card {
background: #0d1120cc;
border: 1px solid #252c45;
border-radius: 10px;
margin-bottom: 10px;
padding: 13px 12px;
}
.account .avatar {
background: #151126;
border: 1px solid var(--accent-magenta);
color: #ef54cf;
height: 42px;
width: 42px;
}
.account-chevron {
color: var(--text-secondary);
margin-left: auto;
}
.account form {
border-top: 1px solid #22283e;
padding-top: 10px;
}
.account .signout-button {
align-items: center;
background: transparent;
border: 0;
box-shadow: none;
color: var(--text-secondary);
display: flex;
gap: 11px;
padding: 10px 12px;
width: 100%;
}
.account .signout-button:hover {
color: var(--text-primary);
}
.nav-dashboard::before { content: "⌂"; }
.nav-audit::before { content: "▣"; }
.nav-settings::before { content: "⚙"; }
.page-heading {
align-items: center;
gap: 24px;
}
.instance-search {
max-width: 320px;
position: relative;
width: min(100%, 320px);
}
.instance-search input {
background: #090c19cc;
border-color: #3c4565;
border-radius: 9px;
padding-left: 42px;
padding-right: 42px;
}
.instance-search::before {
color: var(--text-secondary);
content: "⌕";
font-size: 1.35rem;
left: 13px;
position: absolute;
top: 5px;
z-index: 1;
}
.instance-search kbd {
background: #15192b;
border: 1px solid #313753;
border-radius: 5px;
color: var(--text-secondary);
font: inherit;
padding: 0 7px;
position: absolute;
right: 10px;
top: 9px;
}
.summary-grid {
margin: 0 auto 22px;
}
.summary-grid article {
background: linear-gradient(145deg, #111628dd, #0b0f1ddd);
min-height: 76px;
}
.instance-grid {
position: relative;
}
.instance-card[hidden] {
display: none;
}
.search-empty {
color: var(--text-secondary);
padding: 48px 20px;
text-align: center;
}
button,
.link-button {
background: linear-gradient(100deg, #782fd9, #e52eb0 65%, #14c9ed);
border: 1px solid #ef45c2;
box-shadow: 0 0 18px #f02fb81f;
}
.button-secondary {
background: #11162a;
border-color: #38415f;
box-shadow: none;
}
.tabs {
background: #0c1020dd;
border-color: #303955;
}
@media (max-width: 760px) {
.app-main::after { left: 0; }
.page-heading { align-items: stretch; display: grid; }
.instance-search { max-width: none; width: 100%; }
}
+1
View File
@@ -0,0 +1 @@
{{define "audit.html"}}<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}} · DoGaMa</title><link rel="stylesheet" href="/static/app.v1.css"></head><body class="app-body"><a class="skip-link" href="#main-content">Skip to content</a>{{template "sidebar" .}}<main class="app-main" id="main-content"><header class="mobile-header"><img src="/static/dogama-logo.png" alt="DoGaMa"><strong>DoGaMa</strong><button class="menu-button" type="button" aria-expanded="false" aria-controls="app-navigation">Menu</button></header><div class="page-heading"><div><p class="eyebrow">Administration</p><h1>Audit</h1><p>Review significant authentication and mutation events.</p></div></div><section class="panel"><form class="filter-grid" method="get" action="/audit"><label>Actor ID<input name="actor_id" value="{{.AuditActor}}"></label><label>Instance ID<input name="instance_id" value="{{.AuditInstance}}"></label><label>Action<input name="action" value="{{.AuditAction}}"></label><label>Outcome<select name="outcome"><option value="">Any</option><option value="allowed">Allowed</option><option value="denied">Denied</option><option value="failed">Failed</option></select></label><button type="submit">Filter audit</button></form><div class="table-wrap"><table><thead><tr><th>Time</th><th>Actor</th><th>Action</th><th>Outcome</th><th>Instance</th></tr></thead><tbody>{{range .AuditEvents}}<tr><td>{{.OccurredAt.Format "2006-01-02 15:04:05Z"}}</td><td>{{.ActorLabel}}</td><td><code>{{.Action}}</code></td><td><span class="outcome outcome-{{.Outcome}}">{{.Outcome}}</span></td><td>{{.InstanceID}}</td></tr>{{else}}<tr><td colspan="5">No audit event matches these filters.</td></tr>{{end}}</tbody></table></div></section></main></body></html>{{end}}
+1
View File
@@ -0,0 +1 @@
{{define "sidebar"}}<aside class="sidebar" id="app-navigation"><a class="brand" href="/"><img src="/static/dogama-logo.png" alt="DoGaMa logo"><span><strong>DoGaMa</strong><small>Game server control</small></span></a><nav aria-label="Primary navigation"><a class="nav-dashboard {{if eq .ActivePage "dashboard"}}active{{end}}" href="/">Dashboard</a><span class="nav-instances disabled" aria-disabled="true" title="Instances page planned for a future milestone">Instances</span><span class="nav-catalog disabled" aria-disabled="true" title="Catalog page planned for a future milestone">Catalog</span><span class="nav-backups disabled" aria-disabled="true" title="Backups page planned for a future milestone">Backups</span>{{if .IsAdmin}}<a class="nav-audit {{if eq .ActivePage "audit"}}active{{end}}" href="/audit">Audit</a><a class="nav-settings {{if eq .ActivePage "settings"}}active{{end}}" href="/settings">Settings</a>{{end}}</nav><div class="account"><p class="sr-only">Signed in as <strong>{{.User.Username}}</strong>.</p><div class="account-card"><span class="avatar" aria-hidden="true">DG</span><span><strong>{{.User.Username}}</strong><small>{{.User.Role}}</small></span><span class="account-chevron" aria-hidden="true">⌄</span></div><form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button class="signout-button" type="submit"><span aria-hidden="true">⇥</span> Sign out</button></form></div></aside><script src="/static/app.v2.js" defer></script>{{end}}
+10 -15
View File
@@ -1,16 +1,11 @@
{{define "home.html"}}<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}} · DoGaMa</title><link rel="stylesheet" href="/static/app.v1.css"></head>
<body><header><strong>{{msg "brand"}}</strong><form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button type="submit">{{msg "logout.submit"}}</button></form></header>
<main><h1>{{msg "dashboard.title"}}</h1><p>{{msg "dashboard.signed_in"}} <strong>{{.User.Username}}</strong>.</p><p>{{msg "dashboard.ready"}}</p>
{{if .IsAdmin}}
<section><h2>Notification channels</h2><p>Secrets are encrypted and never displayed after saving. Delivery is queued and retried without blocking operations.</p>
<ul>{{range .Channels}}<li><strong>{{.Name}}</strong> — {{.Type}} · {{if .Enabled}}enabled{{else}}disabled{{end}}
<form class="inline" method="post" action="/admin/notification-channels/{{.ID}}/test"><input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><button type="submit">Send test</button></form>
<form class="inline" method="post" action="/admin/notification-channels/{{.ID}}/delete"><input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><button class="danger" type="submit">Delete</button></form></li>{{else}}<li>No channel configured.</li>{{end}}</ul>
<form method="post" action="/admin/notification-channels"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>Name<input name="name" required></label><label>Type<select name="type"><option value="webhook">HTTPS webhook</option><option value="discord">Discord webhook</option><option value="email">SMTP email</option></select></label><label>HTTPS URL<input name="url" type="url" placeholder="https://…"></label><label>Signing secret<input name="signing_secret" type="password" autocomplete="new-password"></label><div class="grid"><label>SMTP host<input name="host"></label><label>Port<input name="port" inputmode="numeric" placeholder="587"></label><label>Username<input name="smtp_username"></label><label>Password<input name="smtp_password" type="password" autocomplete="new-password"></label><label>From<input name="from" type="email"></label><label>Recipients<input name="to"></label></div><label>Events (space separated)<input name="events" value="backup.failed update.failed restore.failed security.required"></label><button type="submit">Add channel</button></form></section>
<section><h2>Audit retention</h2><form method="post" action="/admin/audit-policy"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><div class="grid"><label>Retention days<input name="retention_days" type="number" min="0" max="3650" value="{{.AuditPolicy.RetentionDays}}"></label><label>Maximum entries<input name="maximum_count" type="number" min="0" max="1000000" value="{{.AuditPolicy.MaximumCount}}"></label></div><p>Zero means unlimited and may grow the database indefinitely.</p><button type="submit">Save retention</button></form>
<form method="post" action="/admin/audit-purge"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>Delete events before<input name="before" type="date" required></label><label><input name="confirm" type="checkbox" value="yes"> Confirm bounded audit purge</label><button class="danger" type="submit">Purge audit events</button></form></section>
<section><h2>Recent audit events</h2><form method="get" action="/"><div class="grid"><label>Actor ID<input name="actor_id" value="{{.AuditActor}}"></label><label>Instance ID<input name="instance_id" value="{{.AuditInstance}}"></label><label>Action<input name="action" value="{{.AuditAction}}"></label><label>Outcome<select name="outcome"><option value="">Any</option><option value="allowed">Allowed</option><option value="denied">Denied</option><option value="failed">Failed</option></select></label></div><button type="submit">Filter audit</button></form>
<div class="table-wrap"><table><thead><tr><th>Time</th><th>Actor</th><th>Action</th><th>Outcome</th><th>Instance</th></tr></thead><tbody>{{range .AuditEvents}}<tr><td>{{.OccurredAt.Format "2006-01-02 15:04:05Z"}}</td><td>{{.ActorLabel}}</td><td><code>{{.Action}}</code></td><td>{{.Outcome}}</td><td>{{.InstanceID}}</td></tr>{{else}}<tr><td colspan="5">No audit event.</td></tr>{{end}}</tbody></table></div></section>
<section><h2>Game-container labels</h2><form method="post" action="/admin/game-container-labels"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>Global labels<textarea name="labels" rows="8" placeholder="key=value">{{.GlobalLabels}}</textarea></label><p>One label per line. Available variables: <code>{{`{{game.name}}`}}</code>, <code>{{`{{game.id}}`}}</code>, <code>{{`{{game.icon_url}}`}}</code>, <code>{{`{{instance.name}}`}}</code>, <code>{{`{{instance.id}}`}}</code>, <code>{{`{{instance.slug}}`}}</code>, <code>{{`{{server.name}}`}}</code>.</p><label><input type="radio" name="apply" value="next_start" checked> Apply on next start</label><label><input type="radio" name="apply" value="immediate"> Apply immediately</label><aside class="warning"><strong>Immediate application stops and recreates affected containers.</strong> Connected players are disconnected immediately. Persistent data is preserved and each instance returns to its previous running or stopped state.</aside><label><input type="checkbox" name="confirm_disconnection" value="yes"> I understand the immediate-disconnection warning</label><button type="submit">Save game-container labels</button></form></section>
{{end}}</main></body></html>{{end}}
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}} · DoGaMa</title><link rel="stylesheet" href="/static/app.v2.css"><link rel="stylesheet" href="/static/theme.v2.css"></head>
<body class="app-body"><a class="skip-link" href="#main-content">Skip to content</a>{{template "sidebar" .}}
<main class="app-main" id="main-content"><header class="mobile-header"><img src="/static/dogama-logo.png" alt="DoGaMa"><strong>DoGaMa</strong><button class="menu-button" type="button" aria-expanded="false" aria-controls="app-navigation">Menu</button></header>
<div class="page-heading"><div><p class="eyebrow">Dashboard</p><h1>Game servers</h1><p>Overview of all your game server instances.</p></div>{{if .Instances}}<search class="instance-search"><label class="sr-only" for="instance-search">Search instances</label><input id="instance-search" type="search" placeholder="Search instance…" autocomplete="off"><kbd>/</kbd></search>{{end}}</div>
{{if .Instances}}<section class="summary-grid" aria-label="Instance summary"><article class="summary-total"><span>Total instances</span><strong>{{len .Instances}}</strong></article><article class="summary-running"><span>Running</span><strong>{{index .StatusCounts "running"}}</strong></article><article class="summary-stopped"><span>Stopped</span><strong>{{index .StatusCounts "stopped"}}</strong></article><article class="summary-warning"><span>Updating</span><strong>{{index .StatusCounts "updating"}}</strong></article><article class="summary-error"><span>Error</span><strong>{{index .StatusCounts "error"}}</strong></article></section>
<section class="instance-grid" aria-label="Game server instances">{{range .Instances}}<article class="instance-card" data-instance-card data-search="{{.Preview.Game.Name}} {{.Preview.DisplayName}} {{statusLabel .LifecycleState}}"><div class="instance-art">{{if .Preview.Game.ArtworkURL}}<img src="{{.Preview.Game.ArtworkURL}}" alt="{{.Preview.Game.Name}} artwork">{{else if .Preview.Game.LogoURL}}<img src="{{.Preview.Game.LogoURL}}" alt="{{.Preview.Game.Name}} logo">{{else if .Preview.Game.IconURL}}<img src="{{.Preview.Game.IconURL}}" alt="{{.Preview.Game.Name}} logo">{{else}}<span aria-hidden="true">DG</span>{{end}}</div>
<div class="instance-card-body"><h2>{{.Preview.Game.Name}}</h2><p class="instance-name">{{.Preview.DisplayName}}</p><span class="status-badge status-{{statusClass .LifecycleState}}"><i></i>{{statusLabel .LifecycleState}}</span></div></article>{{end}}</section>
<p class="search-empty" data-search-empty hidden>No instance matches this search.</p>
{{else}}<section class="empty-state"><span class="empty-icon" aria-hidden="true">◇</span><h2>No game servers yet</h2><p>Instances you are allowed to view will appear here.</p></section>{{end}}
</main></body></html>{{end}}
+1 -3
View File
@@ -1,3 +1 @@
{{define "login.html"}}<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}} · DoGaMa</title><link rel="stylesheet" href="/static/app.v1.css"></head>
<body><main><h1>{{msg "login.heading"}}</h1>{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}<form method="post" action="/login"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>{{msg "field.username"}}<input name="username" autocomplete="username" required></label><label>{{msg "field.password"}}<input type="password" name="password" autocomplete="current-password" required></label><button type="submit">{{msg "login.submit"}}</button></form></main></body></html>{{end}}
{{define "login.html"}}<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}} · DoGaMa</title><link rel="stylesheet" href="/static/app.v1.css"></head><body class="auth-body"><main class="auth-card"><img class="auth-logo" src="/static/dogama-logo.png" alt="DoGaMa logo"><p class="eyebrow">Game server control</p><h1>{{msg "login.heading"}}</h1><p class="auth-copy">Manage your game servers from one secure place.</p>{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}<form method="post" action="/login"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>{{msg "field.username"}}<input name="username" autocomplete="username" required></label><label>{{msg "field.password"}}<input type="password" name="password" autocomplete="current-password" required></label><button type="submit">{{msg "login.submit"}}</button></form></main></body></html>{{end}}
+4
View File
@@ -0,0 +1,4 @@
{{define "settings.html"}}<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}} · DoGaMa</title><link rel="stylesheet" href="/static/app.v1.css"></head><body class="app-body"><a class="skip-link" href="#main-content">Skip to content</a>{{template "sidebar" .}}<main class="app-main" id="main-content"><header class="mobile-header"><img src="/static/dogama-logo.png" alt="DoGaMa"><strong>DoGaMa</strong><button class="menu-button" type="button" aria-expanded="false" aria-controls="app-navigation">Menu</button></header><div class="page-heading"><div><p class="eyebrow">Administration</p><h1>Settings</h1><p>Configure product services without crowding the server overview.</p></div></div><nav class="tabs" aria-label="Settings sections"><a href="#notifications">Notifications</a><a href="#audit">Audit</a><a href="#containers">Game containers</a></nav>
<section class="panel settings-section" id="notifications"><div class="section-heading"><div><h2>Notification channels</h2><p>Secrets remain encrypted and are never displayed after saving.</p></div></div><ul class="channel-list">{{range .Channels}}<li><span><strong>{{.Name}}</strong><small>{{.Type}} · {{if .Enabled}}enabled{{else}}disabled{{end}}</small></span><div><form class="inline" method="post" action="/admin/notification-channels/{{.ID}}/test"><input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><button class="button-secondary" type="submit">Send test</button></form><form class="inline" method="post" action="/admin/notification-channels/{{.ID}}/delete"><input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><button class="danger" type="submit">Delete</button></form></div></li>{{else}}<li class="muted">No channel configured.</li>{{end}}</ul><form method="post" action="/admin/notification-channels"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><div class="form-grid"><label>Name<input name="name" required></label><label>Type<select name="type"><option value="webhook">HTTPS webhook</option><option value="discord">Discord webhook</option><option value="email">SMTP email</option></select></label><label>HTTPS URL<input name="url" type="url" placeholder="https://…"></label><label>Signing secret<input name="signing_secret" type="password" autocomplete="new-password"></label><label>SMTP host<input name="host"></label><label>Port<input name="port" inputmode="numeric" placeholder="587"></label><label>Username<input name="smtp_username"></label><label>Password<input name="smtp_password" type="password" autocomplete="new-password"></label><label>From<input name="from" type="email"></label><label>Recipients<input name="to"></label></div><label>Events (space separated)<input name="events" value="backup.failed update.failed restore.failed security.required"></label><button type="submit">Add channel</button></form></section>
<section class="panel settings-section" id="audit"><div class="section-heading"><div><h2>Audit retention</h2><p>Control history size and perform explicit bounded purges.</p></div><a class="button-secondary link-button" href="/audit">View audit events</a></div><div class="two-panels"><form method="post" action="/admin/audit-policy"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><div class="form-grid"><label>Retention days<input name="retention_days" type="number" min="0" max="3650" value="{{.AuditPolicy.RetentionDays}}"></label><label>Maximum entries<input name="maximum_count" type="number" min="0" max="1000000" value="{{.AuditPolicy.MaximumCount}}"></label></div><p class="help">Zero means unlimited and may grow the database indefinitely.</p><button type="submit">Save retention</button></form><form method="post" action="/admin/audit-purge"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>Delete events before<input name="before" type="date" required></label><label class="check"><input name="confirm" type="checkbox" value="yes"> Confirm bounded audit purge</label><button class="danger" type="submit">Purge audit events</button></form></div></section>
<section class="panel settings-section" id="containers"><div class="section-heading"><div><h2>Game-container labels</h2><p>These labels apply only to game-server containers.</p></div></div><form method="post" action="/admin/game-container-labels"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>Global labels<textarea name="labels" rows="8" placeholder="key=value">{{.GlobalLabels}}</textarea></label><p class="help">Available variables: <code>{{`{{game.name}}`}}</code>, <code>{{`{{game.id}}`}}</code>, <code>{{`{{game.icon_url}}`}}</code>, <code>{{`{{instance.name}}`}}</code>, <code>{{`{{instance.id}}`}}</code>, <code>{{`{{instance.slug}}`}}</code>, <code>{{`{{server.name}}`}}</code>.</p><fieldset><legend>Application</legend><label class="check"><input type="radio" name="apply" value="next_start" checked> Apply on next start</label><label class="check"><input type="radio" name="apply" value="immediate"> Apply immediately</label></fieldset><aside class="warning"><strong>Immediate application stops and recreates affected containers.</strong> Connected players are disconnected immediately. Persistent data is preserved and each instance returns to its previous running or stopped state.</aside><label class="check"><input type="checkbox" name="confirm_disconnection" value="yes"> I understand the immediate-disconnection warning</label><button type="submit">Save game-container labels</button></form></section></main></body></html>{{end}}
+1 -3
View File
@@ -1,3 +1 @@
{{define "setup.html"}}<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}} · DoGaMa</title><link rel="stylesheet" href="/static/app.v1.css"></head>
<body><main><h1>{{msg "setup.heading"}}</h1><p>{{msg "setup.introduction"}}</p>{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}<form method="post" action="/setup"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>{{msg "field.username"}}<input name="username" minlength="3" maxlength="64" autocomplete="username" required></label><label>{{msg "field.password"}}<input type="password" name="password" minlength="12" maxlength="1024" autocomplete="new-password" required></label><button type="submit">{{msg "setup.submit"}}</button></form></main></body></html>{{end}}
{{define "setup.html"}}<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}} · DoGaMa</title><link rel="stylesheet" href="/static/app.v1.css"></head><body class="auth-body"><main class="auth-card"><img class="auth-logo" src="/static/dogama-logo.png" alt="DoGaMa logo"><p class="eyebrow">First launch</p><h1>{{msg "setup.heading"}}</h1><p class="auth-copy">{{msg "setup.introduction"}}</p>{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}<form method="post" action="/setup"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>{{msg "field.username"}}<input name="username" minlength="3" maxlength="64" autocomplete="username" required></label><label>{{msg "field.password"}}<input type="password" name="password" minlength="12" maxlength="1024" autocomplete="new-password" required></label><button type="submit">{{msg "setup.submit"}}</button></form></main></body></html>{{end}}
+5 -1
View File
@@ -21,7 +21,7 @@
"game": {
"type": "object",
"additionalProperties": false,
"required": ["id", "name", "description"],
"required": ["id", "name", "description", "artwork"],
"properties": {
"id": { "$ref": "#/$defs/id" },
"name": { "type": "string", "minLength": 1, "maxLength": 100 },
@@ -30,7 +30,10 @@
"artwork": {
"type": "object",
"additionalProperties": false,
"required": ["logo", "image", "attribution"],
"properties": {
"logo": { "$ref": "#/$defs/relativeAssetPath" },
"image": { "$ref": "#/$defs/relativeAssetPath" },
"poster_source_url": { "type": "string", "format": "uri", "pattern": "^https://" },
"logo_source_url": { "type": "string", "format": "uri", "pattern": "^https://" },
"attribution": { "type": "string", "maxLength": 500 }
@@ -214,6 +217,7 @@
},
"$defs": {
"id": { "type": "string", "pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$", "minLength": 1, "maxLength": 100 },
"relativeAssetPath": { "type": "string", "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[a-zA-Z0-9._/-]+$", "minLength": 1, "maxLength": 300 },
"semver": { "type": "string", "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?$" },
"resources": {
"type": "object",