refactor(catalog): make template artwork fully local
CI / validate (pull_request) Successful in 26m33s

This commit is contained in:
2026-08-26 21:58:30 +02:00
parent 1bb9d20f88
commit 4ed3c8ae58
12 changed files with 191 additions and 18 deletions
+1
View File
@@ -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.
+5
View File
@@ -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.
+1 -1
View File
@@ -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
+25 -1
View File
@@ -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
+17
View File
@@ -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)
}
})
}
}
+15 -7
View File
@@ -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"
+1
View File
@@ -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),
+34 -2
View File
@@ -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, &notNull, &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
View File
@@ -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
}
+15 -1
View File
@@ -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)
}
+3
View File
@@ -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: