feat(catalog): add persistent local templates and game library
CI / validate (pull_request) Successful in 21m2s
CI / validate (pull_request) Successful in 21m2s
This commit is contained in:
@@ -56,12 +56,18 @@ Never delete `data`, the server/backup directories or the internal `agent_state`
|
||||
|
||||
| Host setting | Container path | Contents |
|
||||
|---|---|---|
|
||||
| `DOGAMA_DATA_PATH` | `/var/lib/dogama` | SQLite database, imports and the application-only master key |
|
||||
| `DOGAMA_DATA_PATH` | `/var/lib/dogama` | SQLite database, imports, templates and the application-only master key |
|
||||
| `DOGAMA_SERVERS_PATH` | `/srv/game-servers` | Game-server configuration and player data |
|
||||
| `DOGAMA_BACKUPS_PATH` | `/srv/game-backups` | DoGaMa-managed game backups |
|
||||
|
||||
`agent_state` and `agent_auth` are internal named volumes. The first contains the authenticated agent registry that prevents operations against unknown containers; the second contains only the shared authentication token and is the only agent storage mounted read-only by the application. They are not user configuration surfaces, but both must be backed up with the other DoGaMa state.
|
||||
|
||||
## Local game templates
|
||||
|
||||
Templates are stored under `${DOGAMA_DATA_PATH:-./data}/templates` on the Docker host, mounted in DoGaMa as `/var/lib/dogama/templates`. On the first start, DoGaMa copies its official templates there; Palworld is therefore immediately available. Existing local files are never overwritten on a restart or image update.
|
||||
|
||||
Create one directory per game and place its `template.yaml` (plus any local assets it references) in that directory. Open **Catalog** as an administrator and select **Scan** to apply additions, edits and removals without restarting the application. Invalid templates are ignored while valid ones remain available; the Scan result identifies each invalid directory without exposing host paths or secrets. See [the template guide](docs/operations/local-templates.md) for the complete format.
|
||||
|
||||
## Ports
|
||||
|
||||
| Port | Exposure | Purpose |
|
||||
@@ -104,6 +110,7 @@ Release images and archives target Linux `amd64` and `arm64`. The full validatio
|
||||
## Documentation
|
||||
|
||||
- [Deployment and release](docs/operations/deployment-and-release.md)
|
||||
- [Local templates](docs/operations/local-templates.md)
|
||||
- [Current project state](docs/PROJECT-STATE.md)
|
||||
- [Architecture](docs/architecture/system-architecture.md)
|
||||
- [Restricted Docker agent](docs/architecture/docker-agent.md)
|
||||
|
||||
@@ -9,7 +9,8 @@ source:
|
||||
game:
|
||||
id: palworld
|
||||
name: Palworld
|
||||
description: Official Palworld dedicated server reference for DoGaMa.
|
||||
description: DoGaMa template for a Palworld dedicated server, including the private REST API used by the optional integration.
|
||||
image: https://www.codesproduit.fr/wp-content/uploads/2024/03/PALWORLD-PC-COVER.jpg
|
||||
website: https://www.palworldgame.com/
|
||||
artwork:
|
||||
logo: assets/icon.png
|
||||
@@ -198,4 +199,3 @@ updates:
|
||||
compatibility:
|
||||
minimum_manager_version: 1.0.0
|
||||
requires_instance_migration: false
|
||||
|
||||
|
||||
+17
-4
@@ -45,6 +45,7 @@ func run(logger *slog.Logger) error {
|
||||
listenAddress := environment("DOGAMA_LISTEN_ADDRESS", ":8080")
|
||||
databasePath := environment("DOGAMA_DATABASE_PATH", "dogama.db")
|
||||
serversRoot := environment("DOGAMA_SERVERS_ROOT", "/srv/game-servers")
|
||||
templatesRoot := environment("DOGAMA_TEMPLATES_ROOT", "/var/lib/dogama/templates")
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
@@ -53,15 +54,18 @@ func run(logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
|
||||
if err := catalog.InitializeOfficial(templatesRoot, catalogdata.Files); err != nil {
|
||||
return err
|
||||
}
|
||||
scan, err := catalog.ScanDir(templatesRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
repository := sqlite.NewRepository(db)
|
||||
if err := repository.Sync(ctx, snapshots); err != nil {
|
||||
if err := repository.Replace(ctx, scan.Valid); err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info("local catalog synchronized", "event", "catalog.synchronized", "template_count", len(snapshots))
|
||||
logger.Info("local catalog synchronized", "event", "catalog.synchronized", "template_count", len(scan.Valid), "invalid_template_count", len(scan.Errors))
|
||||
var handler http.Handler
|
||||
var lifecycle *instance.LifecycleService
|
||||
var backupService *backup.Service
|
||||
@@ -112,7 +116,16 @@ func run(logger *slog.Logger) error {
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
handler, err = web.NewHandlerComplete(auth.New(db), repository, lifecycle, backupService, importService, auditService, notificationService, logger)
|
||||
handler, err = web.NewHandlerCompleteWithCatalog(auth.New(db), repository, lifecycle, backupService, importService, auditService, notificationService, func(ctx context.Context) (catalog.ScanResult, error) {
|
||||
result, scanErr := catalog.ScanDir(templatesRoot)
|
||||
if scanErr != nil {
|
||||
return result, scanErr
|
||||
}
|
||||
if syncErr := repository.Replace(ctx, result.Valid); syncErr != nil {
|
||||
return result, syncErr
|
||||
}
|
||||
return result, nil
|
||||
}, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -70,11 +70,12 @@ Read this compact operational baseline before starting a milestone. Open detaile
|
||||
- Notification delivery attempts are capped at five with exponential minute-scale backoff and never determine the originating operation result.
|
||||
- Audit retention defaults to 30 days and 10,000 entries; zero explicitly selects unlimited retention/count within documented bounds.
|
||||
- At least one active global administrator is always retained; deactivation revokes that user's sessions atomically.
|
||||
- The local template directory (`/var/lib/dogama/templates`, under the application data bind mount) is the catalog source of truth. Bundled templates are copied only when their destination files are absent; an administrator Scan validates each directory independently and refreshes the available SQLite index without network fetches.
|
||||
|
||||
## Known limitations and debt
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- Instance detail and backup management remain API-first. The Catalog is a server-rendered local-template library; its Deploy action remains disabled pending the deployment workflow.
|
||||
- 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.
|
||||
- The two DoGaMa services run as root inside their container namespaces for bind-mount portability. Risk is bounded with read-only image filesystems, all capabilities dropped, `no-new-privileges`, no Docker socket in the main application and a private typed agent API; rootless Docker and user-namespace remapping remain host-level deployment choices.
|
||||
- In-place migration of a successfully initialized v0.1.0 data directory has not been validated because that release wrote the application key under a different container identity. Preserve all stores and test a copied deployment rather than assuming compatibility.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Local game templates
|
||||
|
||||
The local template directory is `${DOGAMA_DATA_PATH:-./data}/templates` on the Docker host and `/var/lib/dogama/templates` inside the application container. The production Compose file already persists it through the existing DoGaMa data bind mount; no extra environment variable or container privilege is required.
|
||||
|
||||
At first start, official templates packaged in the DoGaMa image are copied into this directory. The copy is fill-only: a template or asset that already exists locally is never overwritten. This protects administrator customizations across restarts and image updates. Remove a local template directory yourself only when you intentionally want to remove it, then use **Catalog → Scan**.
|
||||
|
||||
## Managing templates
|
||||
|
||||
Use one direct child directory per game:
|
||||
|
||||
```text
|
||||
./data/templates/
|
||||
palworld/
|
||||
template.yaml
|
||||
assets/
|
||||
```
|
||||
|
||||
Add or edit files on the Docker host, then sign in as an administrator and press **Scan** in Catalog. The scan reads each directory independently, validates it, updates changed templates and removes deleted templates from the available catalog. A broken template never prevents other valid templates from appearing. The result lists the directory name and a safe validation reason; it intentionally does not disclose absolute host paths, stack traces or secrets.
|
||||
|
||||
## Template format
|
||||
|
||||
Templates use schema version `1` and are strict YAML documents. Existing deployment fields remain required: container image/tag, ports, storage mounts, configuration fields, capabilities, backup, healthcheck, imports, updates and compatibility. DoGaMa rejects unknown fields and unsafe paths, asset checksums, invalid configuration fields and invalid Docker-related declarations; a template cannot grant arbitrary Docker access.
|
||||
|
||||
The catalog information is under `game` and `requirements`:
|
||||
|
||||
```yaml
|
||||
schema_version: 1
|
||||
id: example-game
|
||||
version: 1.0.0
|
||||
source: { type: local }
|
||||
game:
|
||||
id: example-game
|
||||
name: Example Game
|
||||
description: Concise dedicated-server description.
|
||||
image: https://example.invalid/cover.jpg # vertical public cover URL
|
||||
artwork:
|
||||
logo: assets/icon.png # retained deployment artwork asset
|
||||
image: assets/banner.jpg
|
||||
attribution: Your attribution text
|
||||
requirements:
|
||||
minimum: { cpu_cores: 2, memory_mb: 4096, storage_gb: 20, other: ["Network connection"] }
|
||||
recommended: { cpu_cores: 4, memory_mb: 8192, storage_gb: 40 }
|
||||
```
|
||||
|
||||
`game.image` is the vertical cover displayed by Catalog and the game page. Its URL must be HTTPS, but validation never fetches it: an unavailable remote cover does not block a scan and the interface has a graphical fallback. `requirements.minimum` and `requirements.recommended` contain generic CPU, memory and storage values plus optional `other` lines. Recommended resources cannot be below minimum resources.
|
||||
|
||||
`configuration.fields` retains the current validated configuration contract, including field `id`, label, type, target, apply behavior, visibility, required flag and suitable defaults or bounds. The bundled `palworld/template.yaml` is the complete reference example.
|
||||
|
||||
The **Deploy** action on the game page is deliberately disabled in this milestone. It does not create an instance; the deployment workflow belongs to the next milestone.
|
||||
@@ -0,0 +1,41 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// InitializeOfficial copies packaged templates into an empty local destination.
|
||||
// Existing files are deliberately preserved: administrators own local changes.
|
||||
func InitializeOfficial(destination string, packaged fs.FS) error {
|
||||
if err := os.MkdirAll(destination, 0o755); err != nil {
|
||||
return fmt.Errorf("create template directory: %w", err)
|
||||
}
|
||||
return fs.WalkDir(packaged, ".", func(name string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if name == "." {
|
||||
return nil
|
||||
}
|
||||
target := filepath.Join(destination, filepath.FromSlash(name))
|
||||
if entry.IsDir() {
|
||||
return os.MkdirAll(target, 0o755)
|
||||
}
|
||||
if existing, err := os.Lstat(target); err == nil && !existing.IsDir() {
|
||||
return nil
|
||||
} else if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
body, err := fs.ReadFile(packaged, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(target, body, 0o644)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package catalog_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
|
||||
)
|
||||
|
||||
func TestInitializeOfficialAndScanDirPreservesLocalFiles(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := catalog.InitializeOfficial(root, catalogdata.Files); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := catalog.ScanDir(root)
|
||||
if err != nil || len(first.Valid) != 1 || first.Valid[0].Template.Game.Image == "" {
|
||||
t.Fatalf("first scan = %#v, %v", first, err)
|
||||
}
|
||||
if len(first.Valid[0].Template.Configuration.Fields) == 0 {
|
||||
t.Fatal("configuration fields were lost")
|
||||
}
|
||||
path := filepath.Join(root, "palworld", "template.yaml")
|
||||
if err := os.WriteFile(path, []byte("local customization"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := catalog.InitializeOfficial(root, catalogdata.Files); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil || string(body) != "local customization" {
|
||||
t.Fatalf("local template was overwritten: %q, %v", body, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanDirKeepsValidTemplatesWhenOneIsInvalid(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := catalog.InitializeOfficial(root, catalogdata.Files); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := catalogdata.Files.ReadFile("palworld/template.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
broken := strings.Replace(string(body), "image: https://www.codesproduit.fr/wp-content/uploads/2024/03/PALWORLD-PC-COVER.jpg", "image: not-a-url", 1)
|
||||
if err := os.Mkdir(filepath.Join(root, "broken"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "broken", "template.yaml"), []byte(broken), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := catalog.ScanDir(root)
|
||||
if err != nil || result.Found != 2 || len(result.Valid) != 1 || len(result.Errors) != 1 {
|
||||
t.Fatalf("scan = %#v, %v", result, err)
|
||||
}
|
||||
if result.Errors[0].Template != "broken" || !strings.Contains(result.Errors[0].Message, "/game/image") {
|
||||
t.Fatalf("error = %#v", result.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanDirRejectsTemplateSymlinks(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := catalog.InitializeOfficial(root, catalogdata.Files); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join(root, "palworld", "assets", "icon.png"), filepath.Join(root, "palworld", "assets", "linked-icon.png")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := catalog.ScanDir(root)
|
||||
if err != nil || len(result.Valid) != 0 || len(result.Errors) != 1 {
|
||||
t.Fatalf("scan = %#v, %v", result, err)
|
||||
}
|
||||
if result.Errors[0].Message != "template contains unsupported symbolic links" {
|
||||
t.Fatalf("error = %#v", result.Errors)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ type Summary struct {
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
Description string `json:"description"`
|
||||
Image string `json:"image"`
|
||||
TrustStatus string `json:"trust_status"`
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
@@ -23,6 +24,7 @@ type Summary struct {
|
||||
// Repository persists immutable catalog snapshots.
|
||||
type Repository interface {
|
||||
Sync(context.Context, []Snapshot) error
|
||||
Replace(context.Context, []Snapshot) error
|
||||
List(context.Context) ([]Summary, error)
|
||||
Get(context.Context, string, string) (Snapshot, error)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -47,6 +49,7 @@ type Template struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Image string `json:"image"`
|
||||
Artwork struct {
|
||||
Logo string `json:"logo"`
|
||||
Image string `json:"image"`
|
||||
@@ -118,9 +121,10 @@ type Template struct {
|
||||
}
|
||||
|
||||
type Resources struct {
|
||||
CPUCores float64 `json:"cpu_cores"`
|
||||
MemoryMB int `json:"memory_mb"`
|
||||
StorageGB int `json:"storage_gb"`
|
||||
CPUCores float64 `json:"cpu_cores"`
|
||||
MemoryMB int `json:"memory_mb"`
|
||||
StorageGB int `json:"storage_gb"`
|
||||
Other []string `json:"other,omitempty"`
|
||||
}
|
||||
|
||||
type Port struct {
|
||||
@@ -188,6 +192,82 @@ func LoadFS(source fs.FS, root string) ([]Snapshot, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ScanResult reports a best-effort scan of a local template directory.
|
||||
type ScanResult struct {
|
||||
Found int
|
||||
Valid []Snapshot
|
||||
Errors []ScanError
|
||||
}
|
||||
type ScanError struct {
|
||||
Template string `json:"template"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ScanDir scans immediate template directories. Invalid templates are reported
|
||||
// separately, so they cannot make valid templates disappear.
|
||||
func ScanDir(root string) (ScanResult, error) {
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
return ScanResult{}, fmt.Errorf("read template directory: %w", err)
|
||||
}
|
||||
result := ScanResult{}
|
||||
seen := map[string]struct{}{}
|
||||
source := os.DirFS(root)
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
body, readErr := os.ReadFile(filepath.Join(root, name, "template.yaml"))
|
||||
if errors.Is(readErr, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
result.Found++
|
||||
if readErr != nil {
|
||||
result.Errors = append(result.Errors, ScanError{name, "template cannot be read"})
|
||||
continue
|
||||
}
|
||||
if symlinkErr := rejectSymlinks(filepath.Join(root, name)); symlinkErr != nil {
|
||||
result.Errors = append(result.Errors, ScanError{name, "template contains unsupported symbolic links"})
|
||||
continue
|
||||
}
|
||||
snapshot, validateErr := Validate(body, name, source)
|
||||
if validateErr != nil {
|
||||
result.Errors = append(result.Errors, ScanError{name, publicValidationMessage(validateErr)})
|
||||
continue
|
||||
}
|
||||
key := snapshot.Template.ID + "@" + snapshot.Template.Version
|
||||
if _, duplicate := seen[key]; duplicate {
|
||||
result.Errors = append(result.Errors, ScanError{name, "duplicate template ID and version"})
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
result.Valid = append(result.Valid, snapshot)
|
||||
}
|
||||
sort.Slice(result.Valid, func(i, j int) bool { return result.Valid[i].Template.ID < result.Valid[j].Template.ID })
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func rejectSymlinks(root string) error {
|
||||
return filepath.WalkDir(root, func(_ string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.Type()&fs.ModeSymlink != 0 {
|
||||
return errors.New("symbolic link")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func publicValidationMessage(err error) string {
|
||||
var validation *ValidationErrors
|
||||
if errors.As(err, &validation) && len(validation.Issues) != 0 {
|
||||
return validation.Issues[0].Path + ": " + validation.Issues[0].Message
|
||||
}
|
||||
return "template is invalid"
|
||||
}
|
||||
|
||||
// Validate applies YAML safety, JSON Schema and cross-field validation.
|
||||
func Validate(body []byte, assetRoot string, source fs.FS) (Snapshot, error) {
|
||||
var document yaml.Node
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
|
||||
@@ -26,34 +27,57 @@ func NewRepository(db *sql.DB) *Repository {
|
||||
}
|
||||
|
||||
func (r *Repository) Sync(ctx context.Context, snapshots []catalog.Snapshot) error {
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin catalog sync: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
now := r.now().UTC().Format(time.RFC3339Nano)
|
||||
for _, snapshot := range snapshots {
|
||||
var digest string
|
||||
err := tx.QueryRowContext(ctx, "SELECT digest FROM template_versions WHERE template_id = ? AND version = ?", snapshot.Template.ID, snapshot.Template.Version).Scan(&digest)
|
||||
err := r.db.QueryRowContext(ctx, "SELECT digest FROM template_versions WHERE template_id=? AND version=?", snapshot.Template.ID, snapshot.Template.Version).Scan(&digest)
|
||||
if err == nil && digest != snapshot.Digest {
|
||||
return fmt.Errorf("%w: %s@%s", catalog.ErrImmutableSnapshot, snapshot.Template.ID, snapshot.Template.Version)
|
||||
}
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("check template snapshot: %w", err)
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO templates(id, origin, trust_status, active_version, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET active_version=excluded.active_version, updated_at=excluded.updated_at`,
|
||||
}
|
||||
return r.Replace(ctx, snapshots)
|
||||
}
|
||||
|
||||
// Replace synchronizes the current local catalog. It is used only for
|
||||
// administrator-owned local templates, for which the directory is authoritative.
|
||||
func (r *Repository) Replace(ctx context.Context, snapshots []catalog.Snapshot) error {
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin catalog sync: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
now := r.now().UTC().Format(time.RFC3339Nano)
|
||||
ids := make([]string, 0, len(snapshots))
|
||||
for _, snapshot := range snapshots {
|
||||
ids = append(ids, snapshot.Template.ID)
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO templates(id, origin, trust_status, active_version, available, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 1, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET origin=excluded.origin, trust_status=excluded.trust_status, active_version=excluded.active_version, available=1, updated_at=excluded.updated_at`,
|
||||
snapshot.Template.ID, snapshot.Origin, snapshot.Origin, snapshot.Template.Version, now, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert catalog template: %w", err)
|
||||
}
|
||||
if digest == "" {
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO template_versions(template_id, version, schema_version, canonical_yaml, digest, game_id, game_name, description, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, snapshot.Template.ID, snapshot.Template.Version, snapshot.Template.SchemaVersion, snapshot.CanonicalYAML, snapshot.Digest, snapshot.Template.Game.ID, snapshot.Template.Game.Name, snapshot.Template.Game.Description, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert template snapshot: %w", err)
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO template_versions(template_id, version, schema_version, canonical_yaml, digest, game_id, game_name, description, image, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(template_id, version) DO UPDATE SET schema_version=excluded.schema_version, canonical_yaml=excluded.canonical_yaml, digest=excluded.digest, game_id=excluded.game_id, game_name=excluded.game_name, description=excluded.description, image=excluded.image`, snapshot.Template.ID, snapshot.Template.Version, snapshot.Template.SchemaVersion, snapshot.CanonicalYAML, snapshot.Digest, snapshot.Template.Game.ID, snapshot.Template.Game.Name, snapshot.Template.Game.Description, snapshot.Template.Game.Image, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert template snapshot: %w", err)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
if _, err = tx.ExecContext(ctx, "UPDATE templates SET available=0, updated_at=?", now); err != nil {
|
||||
return fmt.Errorf("hide removed templates: %w", err)
|
||||
}
|
||||
} else {
|
||||
placeholders, args := make([]string, len(ids)), make([]any, 0, len(ids)+1)
|
||||
args = append(args, now)
|
||||
for i, id := range ids {
|
||||
placeholders[i], args = "?", append(args, id)
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, "UPDATE templates SET available=0, updated_at=? WHERE id NOT IN ("+strings.Join(placeholders, ",")+")", args...); err != nil {
|
||||
return fmt.Errorf("hide removed templates: %w", err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
@@ -63,8 +87,8 @@ func (r *Repository) Sync(ctx context.Context, snapshots []catalog.Snapshot) err
|
||||
}
|
||||
|
||||
func (r *Repository) List(ctx context.Context) ([]catalog.Summary, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT t.id, t.active_version, v.game_id, v.game_name, v.description, t.trust_status, v.digest
|
||||
FROM templates t JOIN template_versions v ON v.template_id=t.id AND v.version=t.active_version ORDER BY v.game_name, t.id`)
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT t.id, t.active_version, v.game_id, v.game_name, v.description, v.image, t.trust_status, v.digest
|
||||
FROM templates t JOIN template_versions v ON v.template_id=t.id AND v.version=t.active_version WHERE t.available=1 ORDER BY v.game_name, t.id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list catalog: %w", err)
|
||||
}
|
||||
@@ -72,7 +96,7 @@ func (r *Repository) List(ctx context.Context) ([]catalog.Summary, error) {
|
||||
var result []catalog.Summary
|
||||
for rows.Next() {
|
||||
var summary catalog.Summary
|
||||
if err := rows.Scan(&summary.ID, &summary.Version, &summary.GameID, &summary.GameName, &summary.Description, &summary.TrustStatus, &summary.Digest); err != nil {
|
||||
if err := rows.Scan(&summary.ID, &summary.Version, &summary.GameID, &summary.GameName, &summary.Description, &summary.Image, &summary.TrustStatus, &summary.Digest); err != nil {
|
||||
return nil, fmt.Errorf("scan catalog: %w", err)
|
||||
}
|
||||
result = append(result, summary)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
|
||||
@@ -85,3 +86,38 @@ func TestCatalogSyncIsImmutableAndDraftPinsSnapshot(t *testing.T) {
|
||||
t.Fatalf("recovered operation=%q instance=%q", operationState, state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceMakesLocalCatalogDiskStateAuthoritative(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)
|
||||
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repository.Replace(ctx, snapshots); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
changed := snapshots[0]
|
||||
changed.Template.Game.Name = "Palworld changed"
|
||||
changed.CanonicalYAML = strings.Replace(changed.CanonicalYAML, "Palworld", "Palworld changed", 1)
|
||||
changed.Digest = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
if err := repository.Replace(ctx, []catalog.Snapshot{changed}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, err := repository.List(ctx)
|
||||
if err != nil || len(list) != 1 || list[0].GameName != "Palworld changed" {
|
||||
t.Fatalf("updated list = %#v, %v", list, err)
|
||||
}
|
||||
if err := repository.Replace(ctx, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, err = repository.List(ctx)
|
||||
if err != nil || len(list) != 0 {
|
||||
t.Fatalf("removed list = %#v, %v", list, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ CREATE TABLE templates (
|
||||
origin TEXT NOT NULL,
|
||||
trust_status TEXT NOT NULL,
|
||||
active_version TEXT NOT NULL,
|
||||
available INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
@@ -44,6 +45,7 @@ CREATE TABLE template_versions (
|
||||
game_id TEXT NOT NULL,
|
||||
game_name TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
image TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (template_id, version),
|
||||
UNIQUE (digest)
|
||||
|
||||
@@ -14,6 +14,7 @@ var messages = map[string]map[string]string{
|
||||
"setup.title": "Create administrator", "setup.heading": "Welcome to DoGaMa", "setup.introduction": "Create the first administrator to finish setup.", "setup.submit": "Create administrator",
|
||||
"login.title": "Sign in", "login.heading": "Sign in to DoGaMa", "login.introduction": "Manage your game servers from one secure place.", "login.submit": "Sign in", "logout.submit": "Sign out",
|
||||
"dashboard.title": "Dashboard", "dashboard.eyebrow": "Dashboard", "dashboard.heading": "Game servers", "dashboard.introduction": "Overview of all your game server instances.", "dashboard.search": "Search instances", "dashboard.search_placeholder": "Search instance", "dashboard.summary": "Instance summary", "dashboard.total": "Total instances", "dashboard.running": "Running", "dashboard.stopped": "Stopped", "dashboard.updating": "Updating", "dashboard.error": "Error", "dashboard.no_match": "No matching instance", "dashboard.empty_heading": "No instances deployed", "dashboard.empty_copy": "Your game servers will appear here when they are added from the Catalog.", "dashboard.instances_eyebrow": "Servers", "dashboard.instances": "Your instances", "dashboard.activity_eyebrow": "Operations", "dashboard.recent_activity": "Recent activity", "dashboard.no_activity": "No recent activity", "dashboard.system_eyebrow": "Health", "dashboard.system_status": "System status", "dashboard.agent": "Docker agent", "dashboard.database": "Database", "dashboard.storage": "Storage", "dashboard.backups": "Backups", "dashboard.audit_log": "Audit log", "dashboard.online": "Online", "dashboard.offline": "Offline", "dashboard.healthy": "Healthy", "dashboard.unavailable": "Unavailable", "dashboard.last_backup": "Last backup", "dashboard.no_backup": "No backup", "dashboard.retention_days": "days retention", "dashboard.unlimited": "Unlimited retention", "dashboard.by": "by",
|
||||
"catalog.title": "Catalog", "catalog.eyebrow": "Game library", "catalog.heading": "Catalog", "catalog.search": "Search games", "catalog.search_placeholder": "Search a game", "catalog.scan": "Scan", "catalog.found": "templates found", "catalog.valid": "valid", "catalog.invalid": "invalid", "catalog.no_match": "No matching game", "catalog.empty": "No game available", "catalog.empty_admin": "Add templates to /var/lib/dogama/templates, then use Scan.", "catalog.back": "Back to catalog", "catalog.minimum": "Minimum", "catalog.recommended": "Recommended", "catalog.memory": "Memory", "catalog.storage": "Storage", "catalog.other": "Other", "catalog.deploy": "Deploy", "catalog.deploy_unavailable": "Deployment will be available in the next milestone.",
|
||||
"settings.title": "Settings", "settings.eyebrow": "Administration", "settings.introduction": "Configure product services without crowding the server overview.", "settings.tabs": "Settings sections", "settings.notifications": "Notifications", "settings.audit": "Audit", "settings.containers": "Game containers", "settings.web_access": "Web access", "settings.require_https": "Require HTTPS", "settings.canonical_url": "Canonical base URL", "settings.web_help": "Configure HTTPS enforcement and the public origin. Configure and verify your HTTPS reverse proxy before enabling this lock.", "settings.save_web": "Save web access", "settings.canonical_help": "Verify the canonical URL works before saving it.", "settings.https_help": "When enabled, unsafe HTTP requests are refused and safe navigation is redirected to HTTPS.",
|
||||
"settings.channels": "Notification channels", "settings.channels_help": "Secrets remain encrypted and are never displayed after saving.", "settings.no_channels": "No channel configured.", "settings.send_test": "Send test", "settings.delete": "Delete", "settings.add_channel": "Add channel", "settings.name": "Name", "settings.type": "Type", "settings.enabled": "enabled", "settings.disabled": "disabled", "settings.events": "Events (space separated)", "settings.audit_retention": "Audit retention", "settings.audit_help": "Control history size and perform explicit bounded purges.", "settings.view_audit": "View audit events", "settings.retention_days": "Retention days", "settings.maximum_entries": "Maximum entries", "settings.zero_unlimited": "Zero means unlimited and may grow the database indefinitely.", "settings.save_retention": "Save retention", "settings.delete_before": "Delete events before", "settings.confirm_purge": "Confirm bounded audit purge", "settings.purge": "Purge audit events", "settings.container_labels": "Game-container labels", "settings.container_labels_help": "These labels apply only to game-server containers.", "settings.global_labels": "Global labels", "settings.apply": "Application", "settings.next_start": "Apply on next start", "settings.immediate": "Apply immediately", "settings.disconnection": "Immediate application stops and recreates affected containers.", "settings.confirm_disconnection": "I understand the immediate-disconnection warning", "settings.save_labels": "Save game-container labels",
|
||||
"audit.title": "Audit", "audit.eyebrow": "Administration", "audit.introduction": "Review significant authentication and mutation events.", "audit.actor": "Actor ID", "audit.instance": "Instance ID", "audit.action": "Action", "audit.outcome": "Outcome", "audit.any": "Any", "audit.allowed": "Allowed", "audit.denied": "Denied", "audit.failed": "Failed", "audit.filter": "Filter audit", "audit.time": "Time", "audit.actor_column": "Actor", "audit.instance_column": "Instance", "audit.empty": "No audit event matches these filters.",
|
||||
@@ -25,6 +26,7 @@ var messages = map[string]map[string]string{
|
||||
"setup.title": "Créer l'administrateur", "setup.heading": "Bienvenue dans DoGaMa", "setup.introduction": "Créez le premier administrateur pour terminer la configuration.", "setup.submit": "Créer l'administrateur",
|
||||
"login.title": "Connexion", "login.heading": "Se connecter à DoGaMa", "login.introduction": "Gérez vos serveurs de jeux depuis un espace sécurisé.", "login.submit": "Se connecter", "logout.submit": "Se déconnecter",
|
||||
"dashboard.title": "Tableau de bord", "dashboard.eyebrow": "Tableau de bord", "dashboard.heading": "Serveurs de jeux", "dashboard.introduction": "Vue d'ensemble de toutes vos instances de serveurs de jeux.", "dashboard.search": "Rechercher des instances", "dashboard.search_placeholder": "Rechercher une instance", "dashboard.summary": "Résumé des instances", "dashboard.total": "Instances totales", "dashboard.running": "En cours", "dashboard.stopped": "Arrêtées", "dashboard.updating": "Mise à jour", "dashboard.error": "Erreur", "dashboard.no_match": "Aucune instance correspondante", "dashboard.empty_heading": "Aucune instance déployée", "dashboard.empty_copy": "Vos serveurs de jeux apparaîtront ici lorsqu'ils seront ajoutés depuis le Catalogue.", "dashboard.instances_eyebrow": "Serveurs", "dashboard.instances": "Vos instances", "dashboard.activity_eyebrow": "Opérations", "dashboard.recent_activity": "Activité récente", "dashboard.no_activity": "Aucune activité récente", "dashboard.system_eyebrow": "Santé", "dashboard.system_status": "État du système", "dashboard.agent": "Agent Docker", "dashboard.database": "Base de données", "dashboard.storage": "Stockage", "dashboard.backups": "Sauvegardes", "dashboard.audit_log": "Journal d'audit", "dashboard.online": "En ligne", "dashboard.offline": "Hors ligne", "dashboard.healthy": "Saine", "dashboard.unavailable": "Indisponible", "dashboard.last_backup": "Dernière sauvegarde", "dashboard.no_backup": "Aucune sauvegarde", "dashboard.retention_days": "jours de rétention", "dashboard.unlimited": "Rétention illimitée", "dashboard.by": "par",
|
||||
"catalog.title": "Catalogue", "catalog.eyebrow": "Bibliothèque de jeux", "catalog.heading": "Catalogue", "catalog.search": "Rechercher des jeux", "catalog.search_placeholder": "Rechercher un jeu", "catalog.scan": "Scanner", "catalog.found": "templates trouvés", "catalog.valid": "valides", "catalog.invalid": "invalides", "catalog.no_match": "Aucun jeu correspondant", "catalog.empty": "Aucun jeu disponible", "catalog.empty_admin": "Ajoutez des templates dans /var/lib/dogama/templates, puis utilisez Scanner.", "catalog.back": "Retour au catalogue", "catalog.minimum": "Minimum", "catalog.recommended": "Recommandé", "catalog.memory": "Mémoire", "catalog.storage": "Stockage", "catalog.other": "Autre", "catalog.deploy": "Déployer", "catalog.deploy_unavailable": "Le déploiement sera disponible au prochain bloc.",
|
||||
"settings.title": "Paramètres", "settings.eyebrow": "Administration", "settings.introduction": "Configurez les services du produit sans encombrer la vue des serveurs.", "settings.tabs": "Sections des paramètres", "settings.notifications": "Notifications", "settings.audit": "Audit", "settings.containers": "Conteneurs de jeux", "settings.web_access": "Accès Web", "settings.require_https": "Exiger HTTPS", "settings.canonical_url": "URL de base canonique", "settings.web_help": "Configurez l'obligation HTTPS et l'origine publique. Configurez et vérifiez votre proxy inverse HTTPS avant d'activer ce verrouillage.", "settings.save_web": "Enregistrer l'accès Web", "settings.canonical_help": "Vérifiez que l'URL canonique fonctionne avant de l'enregistrer.", "settings.https_help": "Une fois activé, les requêtes HTTP non sûres sont refusées et les navigations sûres sont redirigées vers HTTPS.",
|
||||
"settings.channels": "Canaux de notification", "settings.channels_help": "Les secrets restent chiffrés et ne sont jamais affichés après enregistrement.", "settings.no_channels": "Aucun canal configuré.", "settings.send_test": "Envoyer un test", "settings.delete": "Supprimer", "settings.add_channel": "Ajouter un canal", "settings.name": "Nom", "settings.type": "Type", "settings.enabled": "activé", "settings.disabled": "désactivé", "settings.events": "Événements (séparés par des espaces)", "settings.audit_retention": "Rétention de l'audit", "settings.audit_help": "Contrôlez la taille de l'historique et effectuez des purges limitées explicites.", "settings.view_audit": "Voir les événements d'audit", "settings.retention_days": "Jours de rétention", "settings.maximum_entries": "Nombre maximal d'entrées", "settings.zero_unlimited": "Zéro signifie illimité et peut faire croître la base indéfiniment.", "settings.save_retention": "Enregistrer la rétention", "settings.delete_before": "Supprimer les événements antérieurs au", "settings.confirm_purge": "Confirmer la purge limitée de l'audit", "settings.purge": "Purger les événements d'audit", "settings.container_labels": "Étiquettes des conteneurs de jeux", "settings.container_labels_help": "Ces étiquettes s'appliquent uniquement aux conteneurs de serveurs de jeux.", "settings.global_labels": "Étiquettes globales", "settings.apply": "Application", "settings.next_start": "Appliquer au prochain démarrage", "settings.immediate": "Appliquer immédiatement", "settings.disconnection": "L'application immédiate arrête et recrée les conteneurs concernés.", "settings.confirm_disconnection": "Je comprends l'avertissement de déconnexion immédiate", "settings.save_labels": "Enregistrer les étiquettes des conteneurs",
|
||||
"audit.title": "Audit", "audit.eyebrow": "Administration", "audit.introduction": "Consultez les événements importants d'authentification et de modification.", "audit.actor": "ID de l'acteur", "audit.instance": "ID de l'instance", "audit.action": "Action", "audit.outcome": "Résultat", "audit.any": "Tous", "audit.allowed": "Autorisé", "audit.denied": "Refusé", "audit.failed": "Échec", "audit.filter": "Filtrer l'audit", "audit.time": "Heure", "audit.actor_column": "Acteur", "audit.instance_column": "Instance", "audit.empty": "Aucun événement d'audit ne correspond à ces filtres.",
|
||||
|
||||
+104
-1
@@ -51,6 +51,7 @@ type server struct {
|
||||
imports *importexport.Service
|
||||
audit *audit.Service
|
||||
notifications *notification.Service
|
||||
catalogScan func(context.Context) (catalog.ScanResult, error)
|
||||
}
|
||||
|
||||
type repository interface {
|
||||
@@ -86,6 +87,10 @@ type pageData struct {
|
||||
StatusCounts map[string]int
|
||||
RecentActivity []dashboardActivity
|
||||
SystemStatus dashboardSystemStatus
|
||||
Catalog []catalog.Summary
|
||||
CatalogDetail *catalog.Template
|
||||
CatalogScan *catalog.ScanResult
|
||||
CatalogScanner bool
|
||||
}
|
||||
|
||||
type dashboardActivity struct {
|
||||
@@ -143,6 +148,11 @@ func NewHandlerComplete(authService *auth.Service, repository repository, lifecy
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -152,11 +162,15 @@ 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) {
|
||||
return newHandlerServicesWithCatalog(authService, repository, lifecycle, backupService, importService, auditService, notificationService, nil, logger)
|
||||
}
|
||||
|
||||
func newHandlerServicesWithCatalog(authService *auth.Service, repository repository, lifecycle *instance.LifecycleService, backupService *backup.Service, importService *importexport.Service, auditService *audit.Service, notificationService *notification.Service, scanner func(context.Context) (catalog.ScanResult, error), logger *slog.Logger) (http.Handler, error) {
|
||||
templates, err := template.New("views").Funcs(template.FuncMap{"msg": message, "statusClass": statusClass, "statusLabel": statusLabel, "activityLabel": activityLabel, "relativeTime": relativeTime, "storagePercent": storagePercent, "formatBytes": formatBytes}).ParseFS(assets, "templates/*.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &server{auth: authService, templates: templates, logger: logger, repository: repository, lifecycle: lifecycle, backups: backupService, imports: importService, audit: auditService, notifications: notificationService}
|
||||
s := &server{auth: authService, templates: templates, logger: logger, repository: repository, lifecycle: lifecycle, backups: backupService, imports: importService, audit: auditService, notifications: notificationService, catalogScan: scanner}
|
||||
if repository != nil {
|
||||
s.permissions = authorization.New(repository)
|
||||
}
|
||||
@@ -244,6 +258,9 @@ func newHandlerServices(authService *auth.Service, repository repository, lifecy
|
||||
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 /catalog", s.catalogPage)
|
||||
mux.HandleFunc("POST /catalog/scan", s.catalogScanForm)
|
||||
mux.HandleFunc("GET /catalog/{id}", s.catalogDetailPage)
|
||||
mux.HandleFunc("GET /account", s.accountPage)
|
||||
mux.HandleFunc("POST /account/email", s.accountEmailForm)
|
||||
mux.HandleFunc("POST /account/password", s.accountPasswordForm)
|
||||
@@ -1509,6 +1526,92 @@ func (s *server) home(w http.ResponseWriter, r *http.Request) {
|
||||
s.render(w, http.StatusOK, "home.html", data)
|
||||
}
|
||||
|
||||
func (s *server) catalogPage(w http.ResponseWriter, r *http.Request) {
|
||||
data, ok := s.catalogPageData(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s.render(w, http.StatusOK, "catalog.html", data)
|
||||
}
|
||||
|
||||
func (s *server) catalogDetailPage(w http.ResponseWriter, r *http.Request) {
|
||||
data, ok := s.catalogPageData(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
for _, summary := range data.Catalog {
|
||||
if summary.ID != id {
|
||||
continue
|
||||
}
|
||||
snapshot, err := s.repository.Get(r.Context(), summary.ID, summary.Version)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
data.CatalogDetail = &snapshot.Template
|
||||
s.render(w, http.StatusOK, "catalog-detail.html", data)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
func (s *server) catalogScanForm(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireBootstrap(w, r, false) {
|
||||
return
|
||||
}
|
||||
user, err := s.currentUser(r)
|
||||
if err != nil || user.Role != "admin" {
|
||||
s.problem(w, http.StatusForbidden, "Administrator access is required.")
|
||||
return
|
||||
}
|
||||
session, err := r.Cookie(sessionCookie)
|
||||
if err != nil || !s.parseForm(w, r) || !s.auth.ValidateCSRF(r.Context(), session.Value, r.FormValue("csrf_token")) {
|
||||
s.problem(w, http.StatusForbidden, message("error.csrf"))
|
||||
return
|
||||
}
|
||||
data, ok := s.catalogPageData(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if s.catalogScan == nil {
|
||||
s.problem(w, http.StatusServiceUnavailable, "Catalog scanning is unavailable.")
|
||||
return
|
||||
}
|
||||
result, scanErr := s.catalogScan(r.Context())
|
||||
if scanErr != nil {
|
||||
s.problem(w, http.StatusInternalServerError, message("error.internal"))
|
||||
return
|
||||
}
|
||||
data.CatalogScan = &result
|
||||
data.Catalog, _ = s.repository.List(r.Context())
|
||||
s.render(w, http.StatusOK, "catalog.html", data)
|
||||
}
|
||||
|
||||
func (s *server) catalogPageData(w http.ResponseWriter, r *http.Request) (pageData, bool) {
|
||||
if !s.requireBootstrap(w, r, false) {
|
||||
return pageData{}, false
|
||||
}
|
||||
user, err := s.currentUser(r)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return pageData{}, false
|
||||
}
|
||||
csrf, err := r.Cookie(csrfCookie)
|
||||
if err != nil {
|
||||
s.problem(w, http.StatusForbidden, message("error.csrf"))
|
||||
return pageData{}, false
|
||||
}
|
||||
data := pageData{Title: localized(s.language(r, user.Language), "catalog.title"), Language: s.language(r, user.Language), User: user, CSRFToken: csrf.Value, IsAdmin: user.Role == "admin", ActivePage: "catalog", CatalogScanner: s.catalogScan != nil}
|
||||
if s.repository != nil {
|
||||
data.Catalog, err = s.repository.List(r.Context())
|
||||
}
|
||||
if err != nil {
|
||||
s.problem(w, http.StatusInternalServerError, message("error.internal"))
|
||||
return pageData{}, false
|
||||
}
|
||||
return data, true
|
||||
}
|
||||
|
||||
func (s *server) dashboardData(ctx context.Context, data *pageData) {
|
||||
status := dashboardSystemStatus{}
|
||||
if s.lifecycle != nil {
|
||||
|
||||
@@ -813,3 +813,80 @@ func assertStatus(t *testing.T, response *httptest.ResponseRecorder, expected in
|
||||
t.Fatalf("status = %d, want %d; body = %s", response.Code, expected, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogPagesAndAdminScan(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)
|
||||
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repository.Replace(ctx, snapshots); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
authService := auth.New(db)
|
||||
if err := authService.BootstrapAdmin(ctx, "admin", "correct horse battery staple"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin, err := authService.Login(ctx, "admin", "correct horse battery staple", "192.0.2.1:1234")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := authService.CreateUser(ctx, "player", "another correct battery staple", "user"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
player, err := authService.Login(ctx, "player", "another correct battery staple", "192.0.2.2:1234")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
scanner := func(context.Context) (catalog.ScanResult, error) {
|
||||
return catalog.ScanResult{Found: 2, Valid: snapshots, Errors: []catalog.ScanError{{Template: "broken", Message: "/game/image: value does not satisfy the template schema"}}}, nil
|
||||
}
|
||||
handler, err := NewHandlerCompleteWithCatalog(authService, repository, nil, nil, nil, nil, nil, scanner, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adminCookies := []*http.Cookie{{Name: sessionCookie, Value: admin.Token}, {Name: csrfCookie, Value: admin.CSRFToken}}
|
||||
page := request(t, handler, http.MethodGet, "/catalog", adminCookies)
|
||||
assertStatus(t, page, http.StatusOK)
|
||||
for _, expected := range []string{"Palworld", snapshots[0].Template.Game.Image, "/catalog/palworld-official", "Scan"} {
|
||||
if !strings.Contains(page.Body.String(), expected) {
|
||||
t.Fatalf("catalog missing %q", expected)
|
||||
}
|
||||
}
|
||||
detail := request(t, handler, http.MethodGet, "/catalog/palworld-official", adminCookies)
|
||||
assertStatus(t, detail, http.StatusOK)
|
||||
for _, expected := range []string{snapshots[0].Template.Game.Description, "Minimum", "Recommended", "Deploy"} {
|
||||
if !strings.Contains(detail.Body.String(), expected) {
|
||||
t.Fatalf("detail missing %q", expected)
|
||||
}
|
||||
}
|
||||
unknown := request(t, handler, http.MethodGet, "/catalog/no-such-game", adminCookies)
|
||||
assertStatus(t, unknown, http.StatusNotFound)
|
||||
scan := request(t, handler, http.MethodPost, "/catalog/scan", adminCookies)
|
||||
// A direct POST without a form token remains protected.
|
||||
assertStatus(t, scan, http.StatusForbidden)
|
||||
req := httptest.NewRequest(http.MethodPost, "/catalog/scan", strings.NewReader("csrf_token="+url.QueryEscape(admin.CSRFToken)))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
for _, cookie := range adminCookies {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, req)
|
||||
assertStatus(t, response, http.StatusOK)
|
||||
if !strings.Contains(response.Body.String(), "broken") {
|
||||
t.Fatal("scan errors were not rendered")
|
||||
}
|
||||
nonAdmin := httptest.NewRequest(http.MethodPost, "/catalog/scan", strings.NewReader("csrf_token="+url.QueryEscape(player.CSRFToken)))
|
||||
nonAdmin.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
nonAdmin.AddCookie(&http.Cookie{Name: sessionCookie, Value: player.Token})
|
||||
nonAdmin.AddCookie(&http.Cookie{Name: csrfCookie, Value: player.CSRFToken})
|
||||
nonAdminResponse := httptest.NewRecorder()
|
||||
handler.ServeHTTP(nonAdminResponse, nonAdmin)
|
||||
assertStatus(t, nonAdminResponse, http.StatusForbidden)
|
||||
}
|
||||
|
||||
@@ -5,3 +5,5 @@
|
||||
.app-body{background-color:#050814;background-image:radial-gradient(circle at 12% 14%,#20d9f355 0 1px,transparent 1.5px),radial-gradient(circle at 78% 8%,#f4f7ff77 0 1px,transparent 1.5px),radial-gradient(circle at 55% 26%,#f02fb844 0 1px,transparent 1.5px),linear-gradient(165deg,#060917 0%,#080b18 52%,#070916 100%);background-size:137px 149px,191px 173px,223px 211px,auto}.sidebar{width:220px;border-right-color:#f02fb866;box-shadow:8px 0 40px #0005}.app-main{margin-left:220px}.brand{display:grid;justify-items:center;text-align:center;padding-bottom:20px}.brand img{width:72px;height:72px}.brand small{display:none}.page-heading{padding-bottom:16px;border-bottom:1px solid #232943}.page-heading h1{font-size:clamp(1.9rem,3vw,2.6rem)}.instance-grid{grid-template-columns:repeat(auto-fill,minmax(min(100%,280px),1fr));gap:16px}.instance-card{border-color:#3d4667;border-radius:10px}.instance-art{height:112px;border-bottom:1px solid #424b6d}.instance-art:after{height:25%}.instance-card-body{padding:13px 15px 15px}.instance-card h2{margin:0;font-size:1.05rem}.instance-name{margin:3px 0 12px;color:var(--text-secondary);font-size:.84rem}.status-badge{position:static;display:inline-flex;padding:0;border:0;background:transparent;font-size:.82rem}.summary-grid{grid-template-columns:repeat(5,1fr);gap:12px}.summary-grid article{position:relative;padding:15px 17px;border-top:2px solid #384260}.summary-grid strong{display:block;line-height:1.1}.summary-total{border-top-color:var(--accent-cyan)!important}.summary-running{border-top-color:var(--status-running)!important}.summary-stopped{border-top-color:var(--status-stopped)!important}.summary-warning{border-top-color:var(--status-warning)!important}.summary-error{border-top-color:var(--status-error)!important}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:1050px){.summary-grid{grid-template-columns:repeat(3,1fr)}}@media(max-width:760px){.sidebar{width:250px}.app-main{margin-left:0}.brand{display:flex;justify-items:initial;text-align:left}.brand img{width:54px;height:54px}.brand small{display:block}}@media(max-width:520px){.summary-grid{grid-template-columns:1fr 1fr}.summary-grid article:first-child{grid-column:1/-1}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important}}
|
||||
|
||||
.auth-card{padding:0;overflow:hidden}.auth-brand{width:100%;line-height:0}.auth-brand img{width:100%;height:auto;display:block;object-fit:contain}.auth-content{padding:28px 34px 34px}.language-select{display:flex;grid-auto-flow:column;justify-content:end;gap:7px;margin:0 0 18px}.language-select button{padding:5px 9px;font-size:.75rem}.auth-content form:not(.language-select){margin-top:26px}@media(max-width:520px){.auth-card{padding:0}.auth-content{padding:22px}.language-select{justify-content:start}}
|
||||
|
||||
.catalog-tools{display:flex;align-items:center;gap:12px}.scan-button,.deploy-band button{border:1px solid #20d9f388;border-radius:10px;padding:10px 15px;background:linear-gradient(110deg,#123b51,#55264d);color:#fff;font-weight:800;cursor:pointer}.catalog-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:22px;max-width:1400px;margin:auto}.catalog-card{text-decoration:none;min-width:0}.catalog-cover,.game-cover{position:relative;overflow:hidden;background:linear-gradient(135deg,#25233f,#101322);border:1px solid var(--border);border-radius:12px;box-shadow:0 12px 26px #0005;aspect-ratio:2/3}.catalog-cover img,.game-cover img{position:relative;z-index:1;width:100%;height:100%;object-fit:cover}.cover-fallback,.game-cover>span{position:absolute;inset:0;display:grid;place-items:center;color:#20d9f366;font-size:3rem}.catalog-card h2{margin:10px 3px 0;font-size:1rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.catalog-card:hover .catalog-cover{border-color:var(--accent-cyan);box-shadow:0 16px 32px #0008,0 0 22px #20d9f322;transform:translateY(-3px)}.catalog-scan-result{max-width:1400px;margin:0 auto 22px;padding:14px 18px;background:#12314466;border:1px solid #20d9f366;border-radius:12px}.catalog-scan-result ul{margin:8px 0 0;padding-left:20px;color:var(--text-secondary)}.back-link{display:inline-block;margin:0 auto 22px;max-width:1400px;color:var(--accent-cyan);text-decoration:none}.game-detail{display:grid;grid-template-columns:minmax(210px,330px) minmax(0,1fr);gap:clamp(28px,5vw,70px);max-width:1120px;margin:auto;padding:clamp(20px,4vw,42px);background:linear-gradient(145deg,#15182d,#101322);border:1px solid var(--border);border-radius:var(--radius)}.game-detail h1{margin:0;font-size:clamp(2rem,5vw,3.6rem)}.game-description{max-width:70ch;color:var(--text-secondary);font-size:1.05rem}.deploy-band{margin-top:12px;padding:12px;background:#0b1020;border:1px solid var(--border);border-radius:10px}.deploy-band button{width:100%;cursor:not-allowed;opacity:.7}.requirements-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;margin-top:30px}.requirements-grid section{padding:18px;background:#0c1020;border:1px solid var(--border);border-radius:12px}.requirements-grid h2{margin:0 0 12px;color:var(--accent-cyan);font-size:1rem}.requirements-grid dl{display:grid;grid-template-columns:auto 1fr;gap:8px 12px;margin:0}.requirements-grid dt{color:var(--text-secondary)}.requirements-grid dd{margin:0;text-align:right}@media(max-width:700px){.catalog-header{align-items:start;flex-direction:column}.catalog-tools{width:100%;justify-content:space-between}.catalog-tools .instance-search{flex:1}.game-detail{grid-template-columns:1fr}.game-detail aside{max-width:300px;margin:auto;width:100%}.requirements-grid{grid-template-columns:1fr}}
|
||||
|
||||
@@ -27,3 +27,25 @@ if (instanceSearch) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const catalogSearch = document.querySelector("#catalog-search");
|
||||
if (catalogSearch) {
|
||||
const cards = [...document.querySelectorAll("[data-catalog-card]")];
|
||||
const empty = document.querySelector("[data-catalog-search-empty]");
|
||||
const filter = () => {
|
||||
const query = catalogSearch.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;
|
||||
};
|
||||
catalogSearch.addEventListener("input", filter);
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "/" && document.activeElement !== catalogSearch) {
|
||||
event.preventDefault(); catalogSearch.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{{define "catalog-detail.html"}}<!doctype html><html lang="{{.Language}}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.CatalogDetail.Game.Name}} · DoGaMa</title><link rel="stylesheet" href="/static/app.v2.css"><link rel="stylesheet" href="/static/theme.v2.css"></head><body class="app-body">{{template "sidebar" .}}<main class="app-main"><a class="back-link" href="/catalog">← {{.Msg "catalog.back"}}</a><section class="game-detail"><aside><div class="game-cover"><img src="{{.CatalogDetail.Game.Image}}" alt="{{.CatalogDetail.Game.Name}}"><span>◈</span></div><div class="deploy-band"><button disabled title="{{.Msg "catalog.deploy_unavailable"}}">{{.Msg "catalog.deploy"}}</button></div></aside><article><p class="eyebrow">{{.Msg "catalog.eyebrow"}}</p><h1>{{.CatalogDetail.Game.Name}}</h1><p class="game-description">{{.CatalogDetail.Game.Description}}</p><div class="requirements-grid"><section><h2>{{.Msg "catalog.minimum"}}</h2><dl><dt>CPU</dt><dd>{{.CatalogDetail.Requirements.Minimum.CPUCores}}</dd><dt>{{.Msg "catalog.memory"}}</dt><dd>{{.CatalogDetail.Requirements.Minimum.MemoryMB}} MB</dd><dt>{{.Msg "catalog.storage"}}</dt><dd>{{.CatalogDetail.Requirements.Minimum.StorageGB}} GB</dd>{{range .CatalogDetail.Requirements.Minimum.Other}}<dt>{{$.Msg "catalog.other"}}</dt><dd>{{.}}</dd>{{end}}</dl></section><section><h2>{{.Msg "catalog.recommended"}}</h2><dl><dt>CPU</dt><dd>{{.CatalogDetail.Requirements.Recommended.CPUCores}}</dd><dt>{{.Msg "catalog.memory"}}</dt><dd>{{.CatalogDetail.Requirements.Recommended.MemoryMB}} MB</dd><dt>{{.Msg "catalog.storage"}}</dt><dd>{{.CatalogDetail.Requirements.Recommended.StorageGB}} GB</dd>{{range .CatalogDetail.Requirements.Recommended.Other}}<dt>{{$.Msg "catalog.other"}}</dt><dd>{{.}}</dd>{{end}}</dl></section></div></article></section></main></body></html>{{end}}
|
||||
@@ -0,0 +1 @@
|
||||
{{define "catalog.html"}}<!doctype html><html lang="{{.Language}}"><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">{{template "sidebar" .}}<main class="app-main"><header class="page-heading catalog-header"><div><p class="eyebrow">{{.Msg "catalog.eyebrow"}}</p><h1>{{.Msg "catalog.heading"}}</h1></div><div class="catalog-tools"><label class="instance-search"><span class="sr-only">{{.Msg "catalog.search"}}</span><input id="catalog-search" type="search" placeholder="{{.Msg "catalog.search_placeholder"}}" autocomplete="off"><kbd>/</kbd></label>{{if and .IsAdmin .CatalogScanner}}<form method="post" action="/catalog/scan"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button class="scan-button" type="submit">↻ {{.Msg "catalog.scan"}}</button></form>{{end}}</div></header>{{if .CatalogScan}}<section class="catalog-scan-result"><strong>{{.CatalogScan.Found}} {{.Msg "catalog.found"}} · {{len .CatalogScan.Valid}} {{.Msg "catalog.valid"}} · {{len .CatalogScan.Errors}} {{.Msg "catalog.invalid"}}</strong>{{if .CatalogScan.Errors}}<ul>{{range .CatalogScan.Errors}}<li><strong>{{.Template}}</strong> — {{.Message}}</li>{{end}}</ul>{{end}}</section>{{end}}{{if .Catalog}}<section class="catalog-grid" aria-label="{{.Msg "catalog.heading"}}">{{range .Catalog}}<a class="catalog-card" data-catalog-card data-search="{{.GameName}} {{.ID}} {{.Description}}" href="/catalog/{{.ID}}"><div class="catalog-cover"><img src="{{.Image}}" alt="{{.GameName}}" loading="lazy"><span class="cover-fallback">◈</span></div><h2>{{.GameName}}</h2></a>{{end}}</section><p class="search-empty" data-catalog-search-empty hidden>{{.Msg "catalog.no_match"}}</p>{{else}}<section class="empty-state"><span class="empty-icon">◈</span><h2>{{.Msg "catalog.empty"}}</h2>{{if .IsAdmin}}<p>{{.Msg "catalog.empty_admin"}}</p>{{end}}</section>{{end}}</main><script src="/static/app.v2.js"></script></body></html>{{end}}
|
||||
@@ -1 +1 @@
|
||||
{{define "sidebar"}}<aside class="sidebar" id="app-navigation"><a class="brand brand-image" href="/"><img src="/static/dogama.png" alt="DoGaMa"></a><nav aria-label="{{.Msg "nav.primary"}}"><a class="nav-dashboard {{if eq .ActivePage "dashboard"}}active{{end}}" href="/">{{.Msg "nav.dashboard"}}</a><span class="nav-catalog disabled" aria-disabled="true" title="{{.Msg "nav.planned"}}">{{.Msg "nav.catalog"}}</span>{{if .IsAdmin}}<a class="nav-audit {{if eq .ActivePage "audit"}}active{{end}}" href="/audit">{{.Msg "nav.audit"}}</a><a class="nav-administration {{if eq .ActivePage "administration"}}active{{end}}" href="/administration">Administration</a>{{end}}<a class="nav-account {{if eq .ActivePage "account"}}active{{end}}" href="/account">⚙ {{.Msg "nav.settings"}}</a></nav><div class="account"><p class="sr-only">{{.Msg "account.signed_in"}} <strong>{{.User.Username}}</strong>.</p><div class="account-card"><span class="avatar">DG</span><span><strong>{{.User.Username}}</strong><small>{{.User.Role}}</small></span></div><form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button class="signout-button" type="submit">↪ {{.Msg "logout.submit"}}</button></form></div></aside>{{end}}
|
||||
{{define "sidebar"}}<aside class="sidebar" id="app-navigation"><a class="brand brand-image" href="/"><img src="/static/dogama.png" alt="DoGaMa"></a><nav aria-label="{{.Msg "nav.primary"}}"><a class="nav-dashboard {{if eq .ActivePage "dashboard"}}active{{end}}" href="/">{{.Msg "nav.dashboard"}}</a><a class="nav-catalog {{if eq .ActivePage "catalog"}}active{{end}}" href="/catalog">{{.Msg "nav.catalog"}}</a>{{if .IsAdmin}}<a class="nav-audit {{if eq .ActivePage "audit"}}active{{end}}" href="/audit">{{.Msg "nav.audit"}}</a><a class="nav-administration {{if eq .ActivePage "administration"}}active{{end}}" href="/administration">Administration</a>{{end}}<a class="nav-account {{if eq .ActivePage "account"}}active{{end}}" href="/account">⚙ {{.Msg "nav.settings"}}</a></nav><div class="account"><p class="sr-only">{{.Msg "account.signed_in"}} <strong>{{.User.Username}}</strong>.</p><div class="account-card"><span class="avatar">DG</span><span><strong>{{.User.Username}}</strong><small>{{.User.Role}}</small></span></div><form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button class="signout-button" type="submit">↪ {{.Msg "logout.submit"}}</button></form></div></aside>{{end}}
|
||||
|
||||
@@ -21,11 +21,12 @@
|
||||
"game": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "name", "description", "artwork"],
|
||||
"required": ["id", "name", "description", "image", "artwork"],
|
||||
"properties": {
|
||||
"id": { "$ref": "#/$defs/id" },
|
||||
"name": { "type": "string", "minLength": 1, "maxLength": 100 },
|
||||
"description": { "type": "string", "minLength": 1, "maxLength": 2000 },
|
||||
"image": { "type": "string", "format": "uri", "pattern": "^https://", "maxLength": 2000 },
|
||||
"website": { "type": "string", "format": "uri", "pattern": "^https://" },
|
||||
"artwork": {
|
||||
"type": "object",
|
||||
@@ -226,7 +227,8 @@
|
||||
"properties": {
|
||||
"cpu_cores": { "type": "number", "exclusiveMinimum": 0, "maximum": 1024 },
|
||||
"memory_mb": { "type": "integer", "minimum": 128 },
|
||||
"storage_gb": { "type": "integer", "minimum": 1 }
|
||||
"storage_gb": { "type": "integer", "minimum": 1 },
|
||||
"other": { "type": "array", "maxItems": 16, "items": { "type": "string", "minLength": 1, "maxLength": 300 } }
|
||||
}
|
||||
},
|
||||
"capability": { "enum": ["server_info", "metrics", "player_list", "online_save", "graceful_shutdown", "announcement", "kick", "ban", "unban"] },
|
||||
|
||||
Reference in New Issue
Block a user