03a281d009
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1454 lines
49 KiB
Go
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§ion=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()
|
|
}
|