package updater import ( "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "io" "net/http" "os" "path" "path/filepath" "strings" "time" archivepkg "github.com/dbledeez/panel/agent/internal/archive" modulepkg "github.com/dbledeez/panel/pkg/module" "github.com/dbledeez/panel/pkg/version" ) // DirectProvider fetches a URL and writes the response body to a target // path under the instance. Used for vanilla Minecraft jars, custom mod // packs hosted on any HTTP server, etc. type DirectProvider struct{} // Name identifies this provider kind in logs / manifest. func (DirectProvider) Name() string { return "direct" } const maxDirectDownloadBytes = 1 << 30 // 1 GiB sanity cap // Update downloads spec.URL and writes it to spec.TargetPath relative // to the instance. Optional SHA256 verification is reserved for a future // `sha256:` field on the spec. func (DirectProvider) Update(ctx context.Context, uc *Context, spec *modulepkg.UpdateProvider) error { if spec.URL == "" { return fmt.Errorf("direct provider: url is required") } if spec.TargetPath == "" { return fmt.Errorf("direct provider: target_path is required") } req, err := http.NewRequestWithContext(ctx, "GET", spec.URL, nil) if err != nil { return fmt.Errorf("new request: %w", err) } req.Header.Set("User-Agent", version.UserAgent()) client := &http.Client{Timeout: 5 * time.Minute} uc.Log(fmt.Sprintf("GET %s", spec.URL)) resp, err := client.Do(req) if err != nil { return fmt.Errorf("http: %w", err) } defer resp.Body.Close() if resp.StatusCode/100 != 2 { return fmt.Errorf("http status %d", resp.StatusCode) } if resp.ContentLength > 0 { uc.Log(fmt.Sprintf("Content-Length: %d bytes", resp.ContentLength)) } data, err := io.ReadAll(io.LimitReader(resp.Body, maxDirectDownloadBytes+1)) if err != nil { return fmt.Errorf("read body: %w", err) } if int64(len(data)) > maxDirectDownloadBytes { return fmt.Errorf("response exceeds %d-byte cap", maxDirectDownloadBytes) } sum := sha256.Sum256(data) uc.Log(fmt.Sprintf("downloaded %d bytes (sha256:%s)", len(data), hex.EncodeToString(sum[:]))) // Archive handling. Some direct URLs serve a whole game tree as an // archive (factorio's headless .tar.xz) — dumping those bytes as a // single file at TargetPath is never what the module wants. Extract // when the module opted in (spec.Extract) or when the content is a // recognized archive AND TargetPath looks like a directory (no file // extension). A TargetPath with an extension is always written // verbatim: minecraft server.jar IS a zip by magic bytes, and // terraria's entrypoint expects its .zip left on disk. format := archivepkg.DetectFormat(data, spec.URL) if spec.Extract && format == "" { return fmt.Errorf("extract requested but content is not a recognized archive (url %s)", spec.URL) } if format != "" && (spec.Extract || path.Ext(path.Base(spec.TargetPath)) == "") { uc.Log(fmt.Sprintf("content is a %s archive — extracting into %s", format, spec.TargetPath)) entries, bytesOut, err := extractToInstance(ctx, uc, spec.TargetPath, data, spec.URL) if err != nil { return fmt.Errorf("extract into %s: %w", spec.TargetPath, err) } uc.Log(fmt.Sprintf("extracted %d entries (%d bytes) into %s", entries, bytesOut, spec.TargetPath)) return nil } if err := writeToInstance(ctx, uc, spec.TargetPath, data); err != nil { return fmt.Errorf("write target: %w", err) } uc.Log(fmt.Sprintf("wrote %s", spec.TargetPath)) return nil } // containerTargetAbs resolves a provider target_path to a container- // absolute path. An absolute target that is equal to or under one of // the module's declared browseable roots or volume mount points is // used as-is (multi-root modules: factorio's /game volume sits BESIDE // its /game-saves default root). Anything else keeps the historical // behavior of joining under BrowseableRoot — terraria's entrypoint, // for one, expects /terraria-server.zip to land at // /game-saves/terraria-server.zip via exactly that join. func containerTargetAbs(uc *Context, targetPath string) string { if strings.HasPrefix(targetPath, "/") && uc.Manifest != nil { cleaned := path.Clean(targetPath) var roots []string if d := uc.Manifest.Runtime.Docker; d != nil { for _, r := range d.BrowseableRoots { if r.Path != "" { roots = append(roots, r.Path) } } for _, v := range d.Volumes { if v.Container != "" { roots = append(roots, v.Container) } } } for _, r := range roots { if cleaned == r || strings.HasPrefix(cleaned+"/", r+"/") { return cleaned } } } return path.Join(uc.BrowseableRoot, targetPath) } // extractToInstance expands archive `data` into the instance-relative // directory targetPath. Mirrors writeToInstance's storage preference: // container-path ops when the instance has a BrowseableRoot and a // container (re-encode the archive as one tar stream and let Docker // extract it in place — same pattern as the file manager's FsExtract), // falling back to plain filesystem extraction under DataPath. func extractToInstance(ctx context.Context, uc *Context, targetPath string, data []byte, originalName string) (int64, int64, error) { if targetPath == "" { return 0, 0, fmt.Errorf("target_path is required") } if uc.BrowseableRoot != "" && uc.ContainerID != "" { destAbs := containerTargetAbs(uc, targetPath) // Best-effort mkdir; only possible on a running container, and // the usual target is a volume mount point that already exists. _, _, _, _ = uc.Runtime.ExecCapture(ctx, uc.ContainerID, []string{"sh", "-c", "mkdir -p -- '" + destAbs + "'"}) pr, pw := io.Pipe() emitErr := make(chan error, 1) var entries, bytesOut int64 go func() { n, b, err := archivepkg.WriteAsTar(data, originalName, pw) entries, bytesOut = n, b _ = pw.CloseWithError(err) emitErr <- err }() if err := uc.Runtime.CopyTarToContainer(ctx, uc.ContainerID, destAbs, pr); err != nil { // Prefer the encoder's error when it carries archive-format // detail — but NOT when it's just the pipe slamming shut // because Docker aborted the copy (that would mask the real // error, e.g. "destination directory does not exist"). if encErr := <-emitErr; encErr != nil && !errors.Is(encErr, io.ErrClosedPipe) { return entries, bytesOut, encErr } return entries, bytesOut, fmt.Errorf("copy to container %s: %w", destAbs, err) } if encErr := <-emitErr; encErr != nil { return entries, bytesOut, encErr } return entries, bytesOut, nil } if uc.DataPath == "" { return 0, 0, fmt.Errorf("no storage available: container has no BrowseableRoot and instance has no DataPath") } destAbs := filepath.Join(uc.DataPath, filepath.FromSlash(targetPath)) if err := os.MkdirAll(destAbs, 0o755); err != nil { return 0, 0, fmt.Errorf("mkdir %s: %w", destAbs, err) } return archivepkg.Walk(data, originalName, func(name string, mode int64, isDir bool, body io.Reader) error { safe := archivepkg.SafeEntryPath(name) if safe == "" { return nil } out := filepath.Join(destAbs, filepath.FromSlash(safe)) if isDir { return os.MkdirAll(out, 0o755) } if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil { return err } perm := os.FileMode(mode & 0o777) if perm == 0 { perm = 0o644 } f, err := os.OpenFile(out, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, perm) if err != nil { return err } if _, err := io.Copy(f, body); err != nil { _ = f.Close() return err } return f.Close() }) }