Skip to content
Open
5 changes: 3 additions & 2 deletions cmd/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ func connectAndRunTUI(cmd *cobra.Command, target string) error {
}
}

m := newRemoteRootModel(port, service, serverHost)
m := newRemoteRootModel(port, service, serverHost, baseURL, token)

p := tea.NewProgram(m)
go func() {
Expand All @@ -93,8 +93,9 @@ func connectAndRunTUI(cmd *cobra.Command, target string) error {
return nil
}

func newRemoteRootModel(port int, service core.DownloadService, serverHost string) tui.RootModel {
func newRemoteRootModel(port int, service core.DownloadService, serverHost string, baseURL string, token string) tui.RootModel {
m := tui.InitialRootModel(port, Version, service, nil, false)
m.Transfer = core.NewRemoteTransferService(baseURL, token)
m.ServerHost = serverHost
m.IsRemote = true
return m
Expand Down
4 changes: 2 additions & 2 deletions cmd/connect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ func (f *fakeRemoteDownloadService) GetStatus(id string) (*types.DownloadStatus,
func (f *fakeRemoteDownloadService) Shutdown() error { return nil }

func TestNewRemoteRootModel_UsesNilOrchestrator(t *testing.T) {
m := newRemoteRootModel(1700, nil, "example.com")
m := newRemoteRootModel(1700, nil, "example.com", "https://example.com:1700", "token")

if m.Orchestrator != nil {
t.Fatal("expected remote root model to use nil orchestrator")
Expand All @@ -80,7 +80,7 @@ func TestNewRemoteRootModel_UsesNilOrchestrator(t *testing.T) {

func TestNewRemoteRootModel_DownloadRequestUsesServiceAdd(t *testing.T) {
service := &fakeRemoteDownloadService{}
m := newRemoteRootModel(1700, service, "example.com")
m := newRemoteRootModel(1700, service, "example.com", "https://example.com:1700", "token")
m.Settings.Extension.ExtensionPrompt = false
m.Settings.General.WarnOnDuplicate = false

Expand Down
54 changes: 54 additions & 0 deletions cmd/export.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package cmd

import (
"context"
"fmt"

"github.com/SurgeDM/Surge/internal/backup"
"github.com/spf13/cobra"
)

var exportCmd = &cobra.Command{
Use: "export <file>",
Short: "Export Surge data to a bundle",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := initializeGlobalState(); err != nil {
return err
}

includeLogs, _ := cmd.Flags().GetBool("include-logs")
includePartials, _ := cmd.Flags().GetBool("include-partials")
leavePaused, _ := cmd.Flags().GetBool("leave-paused")
jsonOutput, _ := cmd.Flags().GetBool("json")

transfer, _, err := resolveTransferService()
if err != nil {
return err
}

manifest, err := exportBundle(context.Background(), transfer, args[0], backup.ExportOptions{
IncludeLogs: includeLogs,
IncludePartials: includePartials,
LeavePaused: leavePaused,
})
if err != nil {
return err
}

if jsonOutput {
return printJSON(manifest)
}
fmt.Printf("Exported bundle to %s\n", ensureExportPath(args[0]))
return nil
},
}

func init() {
rootCmd.AddCommand(exportCmd)
exportCmd.Flags().Bool("include-logs", false, "Include Surge log files in the export bundle")
exportCmd.Flags().Bool("include-partials", false, "Include paused .surge partial files for resumable restore")
exportCmd.Flags().Bool("leave-paused", false, "Leave downloads paused after export")
exportCmd.Flags().Bool("json", false, "Output manifest as JSON")
}

2 changes: 2 additions & 0 deletions cmd/http_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ func registerHTTPRoutes(mux *http.ServeMux, port int, defaultOutputDir string, s

writeJSONResponse(w, http.StatusOK, map[string]string{"status": "updated", "id": id, "url": newURL})
})))

registerTransferRoutes(mux, service)
}

func eventsHandler(service core.DownloadService) http.HandlerFunc {
Expand Down
69 changes: 69 additions & 0 deletions cmd/import.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package cmd

import (
"context"

"github.com/SurgeDM/Surge/internal/backup"
"github.com/spf13/cobra"
)

var importCmd = &cobra.Command{
Use: "import <file>",
Short: "Preview or import a Surge bundle",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := initializeGlobalState(); err != nil {
return err
}

apply, _ := cmd.Flags().GetBool("apply")
replace, _ := cmd.Flags().GetBool("replace")
rootDir, _ := cmd.Flags().GetString("root")
jsonOutput, _ := cmd.Flags().GetBool("json")

transfer, isRemote, err := resolveTransferService()
if err != nil {
return err
}

previewOpts := backup.ImportOptions{
RootDir: rootDir,
Replace: replace,
}
preview, err := previewBundle(context.Background(), transfer, args[0], previewOpts)
if err != nil {
return err
}

if !apply {
if jsonOutput {
return printJSON(preview)
}
printImportPreview(preview)
return nil
}

opts := previewOpts
if isRemote {
opts.SessionID = preview.SessionID
}
result, err := applyBundle(context.Background(), transfer, args[0], opts)
if err != nil {
return err
}

if jsonOutput {
return printJSON(result)
}
printImportResult(result)
return nil
},
}

func init() {
rootCmd.AddCommand(importCmd)
importCmd.Flags().Bool("apply", false, "Apply the import after preview succeeds")
importCmd.Flags().Bool("replace", false, "Replace existing Surge state instead of merging")
importCmd.Flags().String("root", "", "Root directory for rebased imported paths")
importCmd.Flags().Bool("json", false, "Output preview/result as JSON")
}
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,7 @@ func startTUI(port int, exitWhenDone bool, noResume bool) error {

m := tui.InitialRootModel(port, Version, GlobalService, currentLifecycle(), noResume)
m = m.WithEnqueueContext(currentEnqueueContext(), currentEnqueueCancel())
m.Transfer = core.NewLocalTransferService(GlobalService, Version)
m.ServerHost = serverBindHost
if m.ServerHost == "" {
m.ServerHost = "127.0.0.1"
Expand Down
191 changes: 191 additions & 0 deletions cmd/transfer_api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
package cmd

import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"

"github.com/SurgeDM/Surge/internal/backup"
"github.com/SurgeDM/Surge/internal/core"
)

var maxImportPreviewSize int64 = 512 * 1024 * 1024

type stagedImportSession struct {
Path string
CreatedAt time.Time
}

var importSessionStore = struct {
mu sync.Mutex
items map[string]stagedImportSession
}{
items: make(map[string]stagedImportSession),
}

func cleanupImportSessions() {
cutoff := time.Now().Add(-1 * time.Hour)
importSessionStore.mu.Lock()
defer importSessionStore.mu.Unlock()
for id, session := range importSessionStore.items {
if session.CreatedAt.After(cutoff) {
continue
}
_ = os.Remove(session.Path)
delete(importSessionStore.items, id)
}
}

func newImportSessionID() (string, error) {
token := make([]byte, 16)
if _, err := rand.Read(token); err != nil {
return "", err
}
return hex.EncodeToString(token), nil
}

func registerTransferRoutes(mux *http.ServeMux, service core.DownloadService) {
mux.HandleFunc("/data/export", requireMethod(http.MethodPost, func(w http.ResponseWriter, r *http.Request) {
cleanupImportSessions()
var opts backup.ExportOptions
if r.Body != nil {
if err := json.NewDecoder(r.Body).Decode(&opts); err != nil && err != io.EOF {
http.Error(w, "invalid export request", http.StatusBadRequest)
return
}
}

transfer := core.NewLocalTransferService(service, Version)
tmpFile, err := os.CreateTemp("", "surge-export-*.zip")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer func() {
_ = tmpFile.Close()
_ = os.Remove(tmpFile.Name())
}()

manifest, err := transfer.Export(r.Context(), opts, tmpFile)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if _, err := tmpFile.Seek(0, 0); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

manifestBytes, _ := json.Marshal(manifest)
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", "attachment; filename=\"surge-export.surge-export\"")
w.Header().Set("X-Surge-Manifest", url.QueryEscape(string(manifestBytes)))
if _, err := io.Copy(w, tmpFile); err != nil {
return
}
}))

mux.HandleFunc("/data/import/preview", requireMethod(http.MethodPost, func(w http.ResponseWriter, r *http.Request) {
cleanupImportSessions()
opts := backup.ImportOptions{
RootDir: strings.TrimSpace(r.URL.Query().Get("root_dir")),
Replace: strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("replace")), "true"),
}
tmpFile, err := os.CreateTemp("", "surge-import-preview-*.zip")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer func() { _ = tmpFile.Close() }()
r.Body = http.MaxBytesReader(w, r.Body, maxImportPreviewSize)
if _, err := io.Copy(tmpFile, r.Body); err != nil {
_ = os.Remove(tmpFile.Name())
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if _, err := tmpFile.Seek(0, 0); err != nil {
_ = os.Remove(tmpFile.Name())
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

transfer := core.NewLocalTransferService(service, Version)
preview, err := transfer.PreviewImport(context.Background(), tmpFile, opts)
if err != nil {
_ = os.Remove(tmpFile.Name())
http.Error(w, err.Error(), http.StatusBadRequest)
return
}

sessionID, err := newImportSessionID()
if err != nil {
_ = os.Remove(tmpFile.Name())
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
importSessionStore.mu.Lock()
importSessionStore.items[sessionID] = stagedImportSession{
Path: tmpFile.Name(),
CreatedAt: time.Now(),
}
importSessionStore.mu.Unlock()

preview.SessionID = sessionID
writeJSONResponse(w, http.StatusOK, preview)
}))

mux.HandleFunc("/data/import/apply", requireMethod(http.MethodPost, func(w http.ResponseWriter, r *http.Request) {
cleanupImportSessions()
var req struct {
SessionID string `json:"session_id"`
RootDir string `json:"root_dir"`
Replace bool `json:"replace"`
}
if err := decodeJSONBody(r, &req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if strings.TrimSpace(req.SessionID) == "" {
http.Error(w, "missing session_id", http.StatusBadRequest)
return
}

importSessionStore.mu.Lock()
session, ok := importSessionStore.items[req.SessionID]
if ok {
delete(importSessionStore.items, req.SessionID)
}
importSessionStore.mu.Unlock()
if !ok {
http.Error(w, "import session not found", http.StatusNotFound)
return
}
defer func() { _ = os.Remove(session.Path) }()

file, err := os.Open(session.Path)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer func() { _ = file.Close() }()

transfer := core.NewLocalTransferService(service, Version)
result, err := transfer.ApplyImport(r.Context(), file, backup.ImportOptions{
RootDir: req.RootDir,
Replace: req.Replace,
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSONResponse(w, http.StatusOK, result)
}))
}
Loading
Loading