Files
panel/controller/cmd/controller/dayzmods.go
T
2026-07-14 23:25:11 -07:00

1454 lines
49 KiB
Go

package main
// DayZ Steam Workshop mod manager — install/uninstall/list endpoints.
//
// Architecture:
//
// Controller (this file) Agent
// ────────────────────── ─────
// 1. operator clicks Install Mod
// 2. run SteamCMD sidecar directly via
// docker CLI (controller-side) ──→ shared volume panel-dayz-workshop
// writing into that shared volume ↓
// 3. tell agent to create symlink handleDayzModInstall
// + copy bikeys into /game/keys
// via DayzModInstallRequest envelope
// 4. update /game-saves/panel-mods.txt via
// existing FsWrite RPC so the entrypoint
// picks up the mod on next start.
//
// The Steam credential store persists across instances (same panel-wide
// encrypted row the DayZ install uses), so one login ceremony unlocks
// both the server download and every subsequent workshop item.
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
// dayzModListFile is the operator-readable file we write to on each
// install/uninstall/reorder. The DayZ module's entrypoint reads this file
// on every server start and builds the `-mod=@A;@B` argument.
const (
dayzModListFile = "/game-saves/panel-mods.txt"
dayzServerModListFile = "/game-saves/panel-servermods.txt"
dayzWorkshopAppID = "221100" // DayZ main game — Workshop items live under this app
)
type dayzMod struct {
WorkshopID string `json:"workshop_id,omitempty"`
FolderName string `json:"folder_name"`
ServerMod bool `json:"server_mod,omitempty"`
Order int `json:"order"`
// Optional metadata resolved from Steam:
Title string `json:"title,omitempty"`
Size int64 `json:"size,omitempty"`
Updated int64 `json:"updated,omitempty"` // unix seconds
}
type dayzInstallReq struct {
WorkshopID string `json:"workshop_id"` // "1564026768" OR full URL; parsed below
FolderName string `json:"folder_name,omitempty"` // optional; auto-detected from Workshop metadata
ServerMod bool `json:"server_mod,omitempty"`
}
// handleDayzModsList scans the mod list files + returns the parsed set.
// Runs entirely on the controller side: reads panel-mods.txt + panel-servermods.txt
// via the existing FsRead RPC.
func (h *httpServer) handleDayzModsList(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
agentID, err := h.lookupInstance(r, id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
conn := h.registry.get(agentID)
if conn == nil {
writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", agentID))
return
}
mods := []dayzMod{}
mods = append(mods, h.dayzReadModList(r.Context(), conn, id, dayzModListFile, false)...)
mods = append(mods, h.dayzReadModList(r.Context(), conn, id, dayzServerModListFile, true)...)
writeJSON(w, http.StatusOK, map[string]any{"mods": mods})
}
// handleDayzModInstall kicks off an async mod-install job and returns
// {job_id} immediately. The actual download + symlink + bikey copy runs
// in a background goroutine; the frontend polls GET /dayz/mods/jobs to
// render a live progress card. This keeps the browser request short and
// lets operators close the tab without aborting a 2 GB download.
func (h *httpServer) handleDayzModInstall(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
agentID, err := h.lookupInstance(r, id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
conn := h.registry.get(agentID)
if conn == nil {
writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", agentID))
return
}
var req dayzInstallReq
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
workshopID, perr := parseWorkshopID(req.WorkshopID)
if perr != nil {
writeError(w, http.StatusBadRequest, "bad_workshop_id", perr.Error())
return
}
// Look up mod title via Steam's public GetPublishedFileDetails. Used
// as the default folder name (sanitized to a DayZ-friendly "@Name").
// Happens synchronously since it's fast (single API call, ~200ms).
var title string
var size, updated int64
if d, derr := steamWorkshopDetail(workshopID); derr == nil && d != nil {
title, size, updated = d.Title, d.Size, d.TimeUpdated
}
folder := req.FolderName
if folder == "" {
folder = sanitizeDayzFolder(title)
if folder == "" {
folder = "@WS" + workshopID
}
}
if !strings.HasPrefix(folder, "@") {
folder = "@" + folder
}
// Steam creds — fail fast on missing cache so the UI can pop the
// login modal instead of wasting a job slot.
user, _, ok := h.getSteamLoginForUpdate(r.Context())
if !ok {
writeJSON(w, http.StatusOK, map[string]any{
"steam_login_required": true,
"reason": "DayZ Workshop downloads need a Steam account that owns the base game. Sign in once — we'll cache the token so you won't be prompted again.",
})
return
}
// Reject duplicate in-flight installs of the same mod to stop the
// operator from double-clicking into concurrent SteamCMD runs.
for _, existing := range h.dayzJobs.listForInstance(id) {
existing.mu.Lock()
dup := existing.WorkshopID == workshopID &&
(existing.Phase == phaseQueued || existing.Phase == phaseDownloading ||
existing.Phase == phaseLinking || existing.Phase == phaseFinalizing)
existingID := existing.ID
existing.mu.Unlock()
if dup {
writeJSON(w, http.StatusAccepted, map[string]any{
"accepted": true,
"job_id": existingID,
"duplicate": true,
})
return
}
}
job := h.dayzJobs.create(id, workshopID, folder, req.ServerMod)
job.setResult(title, size, updated)
job.appendLine(fmt.Sprintf("queued: %s (id=%s) → %s", title, workshopID, folder))
h.log.Info("dayz mod install queued",
"job_id", job.ID, "instance_id", id, "workshop_id", workshopID, "folder", folder)
// Detach: goroutine context lives beyond the HTTP request.
go h.runDayzModInstallJob(context.Background(), job, agentID, user)
writeJSON(w, http.StatusAccepted, map[string]any{
"accepted": true,
"job_id": job.ID,
"workshop_id": workshopID,
"folder_name": folder,
"title": title,
})
}
// runDayzModInstallJob is the full install pipeline, run off the HTTP
// request. Uses a background context so browser disconnects don't cancel
// the download. Each phase updates job state for the UI polling loop.
func (h *httpServer) runDayzModInstallJob(ctx context.Context, job *dayzModJob, agentID, user string) {
defer func() {
if rec := recover(); rec != nil {
h.log.Error("dayz mod install goroutine panic", "job_id", job.ID, "recover", rec)
job.setError(fmt.Sprintf("internal error: %v", rec), false)
}
}()
// ---- phase: download ----
job.setPhase(phaseDownloading)
err := runSteamCMDWorkshopDownloadStreaming(ctx, h.log, user, job, jobLineSink(job))
if err != nil {
if errors.Is(err, errTokenStale) || errors.Is(err, errTokenMissing) {
job.setLoginNeeded("Your Steam session expired. Sign in again to refresh the cached token.")
return
}
permanent := errors.Is(err, errWorkshopNotFound) ||
strings.Contains(err.Error(), "Access Denied") ||
strings.Contains(err.Error(), "own the DayZ base game")
friendly := friendlyDownloadError(err)
job.appendLine("download failed: " + friendly)
job.setError(friendly, permanent)
return
}
// ---- phase: linking ----
job.setPhase(phaseLinking)
job.appendLine("requesting agent: create symlink + copy bikeys")
conn := h.registry.get(agentID)
if conn == nil {
job.setError("agent disconnected during download", false)
return
}
corrID := newCorrelationID("dzmod_install")
if err := conn.Send(&panelv1.ControllerEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.ControllerEnvelope_DayzModInstall{
DayzModInstall: &panelv1.DayzModInstallRequest{
InstanceId: job.InstanceID,
WorkshopId: job.WorkshopID,
FolderName: job.FolderName,
},
},
}); err != nil {
job.setError("forward to agent: "+err.Error(), false)
return
}
awaitCtx, awaitCancel := context.WithTimeout(ctx, 60*time.Second)
env, err := h.pending.Await(awaitCtx, corrID, 60*time.Second)
awaitCancel()
if err != nil {
job.setError("agent didn't reply: "+err.Error(), false)
return
}
res := env.GetDayzModInstallResult()
if res == nil {
job.setError("agent returned empty reply", false)
return
}
if res.Error != "" {
job.setError("agent: "+res.Error, false)
return
}
job.setAgentResult(res.BikeysCopied, res.ResolvedModPath)
if len(res.BikeysCopied) > 0 {
job.appendLine(fmt.Sprintf("copied %d bikey%s: %s",
len(res.BikeysCopied), plural(len(res.BikeysCopied)),
strings.Join(res.BikeysCopied, ", ")))
} else {
job.appendLine("no bikeys shipped with this mod (common for admin/QoL mods)")
}
// ---- phase: finalizing ----
job.setPhase(phaseFinalizing)
listPath := dayzModListFile
if job.ServerMod {
listPath = dayzServerModListFile
}
if err := h.dayzAppendModList(ctx, conn, job.InstanceID, listPath, job.FolderName); err != nil {
job.setError("append to mod list: "+err.Error(), false)
return
}
job.appendLine(fmt.Sprintf("added %s to %s", job.FolderName, listPath))
job.setPercent(100)
job.setPhase(phaseDone)
h.log.Info("dayz mod install done", "job_id", job.ID, "folder", job.FolderName)
}
func plural(n int) string {
if n == 1 {
return ""
}
return "s"
}
// friendlyDownloadError collapses SteamCMD's verbose tails into a short
// one-line message the UI can render directly. Keeps the full error in
// the job's log tail for operators who want the raw story.
func friendlyDownloadError(err error) string {
if err == nil {
return ""
}
switch {
case errors.Is(err, errWorkshopNotFound):
return "Workshop item not found — ID is wrong or the mod was delisted."
case errors.Is(err, errTokenStale):
return "Steam session expired — sign in again to refresh the cached token."
case errors.Is(err, errTokenMissing):
return "No Steam username configured — complete the Steam login first."
case strings.Contains(err.Error(), "Access Denied"):
return "Access denied — this mod is restricted for your Steam account (region-lock or private item)."
case strings.Contains(err.Error(), "own the DayZ base game"):
return "Your Steam account doesn't own DayZ — use an account that has the game."
case strings.Contains(err.Error(), "timeout"):
return "SteamCMD timed out after 4 retries. Try again in a few minutes — Steam CDN is flaky."
}
// Fallback: first line only, plus prefix hint.
msg := err.Error()
if i := strings.Index(msg, "\n"); i > 0 {
msg = msg[:i]
}
if len(msg) > 200 {
msg = msg[:200] + "…"
}
return msg
}
// jobLineSink returns a callback that pipes steamcmd lines into the job's
// log + parses percent-style progress hints. Kept here (next to the handler)
// so the filtering rules live close to the UI they feed.
func jobLineSink(job *dayzModJob) func(string) {
return func(line string) {
line = strings.TrimSpace(line)
if line == "" {
return
}
// Attempt number hint emitted by the retry loop — promote to state.
if strings.HasPrefix(line, "__attempt__:") {
if n, err := strconv.Atoi(strings.TrimPrefix(line, "__attempt__:")); err == nil {
job.setAttempt(n)
if n > 1 {
job.appendLine(fmt.Sprintf("retry attempt #%d — resuming partial download", n))
}
return
}
}
// Parse steamcmd progress lines of the form:
// " Update state (0x61) downloading, progress: 23.45 (51200000 / 218103808)"
// Or:
// "[ 23%] progress..."
if pct, ok := parseSteamCMDPercent(line); ok {
job.setPercent(pct)
}
// Skip the boilerplate Valve prints on every run — we already
// showed them in a prior install, and they're pure noise for
// the UI log tail.
if steamCMDBoilerplate(line) {
return
}
job.appendLine(line)
}
}
var progressRE = regexp.MustCompile(`progress:\s*([0-9.]+)`)
var percentBucketRE = regexp.MustCompile(`^\[\s*([0-9]+)\s*%\s*\]`)
func parseSteamCMDPercent(line string) (float64, bool) {
if m := progressRE.FindStringSubmatch(line); len(m) > 1 {
if v, err := strconv.ParseFloat(m[1], 64); err == nil {
return v, true
}
}
if m := percentBucketRE.FindStringSubmatch(line); len(m) > 1 {
if v, err := strconv.Atoi(m[1]); err == nil {
return float64(v), true
}
}
return 0, false
}
func steamCMDBoilerplate(line string) bool {
switch {
case strings.HasPrefix(line, "Redirecting stderr"),
strings.HasPrefix(line, "Logging directory:"),
strings.HasPrefix(line, "UpdateUI:"),
strings.HasPrefix(line, "Steam Console Client"),
strings.HasPrefix(line, "-- type "),
strings.HasPrefix(line, "Loading Steam API"),
strings.HasPrefix(line, "IPC function call"),
strings.HasPrefix(line, "Unloading Steam API"):
return true
}
return false
}
// handleDayzModUninstall removes the symlink + bikeys + mod-list entry.
func (h *httpServer) handleDayzModUninstall(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
agentID, err := h.lookupInstance(r, id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
conn := h.registry.get(agentID)
if conn == nil {
writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", agentID))
return
}
folder := r.PathValue("folder")
if folder == "" {
writeError(w, http.StatusBadRequest, "bad_folder", "folder path segment is required")
return
}
if !strings.HasPrefix(folder, "@") {
folder = "@" + folder
}
// 1. Remove from BOTH mod list files (operator may have moved it between
// client-mod / server-mod at some point).
_ = h.dayzRemoveFromModList(r.Context(), conn, id, dayzModListFile, folder)
_ = h.dayzRemoveFromModList(r.Context(), conn, id, dayzServerModListFile, folder)
// 2. Agent-side symlink + bikey removal.
corrID := newCorrelationID("dzmod_uninstall")
if err := conn.Send(&panelv1.ControllerEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.ControllerEnvelope_DayzModUninstall{
DayzModUninstall: &panelv1.DayzModUninstallRequest{
InstanceId: id,
FolderName: folder,
},
},
}); err != nil {
writeError(w, http.StatusBadGateway, "forward", err.Error())
return
}
env, err := h.pending.Await(r.Context(), corrID, 30*time.Second)
if err != nil {
writeError(w, http.StatusGatewayTimeout, "timeout", err.Error())
return
}
res := env.GetDayzModUninstallResult()
if res == nil {
writeError(w, http.StatusInternalServerError, "bad_reply", "agent returned no uninstall result")
return
}
if res.Error != "" {
writeError(w, http.StatusBadRequest, "agent", res.Error)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"folder": folder,
"bikeys_removed": res.BikeysRemoved,
})
}
// handleDayzModJobList returns every tracked install job for an instance,
// newest first. Used by the Mods tab's polling loop to render progress
// cards — includes terminal jobs so the UI can show a recently-finished
// success or error briefly before auto-dismissing.
func (h *httpServer) handleDayzModJobList(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, err := h.lookupInstance(r, id); err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
jobs := h.dayzJobs.listForInstance(id)
snaps := make([]map[string]any, 0, len(jobs))
for _, j := range jobs {
snaps = append(snaps, j.snapshot())
}
writeJSON(w, http.StatusOK, map[string]any{"jobs": snaps})
}
// handleDayzModJobGet returns one job. Separate endpoint so a retry or
// "show log tail" action can fetch fresh state without re-listing.
func (h *httpServer) handleDayzModJobGet(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, err := h.lookupInstance(r, id); err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
job := h.dayzJobs.get(r.PathValue("jobId"))
if job == nil || job.InstanceID != id {
writeError(w, http.StatusNotFound, "not_found", "no such job")
return
}
writeJSON(w, http.StatusOK, job.snapshot())
}
// handleDayzModJobDelete drops a job from the manager — used by the UI's
// "✕ Dismiss" button on download cards. Safe on any phase; for in-flight
// jobs the goroutine orphans (its state updates go nowhere but the
// download already on disk persists so a future retry resumes).
func (h *httpServer) handleDayzModJobDelete(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, err := h.lookupInstance(r, id); err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
jobID := r.PathValue("jobId")
job := h.dayzJobs.get(jobID)
if job == nil || job.InstanceID != id {
writeError(w, http.StatusNotFound, "not_found", "no such job")
return
}
removed := h.dayzJobs.remove(jobID)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "removed": removed})
}
// handleDayzModXmlScan recursively finds every *.xml file inside an installed
// mod's workshop folder and returns metadata (path, size, inferred kind) so
// the UI can offer to auto-merge the snippets into the server's mission tree.
// The XML Mods tab already has the merge engine — this just surfaces what's
// available in the mod itself instead of making the operator find + paste.
func (h *httpServer) handleDayzModXmlScan(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
agentID, err := h.lookupInstance(r, id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
conn := h.registry.get(agentID)
if conn == nil {
writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", agentID))
return
}
folder := r.PathValue("folder")
if !strings.HasPrefix(folder, "@") {
folder = "@" + folder
}
root := "/game/" + folder
// BFS walk capped at 4 levels deep — mod XML bundles live shallow.
type entry struct {
Path string `json:"path"`
Size int64 `json:"size"`
Kind string `json:"kind"`
}
var files []entry
queue := []string{root}
visited := 0
for len(queue) > 0 && visited < 500 {
cur := queue[0]
queue = queue[1:]
visited++
entries, ferr := h.fsListAt(r.Context(), conn, id, cur)
if ferr != nil {
continue
}
for _, e := range entries {
full := cur + "/" + e.Name
if e.IsDir {
// Don't descend into addons/ — those are .pbo bundles, no loose XML.
if strings.EqualFold(e.Name, "addons") || strings.EqualFold(e.Name, "keys") {
continue
}
// Cap depth by counting slashes in relative path
rel := strings.TrimPrefix(full, root)
if strings.Count(rel, "/") < 4 {
queue = append(queue, full)
}
continue
}
if !strings.HasSuffix(strings.ToLower(e.Name), ".xml") {
continue
}
rel := strings.TrimPrefix(full, root+"/")
files = append(files, entry{
Path: rel,
Size: e.Size,
Kind: guessDayzXmlKind(e.Name),
})
}
}
writeJSON(w, http.StatusOK, map[string]any{
"folder": folder,
"root": root,
"files": files,
})
}
// fsListAt is a small helper that wraps the FsList RPC into a typed return.
// Existing filesList HTTP handler does the same plumbing inline; we want a
// reusable function for the recursive walk.
type fsEntry struct {
Name string
IsDir bool
Size int64
}
func (h *httpServer) fsListAt(ctx context.Context, conn *agentConn, instanceID, path string) ([]fsEntry, error) {
corrID := newCorrelationID("fslist_scan")
if err := conn.Send(&panelv1.ControllerEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.ControllerEnvelope_FsList{
FsList: &panelv1.FsListRequest{InstanceId: instanceID, Path: path},
},
}); err != nil {
return nil, err
}
env, err := h.pending.Await(ctx, corrID, 10*time.Second)
if err != nil {
return nil, err
}
res := env.GetFsListResult()
if res == nil || res.Error != "" {
if res != nil {
return nil, errors.New(res.Error)
}
return nil, errors.New("no reply")
}
out := make([]fsEntry, 0, len(res.Entries))
for _, e := range res.Entries {
out = append(out, fsEntry{Name: e.Name, IsDir: e.IsDir, Size: e.Size})
}
return out, nil
}
// guessDayzXmlKind infers which Central Economy file a snippet is meant to
// merge into, based on filename. The XML merger does its own root-element
// sniff; this is just a UX hint so we can label the button correctly.
func guessDayzXmlKind(name string) string {
n := strings.ToLower(name)
switch {
case n == "types.xml", strings.HasSuffix(n, "_types.xml"):
return "types"
case n == "events.xml", strings.HasSuffix(n, "_events.xml"):
return "events"
case n == "cfgspawnabletypes.xml", strings.HasSuffix(n, "_cfgspawnabletypes.xml"), strings.HasSuffix(n, "_spawnabletypes.xml"):
return "spawnabletypes"
case n == "cfgeventspawns.xml", strings.HasSuffix(n, "_eventspawns.xml"):
return "eventspawns"
case n == "cfgrandompresets.xml", strings.HasSuffix(n, "_randompresets.xml"):
return "randompresets"
case n == "messages.xml", strings.HasSuffix(n, "_messages.xml"):
return "messages"
}
return "" // unknown — merger will still try to sniff by root element
}
// handleDayzModsReorder replaces the ordering of client mods + server mods
// wholesale. Request body: {client: ["@A","@B"], server: ["@X"]}
func (h *httpServer) handleDayzModsReorder(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
agentID, err := h.lookupInstance(r, id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
conn := h.registry.get(agentID)
if conn == nil {
writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", agentID))
return
}
var req struct {
Client []string `json:"client"`
Server []string `json:"server"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 32*1024)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
if err := h.dayzWriteModList(r.Context(), conn, id, dayzModListFile, normalizeFolders(req.Client)); err != nil {
writeError(w, http.StatusBadGateway, "write_client", err.Error())
return
}
if err := h.dayzWriteModList(r.Context(), conn, id, dayzServerModListFile, normalizeFolders(req.Server)); err != nil {
writeError(w, http.StatusBadGateway, "write_server", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// ---- helpers ----
var workshopIDRE = regexp.MustCompile(`(?:[?&]id=|/filedetails/\?id=|^)(\d{6,20})`)
// parseWorkshopID accepts a raw workshop id or a full Steam Workshop URL.
func parseWorkshopID(input string) (string, error) {
input = strings.TrimSpace(input)
if input == "" {
return "", errors.New("workshop_id is required")
}
if _, err := strconv.ParseInt(input, 10, 64); err == nil {
return input, nil
}
m := workshopIDRE.FindStringSubmatch(input)
if len(m) > 1 {
return m[1], nil
}
return "", errors.New("couldn't extract a Steam Workshop item id from: " + input)
}
// sanitizeDayzFolder turns a Steam Workshop title into a DayZ-friendly
// @Folder name. Rules matching the wider DayZ community convention:
// - strip non-alphanumeric (keeps underscore + dash)
// - collapse multi-spaces
// - prefix with @
func sanitizeDayzFolder(title string) string {
if title == "" {
return ""
}
// Replace anything that isn't safe with a dash, then trim dashes.
var b strings.Builder
for _, r := range title {
switch {
case r >= 'A' && r <= 'Z', r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
case r == '_' || r == '-':
b.WriteRune(r)
}
}
name := strings.Trim(b.String(), "-_")
if name == "" {
return ""
}
return "@" + name
}
func normalizeFolders(in []string) []string {
out := make([]string, 0, len(in))
seen := map[string]bool{}
for _, s := range in {
s = strings.TrimSpace(s)
if s == "" {
continue
}
if !strings.HasPrefix(s, "@") {
s = "@" + s
}
if strings.ContainsAny(s, "/\\ \t\n") {
continue
}
if seen[s] {
continue
}
seen[s] = true
out = append(out, s)
}
return out
}
// dayzReadModList reads a plain-text mod list file from the instance
// and returns a slice of dayzMod entries keyed by their folder name.
// Missing file is treated as an empty list.
func (h *httpServer) dayzReadModList(ctx context.Context, conn *agentConn, instanceID, path string, serverMod bool) []dayzMod {
res, err := h.dayzXmlReadFile(ctx, conn, instanceID, path)
if err != nil || res == nil || res.Error != "" {
return nil
}
out := []dayzMod{}
order := 0
scanner := bufio.NewScanner(bytes.NewReader(res.Content))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
out = append(out, dayzMod{
FolderName: line,
Order: order,
ServerMod: serverMod,
})
order++
}
return out
}
// dayzAppendModList appends a folder name to the mod list file (idempotent).
func (h *httpServer) dayzAppendModList(ctx context.Context, conn *agentConn, instanceID, path, folder string) error {
existing := h.dayzReadModList(ctx, conn, instanceID, path, false)
for _, m := range existing {
if m.FolderName == folder {
return nil // already present
}
}
folders := make([]string, 0, len(existing)+1)
for _, m := range existing {
folders = append(folders, m.FolderName)
}
folders = append(folders, folder)
return h.dayzWriteModList(ctx, conn, instanceID, path, folders)
}
// dayzRemoveFromModList rewrites the list without the target folder.
func (h *httpServer) dayzRemoveFromModList(ctx context.Context, conn *agentConn, instanceID, path, folder string) error {
existing := h.dayzReadModList(ctx, conn, instanceID, path, false)
folders := make([]string, 0, len(existing))
for _, m := range existing {
if m.FolderName == folder {
continue
}
folders = append(folders, m.FolderName)
}
return h.dayzWriteModList(ctx, conn, instanceID, path, folders)
}
// dayzWriteModList replaces the file with the supplied folder list.
func (h *httpServer) dayzWriteModList(ctx context.Context, conn *agentConn, instanceID, path string, folders []string) error {
var body bytes.Buffer
body.WriteString("# panel-managed mod list — one @Folder per line\n")
body.WriteString("# blank lines and '#' comments ignored\n")
for _, f := range folders {
body.WriteString(f)
body.WriteByte('\n')
}
return h.dayzXmlWriteFile(ctx, conn, instanceID, path, body.Bytes())
}
// runSteamCMDWorkshopDownload invokes a disposable SteamCMD sidecar to
// download one Workshop item into the shared panel-dayz-workshop volume.
// Wraps runSteamCMDWorkshopDownloadOnce in a retry loop so big mods
// (Expansion, 2+ GB) can resume past SteamCMD's internal depot-download
// timeout — SteamCMD hashes each on-disk chunk and skips what's already
// present, so a second invocation picks up where the first stopped.
//
// This mirrors what AMP's dayz-originalupdates.json relies on implicitly:
// the SteamCMD stage is marked SkipOnFailure=false for workshop pulls,
// and Valve's client itself retries internally via the depot-chunk queue.
// We just surface the retry at the job level so the panel's Install button
// doesn't return "timeout" for mods that would succeed on retry.
func runSteamCMDWorkshopDownload(ctx context.Context, log Logger, user, workshopID string) error {
if user == "" {
return errTokenMissing
}
const maxAttempts = 4
var lastErr error
for attempt := 1; attempt <= maxAttempts; attempt++ {
if attempt > 1 {
// Backoff: 10 s, 30 s, 60 s. Gives Steam CDN a breather and
// keeps long-running HTTP requests within a sane window
// (max cumulative wait ~100 s of pure backoff).
backoff := time.Duration(attempt*attempt-1) * 10 * time.Second
log.Info("steamcmd workshop_download retrying", "workshop_id", workshopID,
"attempt", attempt, "backoff", backoff, "last_err", lastErr)
select {
case <-time.After(backoff):
case <-ctx.Done():
return ctx.Err()
}
}
err := runSteamCMDWorkshopDownloadOnce(ctx, log, user, workshopID)
if err == nil {
return nil
}
// Stop immediately on non-retriable errors (bad auth, access denied,
// subscription missing, stale token needs modal re-login, item gone).
if errors.Is(err, errTokenStale) || errors.Is(err, errTokenMissing) ||
errors.Is(err, errWorkshopNotFound) ||
strings.Contains(err.Error(), "Access Denied") ||
strings.Contains(err.Error(), "own the DayZ base game") {
return err
}
lastErr = err
}
return fmt.Errorf("workshop download failed after %d attempts — last: %w", maxAttempts, lastErr)
}
// runSteamCMDWorkshopDownloadStreaming is the async-job equivalent: same
// retry-on-timeout semantics as runSteamCMDWorkshopDownload, but the
// sidecar's stdout/stderr is streamed line-by-line into `onLine` as it
// runs, so the UI can tail progress in real time. Receives the job so it
// can emit synthetic "__attempt__:N" marker lines for the UI.
func runSteamCMDWorkshopDownloadStreaming(ctx context.Context, log Logger, user string, job *dayzModJob, onLine func(string)) error {
if user == "" {
return errTokenMissing
}
const maxAttempts = 4
var lastErr error
for attempt := 1; attempt <= maxAttempts; attempt++ {
if attempt > 1 {
backoff := time.Duration(attempt*attempt-1) * 10 * time.Second
onLine(fmt.Sprintf("retry in %s (attempt %d/%d) — last: %v", backoff, attempt, maxAttempts, lastErr))
select {
case <-time.After(backoff):
case <-ctx.Done():
return ctx.Err()
}
}
onLine(fmt.Sprintf("__attempt__:%d", attempt))
err := runSteamCMDWorkshopDownloadOnceStreaming(ctx, log, user, job.WorkshopID, onLine)
if err == nil {
return nil
}
if errors.Is(err, errTokenStale) || errors.Is(err, errTokenMissing) ||
errors.Is(err, errWorkshopNotFound) ||
strings.Contains(err.Error(), "Access Denied") ||
strings.Contains(err.Error(), "own the DayZ base game") {
return err
}
lastErr = err
}
return fmt.Errorf("workshop download failed after %d attempts — last: %w", maxAttempts, lastErr)
}
// runSteamCMDWorkshopDownloadOnceStreaming runs one SteamCMD sidecar and
// pipes its combined output to onLine. Classification (success / stale
// token / access denied / timeout) is done on the captured output after
// the process exits.
func runSteamCMDWorkshopDownloadOnceStreaming(ctx context.Context, log Logger, user, workshopID string, onLine func(string)) error {
dockerBin := locateDockerBinary()
cctx, cancel := context.WithTimeout(ctx, 15*time.Minute)
defer cancel()
args := []string{
"run", "--rm",
"-v", "panel-steamcmd-auth:/root/.local/share/Steam",
"-v", "panel-dayz-workshop:/dayz-workshop/steamapps/workshop",
"steamcmd/steamcmd:latest",
"+force_install_dir", "/dayz-workshop",
"+login", user,
"+workshop_download_item", dayzWorkshopAppID, workshopID, "validate",
"+quit",
}
cmd := exec.CommandContext(cctx, dockerBin, args...)
// Tee combined stdout+stderr through a line-splitting writer so onLine
// fires once per real line as it happens (rather than buffering all
// of it until process exit). We also keep a bytes.Buffer copy so the
// post-exit classifier can match on the full plain text.
var full bytes.Buffer
lw := &lineWriter{
full: &full,
cb: func(line string) {
onLine(ansiEscapeRE.ReplaceAllString(line, ""))
},
}
cmd.Stdout = lw
cmd.Stderr = lw
err := cmd.Run()
lw.flush()
plain := ansiEscapeRE.ReplaceAllString(full.String(), "")
log.Info("steamcmd workshop_download output", "workshop_id", workshopID,
"exit_err", err, "tail", lastFewLines(plain, 20))
switch {
case strings.Contains(plain, "Success. Downloaded item"):
return nil
case strings.Contains(plain, "failed (File Not Found)"),
strings.Contains(plain, "failed (Invalid App ID)"):
// Nonexistent or deleted workshop item — no point retrying.
return errWorkshopNotFound
case strings.Contains(plain, "Logon failure"),
strings.Contains(plain, "Invalid Password"),
strings.Contains(plain, "No cached credentials"),
strings.Contains(plain, "cached credentials are invalid"),
strings.Contains(plain, "FAILED (Rate Limit Exceeded)"),
strings.Contains(plain, "FAILED (Access Denied)"):
return errTokenStale
case strings.Contains(plain, "failed (Access Denied)"):
// Item exists but account can't download it (delisted, region-
// locked, or marked for a different branch). Hard fail.
return fmt.Errorf("Access Denied — this workshop item is restricted for your account: %s", lastFewLines(plain, 6))
case strings.Contains(plain, "No subscription") || strings.Contains(plain, "Missing configuration"):
return fmt.Errorf("Steam account doesn't own the DayZ base game (or item access denied): %s", lastFewLines(plain, 6))
case strings.Contains(plain, "Timeout downloading item"):
// Retriable — SteamCMD resumes from partial data on next attempt.
return fmt.Errorf("steamcmd timeout mid-download (will retry)")
case err != nil:
return fmt.Errorf("steamcmd exec: %w — last output: %s", err, lastFewLines(plain, 6))
}
return fmt.Errorf("steamcmd: unknown result — tail:\n%s", lastFewLines(plain, 12))
}
// lineWriter is an io.Writer that splits on any of \n \r \r\n and invokes
// cb per line (stripped of the terminator). Also tees everything into a
// tail-accessible buffer. Safe for concurrent writes from cmd.Stdout and
// cmd.Stderr sharing the same pointer (exec spawns separate goroutines).
type lineWriter struct {
mu sync.Mutex
buf []byte
full *bytes.Buffer
cb func(string)
}
func (w *lineWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
w.full.Write(p)
w.buf = append(w.buf, p...)
// Emit complete lines. Treat \r as line terminator too so SteamCMD's
// progress-overwrite lines aren't stuck buffering for minutes.
for {
i := firstLineTerm(w.buf)
if i < 0 {
break
}
line := string(w.buf[:i])
w.buf = w.buf[i+1:]
if line != "" && w.cb != nil {
w.cb(line)
}
}
return len(p), nil
}
// flush emits any trailing partial line as its own callback. Safe to call
// multiple times.
func (w *lineWriter) flush() {
w.mu.Lock()
defer w.mu.Unlock()
if len(w.buf) > 0 && w.cb != nil {
w.cb(string(w.buf))
}
w.buf = w.buf[:0]
}
func firstLineTerm(b []byte) int {
for i, c := range b {
if c == '\n' || c == '\r' {
return i
}
}
return -1
}
func runSteamCMDWorkshopDownloadOnce(ctx context.Context, log Logger, user, workshopID string) error {
dockerBin := locateDockerBinary()
// Per-attempt timeout is generous: big mods (Community Framework,
// Expansion) can be 2+ GB. Retries reset this clock.
cctx, cancel := context.WithTimeout(ctx, 15*time.Minute)
defer cancel()
// The steamcmd:latest image uses /root/.local/share/Steam as its data
// dir. Workshop items download to <install_dir>/steamapps/workshop/...
// when +force_install_dir is set; we match that to the shared volume.
args := []string{
"run", "--rm",
"-v", "panel-steamcmd-auth:/root/.local/share/Steam",
"-v", "panel-dayz-workshop:/dayz-workshop/steamapps/workshop",
"steamcmd/steamcmd:latest",
"+force_install_dir", "/dayz-workshop",
"+login", user, // no password — uses cached refresh token
"+workshop_download_item", dayzWorkshopAppID, workshopID, "validate",
"+quit",
}
cmd := exec.CommandContext(cctx, dockerBin, args...)
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
err := cmd.Run()
outStr := out.String()
// ANSI color escapes splice into literal text and break substring matches.
plain := ansiEscapeRE.ReplaceAllString(outStr, "")
log.Info("steamcmd workshop_download output", "workshop_id", workshopID,
"exit_err", err, "tail", lastFewLines(plain, 20))
// Classify result. Keep in sync with runSteamCMDWorkshopDownloadOnceStreaming.
switch {
case strings.Contains(plain, "Success. Downloaded item"):
return nil
case strings.Contains(plain, "failed (File Not Found)"),
strings.Contains(plain, "failed (Invalid App ID)"):
return errWorkshopNotFound
case strings.Contains(plain, "Logon failure"),
strings.Contains(plain, "Invalid Password"),
strings.Contains(plain, "No cached credentials"),
strings.Contains(plain, "cached credentials are invalid"),
strings.Contains(plain, "FAILED (Rate Limit Exceeded)"),
strings.Contains(plain, "FAILED (Access Denied)"):
return errTokenStale
case strings.Contains(plain, "failed (Access Denied)"):
return fmt.Errorf("Access Denied — this workshop item is restricted for your account: %s", lastFewLines(plain, 6))
case strings.Contains(plain, "No subscription") || strings.Contains(plain, "Missing configuration"):
return fmt.Errorf("Steam account doesn't own the DayZ base game (or item access denied): %s", lastFewLines(plain, 6))
case strings.Contains(plain, "Timeout downloading item"):
return fmt.Errorf("steamcmd timeout mid-download (will retry)")
case err != nil:
return fmt.Errorf("steamcmd exec: %w — last output: %s", err, lastFewLines(plain, 6))
}
return fmt.Errorf("steamcmd: unknown result — tail:\n%s", lastFewLines(plain, 12))
}
// errTokenStale / errTokenMissing distinguish "user needs to re-auth" from
// other download failures. The HTTP handler converts these into a
// steam_login_required response so the frontend can pop the Steam login
// modal, same flow as the Update button.
var (
errTokenStale = errors.New("steam refresh token expired — re-authentication required")
errTokenMissing = errors.New("no steam username configured — complete the Steam login ceremony first")
errWorkshopNotFound = errors.New("workshop item not found — it may have been delisted or the ID is wrong")
)
// Logger is a minimal slog-ish interface so runSteamCMDWorkshopDownload can
// log without importing slog directly (httpServer already has one).
type Logger interface {
Info(msg string, keysAndValues ...any)
}
func locateDockerBinary() string {
candidates := []string{
os.Getenv("DOCKER_BIN"),
`C:\Program Files\Docker\Docker\resources\bin\docker.exe`,
"/usr/bin/docker",
"/usr/local/bin/docker",
}
for _, c := range candidates {
if c == "" {
continue
}
if fi, err := os.Stat(c); err == nil && !fi.IsDir() {
return c
}
}
return "docker"
}
func lastFewLines(s string, n int) string {
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
if len(lines) > n {
lines = lines[len(lines)-n:]
}
return strings.Join(lines, "\n")
}
// ---- Steam Workshop metadata (public, no auth required) ----
// workshopDetail carries everything the UI needs to render a mod info
// modal — title, description, preview image, author, size, timestamps,
// engagement stats. Filled from GetPublishedFileDetails (no API key
// required). ID is the published_file_id (decimal string) so callers
// fetching a batch can map results back to their input ids without
// guessing — GetPublishedFileDetails returns entries in input order
// only when every id is valid; missing/private items are silently
// dropped from the response, which would shift indices in a naïve
// match-by-position scheme.
type workshopDetail struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
PreviewURL string `json:"preview_url"`
Creator string `json:"creator"`
Size int64 `json:"size"`
TimeCreated int64 `json:"time_created"`
TimeUpdated int64 `json:"time_updated"`
Subscriptions int64 `json:"subscriptions"`
Favorited int64 `json:"favorited"`
Views int64 `json:"views"`
Tags []string `json:"tags"`
}
// steamWorkshopDetail calls GetPublishedFileDetails for a single item.
func steamWorkshopDetail(workshopID string) (*workshopDetail, error) {
details, err := steamWorkshopDetails([]string{workshopID})
if err != nil {
return nil, err
}
if len(details) == 0 {
return nil, errors.New("no workshop item found")
}
return details[0], nil
}
// steamWorkshopDetails batches multiple ids in one request.
func steamWorkshopDetails(ids []string) ([]*workshopDetail, error) {
if len(ids) == 0 {
return nil, nil
}
var form strings.Builder
fmt.Fprintf(&form, "itemcount=%d", len(ids))
for i, id := range ids {
fmt.Fprintf(&form, "&publishedfileids%%5B%d%%5D=%s", i, id)
}
req, err := http.NewRequest("POST",
"https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/",
strings.NewReader(form.String()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var payload struct {
Response struct {
PublishedFileDetails []struct {
PublishedFileID string `json:"publishedfileid"`
Title string `json:"title"`
Description string `json:"description"`
PreviewURL string `json:"preview_url"`
Creator string `json:"creator"`
FileSize any `json:"file_size"`
TimeCreated int64 `json:"time_created"`
TimeUpdated int64 `json:"time_updated"`
Subscriptions int64 `json:"subscriptions"`
Favorited int64 `json:"favorited"`
Views int64 `json:"views"`
Tags []struct {
Tag string `json:"tag"`
} `json:"tags"`
Result int `json:"result"`
} `json:"publishedfiledetails"`
} `json:"response"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return nil, err
}
out := make([]*workshopDetail, 0, len(payload.Response.PublishedFileDetails))
for _, d := range payload.Response.PublishedFileDetails {
if d.Result != 0 && d.Result != 1 {
continue
}
var size int64
switch v := d.FileSize.(type) {
case float64:
size = int64(v)
case string:
size, _ = strconv.ParseInt(v, 10, 64)
}
tags := make([]string, 0, len(d.Tags))
for _, t := range d.Tags {
if t.Tag != "" {
tags = append(tags, t.Tag)
}
}
out = append(out, &workshopDetail{
ID: d.PublishedFileID,
Title: d.Title,
Description: d.Description,
PreviewURL: d.PreviewURL,
Creator: d.Creator,
Size: size,
TimeCreated: d.TimeCreated,
TimeUpdated: d.TimeUpdated,
Subscriptions: d.Subscriptions,
Favorited: d.Favorited,
Views: d.Views,
Tags: tags,
})
}
return out, nil
}
// handleWorkshopDetail proxies the above for the UI's mod info modal.
func (h *httpServer) handleWorkshopDetail(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
if id == "" {
writeError(w, http.StatusBadRequest, "bad_id", "id query param required")
return
}
workshopID, perr := parseWorkshopID(id)
if perr != nil {
writeError(w, http.StatusBadRequest, "bad_id", perr.Error())
return
}
d, err := steamWorkshopDetail(workshopID)
if err != nil {
writeError(w, http.StatusBadGateway, "steam", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"workshop_id": workshopID,
"title": d.Title,
"description": d.Description,
"preview_url": d.PreviewURL,
"creator": d.Creator,
"size": d.Size,
"time_created": d.TimeCreated,
"time_updated": d.TimeUpdated,
"subscriptions": d.Subscriptions,
"favorited": d.Favorited,
"views": d.Views,
"tags": d.Tags,
"suggested_folder": sanitizeDayzFolder(d.Title),
})
}
// handleWorkshopSearch is a best-effort search of the Steam Workshop.
//
// Steam's current /workshop/browse page is a React SPA with hashed CSS class
// names — scraping item cards is no longer viable. BUT: the page's server-
// rendered anchor hrefs still carry `?id=NNN` for every hit, in ranked order.
// We extract those ids and batch-hydrate title/preview/size from the public
// GetPublishedFileDetails endpoint (same API steamWorkshopDetail uses).
//
// Query params:
// q — required search text
// app — optional Steam appid to scope the search; defaults to 221100
// (DayZ) for backward compat. Conan Exiles uses 440900.
// tag — optional Steam Workshop tag filter. Each value becomes a
// requiredtags[] URL parameter so Steam restricts results to
// items with that tag. Repeatable as a CSV (`tag=Enhanced,Server`).
// Conan Exiles uses "Enhanced" / "Legacy" to split UE5 / UE4 mods.
func (h *httpServer) handleWorkshopSearch(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
if q == "" {
writeError(w, http.StatusBadRequest, "bad_query", "q query param required")
return
}
appID := strings.TrimSpace(r.URL.Query().Get("app"))
if appID == "" {
appID = dayzWorkshopAppID
}
// Defence-in-depth: only allow numeric appids so we can't be tricked
// into building a URL pointing at a different host.
for _, c := range appID {
if c < '0' || c > '9' {
writeError(w, http.StatusBadRequest, "bad_app", "app must be numeric")
return
}
}
// Parse tag filter — CSV-friendly so the UI can pass multiple in one
// query param. Each unique non-empty token becomes a requiredtags[] URL
// param. Steam's browse endpoint AND-combines them, so we drop dupes.
tagParam := strings.TrimSpace(r.URL.Query().Get("tag"))
tagFilter := ""
if tagParam != "" {
seen := map[string]bool{}
for _, raw := range strings.Split(tagParam, ",") {
t := strings.TrimSpace(raw)
if t == "" || seen[strings.ToLower(t)] {
continue
}
seen[strings.ToLower(t)] = true
// Steam expects tags as PHP-array form: requiredtags%5B%5D=Enhanced
tagFilter += "&requiredtags%5B%5D=" + urlEncode(t)
}
}
u := fmt.Sprintf("https://steamcommunity.com/workshop/browse/?appid=%s&searchtext=%s&browsesort=textsearch&section=readytouseitems%s",
appID, urlEncode(q), tagFilter)
client := &http.Client{Timeout: 12 * time.Second}
req, _ := http.NewRequest("GET", u, nil)
// Use a real-browser UA — Steam returns a lighter/redirected page for
// unknown UAs and that breaks id extraction.
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36")
resp, err := client.Do(req)
if err != nil {
writeError(w, http.StatusBadGateway, "steam", err.Error())
return
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 4*1024*1024))
if err != nil {
writeError(w, http.StatusBadGateway, "read", err.Error())
return
}
ids := extractWorkshopIDs(string(body), 30)
if len(ids) == 0 {
writeJSON(w, http.StatusOK, map[string]any{"items": []workshopSearchItem{}, "query": q})
return
}
items, err := hydrateWorkshopItems(ids)
if err != nil {
writeError(w, http.StatusBadGateway, "steam_details", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items, "query": q})
}
var wsBrowseIDRE = regexp.MustCompile(`[?&]id=(\d{6,20})`)
// extractWorkshopIDs pulls unique published-file ids out of the Steam
// Workshop browse HTML, preserving rank order (which is Steam's popularity/
// relevance order for that query). Limit caps how many we keep.
func extractWorkshopIDs(body string, limit int) []string {
seen := map[string]bool{}
out := []string{}
for _, m := range wsBrowseIDRE.FindAllStringSubmatch(body, -1) {
id := m[1]
if seen[id] {
continue
}
seen[id] = true
out = append(out, id)
if len(out) >= limit {
break
}
}
return out
}
// hydrateWorkshopItems calls GetPublishedFileDetails for up to ~50 ids and
// returns search-result structs ready for the UI.
func hydrateWorkshopItems(ids []string) ([]workshopSearchItem, error) {
if len(ids) == 0 {
return nil, nil
}
var form strings.Builder
fmt.Fprintf(&form, "itemcount=%d", len(ids))
for i, id := range ids {
fmt.Fprintf(&form, "&publishedfileids%%5B%d%%5D=%s", i, id)
}
req, err := http.NewRequest("POST",
"https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/",
strings.NewReader(form.String()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var payload struct {
Response struct {
PublishedFileDetails []struct {
PublishedFileID string `json:"publishedfileid"`
Title string `json:"title"`
PreviewURL string `json:"preview_url"`
Creator string `json:"creator"`
Result int `json:"result"`
} `json:"publishedfiledetails"`
} `json:"response"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return nil, err
}
// Steam returns details in the order we asked — preserve it.
idOrder := map[string]int{}
for i, id := range ids {
idOrder[id] = i
}
out := make([]workshopSearchItem, 0, len(payload.Response.PublishedFileDetails))
for _, d := range payload.Response.PublishedFileDetails {
if d.Result != 0 && d.Result != 1 {
// Result: 1 = OK; anything else (9 = not found, etc.) — skip.
continue
}
if d.Title == "" {
continue
}
out = append(out, workshopSearchItem{
ID: d.PublishedFileID,
Title: d.Title,
PreviewURL: d.PreviewURL,
Author: d.Creator, // SteamID; UI renders "by <id>" — acceptable
})
}
sort.SliceStable(out, func(i, j int) bool {
return idOrder[out[i].ID] < idOrder[out[j].ID]
})
return out, nil
}
type workshopSearchItem struct {
ID string `json:"id"`
Title string `json:"title"`
PreviewURL string `json:"preview_url,omitempty"`
Author string `json:"author,omitempty"`
}
func urlEncode(s string) string {
var b strings.Builder
for _, r := range s {
switch {
case r >= 'A' && r <= 'Z', r >= 'a' && r <= 'z', r >= '0' && r <= '9',
r == '-' || r == '_' || r == '.' || r == '~':
b.WriteRune(r)
case r == ' ':
b.WriteByte('+')
default:
fmt.Fprintf(&b, "%%%02X", r)
}
}
return b.String()
}