Merge pull request 'refactor(catalog): make template artwork fully local' (#50) from codex/block-22-local-catalog-assets into main
CI / validate (push) Canceled after 0s
CI / validate (push) Canceled after 0s
Reviewed-on: #50
This commit was merged in pull request #50.
This commit is contained in:
@@ -62,6 +62,7 @@ Read this compact operational baseline before starting a milestone. Open detaile
|
||||
- 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.
|
||||
- All official and local template artwork is bundled locally, served through generic template-scoped routes with detected raster Content-Type, and retained in SQLite snapshots for historical rendering. Remote, absolute, traversal and escaping-symlink asset paths are rejected; no artwork checksum is required.
|
||||
- Embedded catalog validation is collection-based: every discovered template is schema- and cross-field-validated, including referenced assets, declared template-local integration modules and ports/configuration. A module bundle lives at `<template>/module/`, is declared by `module.path`, is path-confined and is retained with the selected snapshot; no game-specific internal registry exists. Template asset contents are not SHA-256-pinned, allowing administrator-maintained local assets while preserving required-path validation. A template `version` identifies a DoGaMa snapshot, whereas `container.tag` selects the game-server image; the official Palworld template follows Pocketpair's `latest` tag for new deployments and ordinary pulls.
|
||||
- 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 the administrator's managed game-container UID/GID or image-defined user. Image-defined templates cannot be overridden. Never perform automatic recursive ownership changes.
|
||||
|
||||
@@ -13,6 +13,9 @@ Use one direct child directory per game:
|
||||
palworld/
|
||||
template.yaml
|
||||
assets/
|
||||
icon.png
|
||||
banner.jpg
|
||||
poster.jpg
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -48,6 +51,8 @@ requirements:
|
||||
|
||||
`game.artwork` contains required local logo, horizontal image and poster assets. Validation never fetches remote artwork, so a scan remains local and deterministic. `requirements.minimum` and `requirements.recommended` contain generic CPU, memory and storage values plus optional `other` lines. Recommended resources cannot be below minimum resources.
|
||||
|
||||
Artwork paths must be relative to the template directory and must point to regular PNG/JPEG/GIF/WebP files supplied by the template. HTTP(S) URLs, absolute paths, traversal and escaping symlinks are rejected. DoGaMa serves the validated files locally, and stores them in each SQLite template snapshot so older catalog versions remain renderable.
|
||||
|
||||
`configuration.fields` drives the deployment form and the effective server configuration. Supported types are `string`, `integer`, `number`, `boolean`, `enum` and `secret`; defaults, required flags, numeric bounds, regular expressions and enum values are checked again by the server. Secret inputs are write-only, encrypted in the instance secret store when retained for runtime use, and never included in a persisted preview, API response, audit event or error.
|
||||
|
||||
Each field has one explicit `target`. `environment` uses `name` as a container environment-variable name; static `container.environment` values are retained and a field may only set its own declared target. `DOGAMA_*` and process/internal variables are protected. `argument` uses `name` as an argv prefix: ordinary values become `name=value`; boolean fields emit a flag only when true, unless `name` contains `{{value}}`, in which case it is substituted for both boolean values. Arguments are passed as Docker argv entries, never through a shell.
|
||||
|
||||
@@ -47,7 +47,7 @@ The deployment documentation must recommend TLS through a trusted reverse proxy
|
||||
|
||||
Display source as `official`, `verified community`, `local`, `locally modified` or `unverified`. Trust is informative but never bypasses validation/sandboxing. Catalog updates cannot overwrite local copies or silently update live instances.
|
||||
|
||||
Artwork downloads accept bounded raster formats, verify decoded content, convert locally and reject SVG in V1. Preserve source attribution metadata without loading remote assets on every page.
|
||||
Template artwork is supplied as bounded local raster assets, served only through template-scoped paths, and never loaded from a remote URL at page-render time. Preserve source attribution metadata without making runtime network requests.
|
||||
|
||||
## Security headers and API limits
|
||||
|
||||
|
||||
@@ -190,7 +190,10 @@ type Snapshot struct {
|
||||
Digest string
|
||||
Origin string
|
||||
AssetRoot string
|
||||
ModuleFiles map[string][]byte `json:"-"`
|
||||
// AssetFiles contains the template-owned artwork needed by the UI. It is
|
||||
// copied into snapshots so historical versions remain self-contained.
|
||||
AssetFiles map[string][]byte `json:"-"`
|
||||
ModuleFiles map[string][]byte `json:"-"`
|
||||
}
|
||||
|
||||
// LoadFS validates every template.yaml below root and returns stable snapshots.
|
||||
@@ -338,6 +341,10 @@ func Validate(body []byte, assetRoot string, source fs.FS) (Snapshot, error) {
|
||||
if len(moduleIssues) != 0 {
|
||||
return Snapshot{}, &ValidationErrors{Issues: moduleIssues}
|
||||
}
|
||||
assetFiles, assetIssues := collectArtworkFiles(template, assetRoot, source)
|
||||
if len(assetIssues) != 0 {
|
||||
return Snapshot{}, &ValidationErrors{Issues: assetIssues}
|
||||
}
|
||||
pretty, _ := json.MarshalIndent(raw, "", " ")
|
||||
digester := sha256.New()
|
||||
_, _ = digester.Write(canonical)
|
||||
@@ -354,6 +361,7 @@ func Validate(body []byte, assetRoot string, source fs.FS) (Snapshot, error) {
|
||||
Digest: hex.EncodeToString(digest),
|
||||
Origin: template.Source.Type,
|
||||
AssetRoot: assetRoot,
|
||||
AssetFiles: assetFiles,
|
||||
ModuleFiles: moduleFiles,
|
||||
}, nil
|
||||
}
|
||||
@@ -533,6 +541,22 @@ func crossValidate(template Template, assetRoot string, source fs.FS) []Validati
|
||||
return issues
|
||||
}
|
||||
|
||||
func collectArtworkFiles(template Template, assetRoot string, source fs.FS) (map[string][]byte, []ValidationIssue) {
|
||||
files := make(map[string][]byte, 3)
|
||||
for field, asset := range map[string]string{
|
||||
"logo": template.Game.Artwork.Logo,
|
||||
"image": template.Game.Artwork.Image,
|
||||
"poster": template.Game.Artwork.Poster,
|
||||
} {
|
||||
body, err := fs.ReadFile(source, path.Join(assetRoot, asset))
|
||||
if err != nil {
|
||||
return nil, []ValidationIssue{{Path: "/game/artwork/" + field, Message: "artwork asset cannot be read"}}
|
||||
}
|
||||
files[field] = body
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func validRuntimeEnvironmentName(value string) bool {
|
||||
if value == "" || strings.HasPrefix(value, "DOGAMA_") || value == "PATH" || value == "HOME" || value == "HOSTNAME" || value == "DOCKER_HOST" {
|
||||
return false
|
||||
|
||||
@@ -62,6 +62,23 @@ func TestBuiltInCatalogValidatesEveryDiscoveredTemplate(t *testing.T) {
|
||||
t.Fatalf("template path=%q id=%q declared module=%q is missing", name, snapshot.Template.ID, snapshot.Template.Module.Path)
|
||||
}
|
||||
}
|
||||
for _, field := range []string{"logo", "image", "poster"} {
|
||||
if len(snapshot.AssetFiles[field]) == 0 {
|
||||
t.Fatalf("template path=%q id=%q artwork=%q was not bundled", name, snapshot.Template.ID, field)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtworkURLsAndTraversalAreRejectedGenerically(t *testing.T) {
|
||||
name, body, snapshot := embeddedTemplate(t)
|
||||
for _, value := range []string{"https://example.invalid/art.jpg", "/tmp/art.jpg", "../art.jpg"} {
|
||||
t.Run(value, func(t *testing.T) {
|
||||
invalid := []byte(strings.Replace(string(body), snapshot.Template.Game.Artwork.Image, value, 1))
|
||||
if _, err := catalog.Validate(invalid, path.Dir(name), catalogdata.Files); err == nil {
|
||||
t.Fatalf("unsafe artwork path accepted: %q", value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -126,6 +126,10 @@ func (r *Repository) Replace(ctx context.Context, snapshots []catalog.Snapshot)
|
||||
if marshalErr != nil {
|
||||
return fmt.Errorf("encode template module bundle: %w", marshalErr)
|
||||
}
|
||||
assetBundle, marshalErr := json.Marshal(snapshot.AssetFiles)
|
||||
if marshalErr != nil {
|
||||
return fmt.Errorf("encode template asset bundle: %w", marshalErr)
|
||||
}
|
||||
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, ?, ?)
|
||||
@@ -134,9 +138,9 @@ func (r *Repository) Replace(ctx context.Context, snapshots []catalog.Snapshot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert catalog template: %w", err)
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO template_versions(template_id, version, schema_version, canonical_yaml, digest, game_id, game_name, description, image, module_bundle, 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, module_bundle=excluded.module_bundle`, 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.Artwork.Image, moduleBundle, now)
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO template_versions(template_id, version, schema_version, canonical_yaml, digest, game_id, game_name, description, image, asset_bundle, module_bundle, 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, asset_bundle=excluded.asset_bundle, module_bundle=excluded.module_bundle`, 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.Artwork.Image, assetBundle, moduleBundle, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert template snapshot: %w", err)
|
||||
}
|
||||
@@ -181,9 +185,9 @@ func (r *Repository) List(ctx context.Context) ([]catalog.Summary, error) {
|
||||
|
||||
func (r *Repository) Get(ctx context.Context, id, version string) (catalog.Snapshot, error) {
|
||||
var canonical, digest, origin string
|
||||
var moduleBundle []byte
|
||||
err := r.db.QueryRowContext(ctx, `SELECT v.canonical_yaml, v.digest, t.origin, v.module_bundle FROM template_versions v JOIN templates t ON t.id=v.template_id
|
||||
WHERE v.template_id=? AND v.version=?`, id, version).Scan(&canonical, &digest, &origin, &moduleBundle)
|
||||
var assetBundle, moduleBundle []byte
|
||||
err := r.db.QueryRowContext(ctx, `SELECT v.canonical_yaml, v.digest, t.origin, v.asset_bundle, v.module_bundle FROM template_versions v JOIN templates t ON t.id=v.template_id
|
||||
WHERE v.template_id=? AND v.version=?`, id, version).Scan(&canonical, &digest, &origin, &assetBundle, &moduleBundle)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return catalog.Snapshot{}, catalog.ErrTemplateNotFound
|
||||
}
|
||||
@@ -198,7 +202,11 @@ func (r *Repository) Get(ctx context.Context, id, version string) (catalog.Snaps
|
||||
if len(moduleBundle) != 0 && json.Unmarshal(moduleBundle, &moduleFiles) != nil {
|
||||
return catalog.Snapshot{}, errors.New("decode stored template module bundle")
|
||||
}
|
||||
return catalog.Snapshot{Template: template, CanonicalYAML: canonical, Digest: digest, Origin: origin, ModuleFiles: moduleFiles}, nil
|
||||
var assetFiles map[string][]byte
|
||||
if len(assetBundle) != 0 && json.Unmarshal(assetBundle, &assetFiles) != nil {
|
||||
return catalog.Snapshot{}, errors.New("decode stored template asset bundle")
|
||||
}
|
||||
return catalog.Snapshot{Template: template, CanonicalYAML: canonical, Digest: digest, Origin: origin, AssetFiles: assetFiles, ModuleFiles: moduleFiles}, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CreateDraft(ctx context.Context, draft instance.Draft) error {
|
||||
|
||||
@@ -83,6 +83,11 @@ func TestCatalogSyncIsImmutableAndDraftPinsSnapshot(t *testing.T) {
|
||||
if len(loaded.ModuleFiles) == 0 || loaded.Template.Module == nil || loaded.ModuleFiles[loaded.Template.Module.Path] == nil {
|
||||
t.Fatalf("template-local module bundle was not retained: %#v", loaded.Template.Module)
|
||||
}
|
||||
for _, field := range []string{"logo", "image", "poster"} {
|
||||
if len(loaded.AssetFiles[field]) == 0 {
|
||||
t.Fatalf("template artwork bundle missing %q", field)
|
||||
}
|
||||
}
|
||||
|
||||
tampered := palworld
|
||||
tampered.Digest = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
|
||||
|
||||
@@ -55,6 +55,7 @@ CREATE TABLE template_versions (
|
||||
game_name TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
image TEXT NOT NULL,
|
||||
asset_bundle BLOB,
|
||||
module_bundle BLOB,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (template_id, version),
|
||||
|
||||
@@ -60,7 +60,10 @@ func initialize(ctx context.Context, db *sql.DB) error {
|
||||
if err := ensureDiagnosticSchema(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureTemplateModuleSchema(ctx, db)
|
||||
if err := ensureTemplateModuleSchema(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureTemplateAssetSchema(ctx, db)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, schema); err != nil {
|
||||
return fmt.Errorf("initialize sqlite schema: %w", err)
|
||||
@@ -68,7 +71,36 @@ func initialize(ctx context.Context, db *sql.DB) error {
|
||||
if err := ensureDiagnosticSchema(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureTemplateModuleSchema(ctx, db)
|
||||
if err := ensureTemplateModuleSchema(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureTemplateAssetSchema(ctx, db)
|
||||
}
|
||||
|
||||
func ensureTemplateAssetSchema(ctx context.Context, db *sql.DB) error {
|
||||
rows, err := db.QueryContext(ctx, "PRAGMA table_info(template_versions)")
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect template asset schema: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var cid, notNull, primaryKey int
|
||||
var name, typ string
|
||||
var defaultValue any
|
||||
if err := rows.Scan(&cid, &name, &typ, ¬Null, &defaultValue, &primaryKey); err != nil {
|
||||
return err
|
||||
}
|
||||
if name == "asset_bundle" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, "ALTER TABLE template_versions ADD COLUMN asset_bundle BLOB"); err != nil {
|
||||
return fmt.Errorf("migrate template asset schema: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureTemplateModuleSchema preserves the complete module bundle with an
|
||||
|
||||
+69
-6
@@ -22,7 +22,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/audit"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
|
||||
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/authorization"
|
||||
@@ -293,6 +292,7 @@ func newHandlerServicesWithCatalog(authService *auth.Service, repository reposit
|
||||
if repository != nil {
|
||||
mux.HandleFunc("GET /public/game-icons/{gameID}", s.publicGameIcon)
|
||||
mux.HandleFunc("GET /public/game-artwork/{gameID}", s.publicGameArtwork)
|
||||
mux.HandleFunc("GET /public/template-assets/{id}/{version}/{asset}", s.publicTemplateAsset)
|
||||
mux.HandleFunc("GET /api/v1/catalog", s.catalogList)
|
||||
mux.HandleFunc("POST /api/v1/instances/preview", s.instancePreview)
|
||||
mux.HandleFunc("POST /api/v1/instances/drafts", s.instanceDraft)
|
||||
@@ -573,26 +573,74 @@ func auditAction(method, path string) string {
|
||||
}
|
||||
|
||||
var publicGameIDPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
||||
var publicVersionPattern = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$`)
|
||||
|
||||
func templateAssetURL(id, version, field string) string {
|
||||
return "/public/template-assets/" + id + "/" + version + "/" + field
|
||||
}
|
||||
|
||||
func (s *server) publicGameIcon(w http.ResponseWriter, r *http.Request) {
|
||||
s.publicGameAsset(w, r, "palworld/assets/icon.png", "image/png")
|
||||
s.publicGameAsset(w, r, "logo")
|
||||
}
|
||||
|
||||
func (s *server) publicGameArtwork(w http.ResponseWriter, r *http.Request) {
|
||||
s.publicGameAsset(w, r, "palworld/assets/banner.jpg", "image/jpeg")
|
||||
s.publicGameAsset(w, r, "image")
|
||||
}
|
||||
|
||||
func (s *server) publicGameAsset(w http.ResponseWriter, r *http.Request, assetPath, contentType string) {
|
||||
func (s *server) publicGameAsset(w http.ResponseWriter, r *http.Request, field string) {
|
||||
gameID := r.PathValue("gameID")
|
||||
if !publicGameIDPattern.MatchString(gameID) || gameID != "palworld" {
|
||||
if !publicGameIDPattern.MatchString(gameID) || s.repository == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
body, err := catalogdata.Files.ReadFile(assetPath)
|
||||
summaries, err := s.repository.List(r.Context())
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
for _, summary := range summaries {
|
||||
if summary.GameID != gameID {
|
||||
continue
|
||||
}
|
||||
snapshot, getErr := s.repository.Get(r.Context(), summary.ID, summary.Version)
|
||||
if getErr != nil {
|
||||
break
|
||||
}
|
||||
body, ok := snapshot.AssetFiles[field]
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
serveTemplateAsset(w, body)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
func (s *server) publicTemplateAsset(w http.ResponseWriter, r *http.Request) {
|
||||
id, version, field := r.PathValue("id"), r.PathValue("version"), r.PathValue("asset")
|
||||
if !publicGameIDPattern.MatchString(id) || !publicVersionPattern.MatchString(version) || (field != "logo" && field != "image" && field != "poster") || s.repository == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
snapshot, err := s.repository.Get(r.Context(), id, version)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
body, ok := snapshot.AssetFiles[field]
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
serveTemplateAsset(w, body)
|
||||
}
|
||||
|
||||
func serveTemplateAsset(w http.ResponseWriter, body []byte) {
|
||||
contentType := http.DetectContentType(body)
|
||||
if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/gif" && contentType != "image/webp" {
|
||||
http.Error(w, "unsupported artwork format", http.StatusUnsupportedMediaType)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
@@ -931,6 +979,9 @@ func (s *server) catalogList(w http.ResponseWriter, r *http.Request) {
|
||||
s.apiProblem(w, http.StatusInternalServerError, "catalog_unavailable", "The catalog is unavailable.")
|
||||
return
|
||||
}
|
||||
for index := range templates {
|
||||
templates[index].Image = templateAssetURL(templates[index].ID, templates[index].Version, "image")
|
||||
}
|
||||
s.apiJSON(w, http.StatusOK, struct {
|
||||
Templates []catalog.Summary `json:"templates"`
|
||||
}{Templates: templates})
|
||||
@@ -1844,6 +1895,9 @@ func (s *server) catalogDetailPage(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
snapshot.Template.Game.Artwork.Logo = templateAssetURL(snapshot.Template.ID, snapshot.Template.Version, "logo")
|
||||
snapshot.Template.Game.Artwork.Image = templateAssetURL(snapshot.Template.ID, snapshot.Template.Version, "image")
|
||||
snapshot.Template.Game.Artwork.Poster = templateAssetURL(snapshot.Template.ID, snapshot.Template.Version, "poster")
|
||||
data.CatalogDetail = &snapshot.Template
|
||||
s.render(w, http.StatusOK, "catalog-detail.html", data)
|
||||
return
|
||||
@@ -2109,6 +2163,9 @@ func (s *server) deploymentData(w http.ResponseWriter, r *http.Request) (pageDat
|
||||
if summary.ID == r.PathValue("id") {
|
||||
snapshot, err := s.repository.Get(r.Context(), summary.ID, summary.Version)
|
||||
if err == nil {
|
||||
snapshot.Template.Game.Artwork.Logo = templateAssetURL(snapshot.Template.ID, snapshot.Template.Version, "logo")
|
||||
snapshot.Template.Game.Artwork.Image = templateAssetURL(snapshot.Template.ID, snapshot.Template.Version, "image")
|
||||
snapshot.Template.Game.Artwork.Poster = templateAssetURL(snapshot.Template.ID, snapshot.Template.Version, "poster")
|
||||
return data, snapshot, true
|
||||
}
|
||||
break
|
||||
@@ -2168,6 +2225,9 @@ func (s *server) catalogScanForm(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
data.CatalogScan = &result
|
||||
data.Catalog, _ = s.repository.List(r.Context())
|
||||
for index := range data.Catalog {
|
||||
data.Catalog[index].Image = templateAssetURL(data.Catalog[index].ID, data.Catalog[index].Version, "image")
|
||||
}
|
||||
s.render(w, http.StatusOK, "catalog.html", data)
|
||||
}
|
||||
|
||||
@@ -2193,6 +2253,9 @@ func (s *server) catalogPageData(w http.ResponseWriter, r *http.Request) (pageDa
|
||||
s.problem(w, http.StatusInternalServerError, message("error.internal"))
|
||||
return pageData{}, false
|
||||
}
|
||||
for index := range data.Catalog {
|
||||
data.Catalog[index].Image = templateAssetURL(data.Catalog[index].ID, data.Catalog[index].Version, "image")
|
||||
}
|
||||
return data, true
|
||||
}
|
||||
|
||||
|
||||
@@ -279,6 +279,20 @@ func TestCatalogPreviewAndDraftAPIAuthorization(t *testing.T) {
|
||||
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())
|
||||
}
|
||||
vrising := request(t, handler, http.MethodGet, "/public/game-artwork/vrising", nil)
|
||||
assertStatus(t, vrising, http.StatusOK)
|
||||
if vrising.Header().Get("Content-Type") != "image/jpeg" || vrising.Body.Len() < 100000 {
|
||||
t.Fatalf("V Rising artwork response: type=%q size=%d", vrising.Header().Get("Content-Type"), vrising.Body.Len())
|
||||
}
|
||||
snapshotAsset := request(t, handler, http.MethodGet, "/public/template-assets/palworld-official/1.1.3/image", nil)
|
||||
assertStatus(t, snapshotAsset, http.StatusOK)
|
||||
if snapshotAsset.Header().Get("Content-Type") != "image/jpeg" {
|
||||
t.Fatalf("snapshot artwork content type = %q", snapshotAsset.Header().Get("Content-Type"))
|
||||
}
|
||||
unsafeAsset := request(t, handler, http.MethodGet, "/public/template-assets/palworld-official/1.1.3/../image", nil)
|
||||
if unsafeAsset.Code == http.StatusOK {
|
||||
t.Fatal("template asset traversal accepted")
|
||||
}
|
||||
traversal := request(t, handler, http.MethodGet, "/public/game-icons/..%2Fprivate", nil)
|
||||
if traversal.Code == http.StatusOK {
|
||||
t.Fatal("icon traversal accepted")
|
||||
@@ -1106,7 +1120,7 @@ func TestCatalogPagesAndAdminScan(t *testing.T) {
|
||||
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", palworld.Template.Game.Artwork.Image, "/catalog/palworld-official", "Scan"} {
|
||||
for _, expected := range []string{"Palworld", "/public/template-assets/palworld-official/1.1.3/image", "/catalog/palworld-official", "Scan"} {
|
||||
if !strings.Contains(page.Body.String(), expected) {
|
||||
t.Fatalf("catalog missing %q", expected)
|
||||
}
|
||||
|
||||
@@ -75,10 +75,13 @@ def validate_cross_references(template: dict, manifest: dict | None, template_pa
|
||||
assert mods.get("destination_mount") in mount_ids, "Mods reference an unknown mount"
|
||||
|
||||
for field in ("logo", "image", "poster"):
|
||||
assert not template["game"]["artwork"][field].startswith(("http://", "https://")), f"Remote artwork URL is forbidden: {field}"
|
||||
path = (template_path.parent / template["game"]["artwork"][field]).resolve()
|
||||
assert path.is_relative_to(template_path.parent.resolve()), f"Artwork path escapes template: {field}"
|
||||
assert path.is_file(), f"Missing artwork asset {field}: {path}"
|
||||
for asset in template["container"].get("assets", []):
|
||||
path = (template_path.parent / asset["source"]).resolve()
|
||||
assert path.is_relative_to(template_path.parent.resolve()), "Packaged asset path escapes template"
|
||||
assert path.is_file(), f"Missing packaged asset: {path}"
|
||||
|
||||
if manifest is None:
|
||||
|
||||
Reference in New Issue
Block a user