diff --git a/.gitignore b/.gitignore index 824e7e7..9855015 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ dist/ # cli running assets zeabur.zip +.env.local diff --git a/internal/cmd/file/file.go b/internal/cmd/file/file.go new file mode 100644 index 0000000..9265ed9 --- /dev/null +++ b/internal/cmd/file/file.go @@ -0,0 +1,20 @@ +package file + +import ( + "github.com/spf13/cobra" + + filePullCmd "github.com/zeabur/cli/internal/cmd/file/pull" + "github.com/zeabur/cli/internal/cmdutil" +) + +// NewCmdFile creates the file command. +func NewCmdFile(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "file ", + Short: "Manage uploaded files", + } + + cmd.AddCommand(filePullCmd.NewCmdPull(f)) + + return cmd +} diff --git a/internal/cmd/file/pull/pull.go b/internal/cmd/file/pull/pull.go new file mode 100644 index 0000000..9f3103c --- /dev/null +++ b/internal/cmd/file/pull/pull.go @@ -0,0 +1,59 @@ +package pull + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/zeabur/cli/internal/cmdutil" +) + +// NewCmdPull creates the file pull command. +func NewCmdPull(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "pull [target-dir]", + Short: "Download uploaded project files to local directory", + Args: cobra.RangeArgs(0, 2), + RunE: func(cmd *cobra.Command, args []string) error { + uploadID := "" + targetDir := "." + if len(args) > 0 { + uploadID = args[0] + } + if len(args) > 1 { + targetDir = args[1] + } + return runPull(cmd, f, uploadID, targetDir) + }, + } + + return cmd +} + +func runPull(cmd *cobra.Command, f *cmdutil.Factory, uploadID string, targetDir string) error { + if uploadID == "" { + if !f.Interactive { + return fmt.Errorf("upload-id is required") + } + id, err := f.Prompter.Input("Enter upload ID:", "") + if err != nil { + return err + } + uploadID = strings.TrimSpace(id) + if uploadID == "" { + return fmt.Errorf("upload-id is required") + } + } + + count, skipped, err := f.ApiClient.PullUploadFiles(cmd.Context(), uploadID, targetDir) + if err != nil { + return fmt.Errorf("pull files failed: %w", err) + } + + f.Log.Infof("Pulled %d files to %s", count, targetDir) + if skipped > 0 { + f.Log.Infof("Skipped %d binary files (images, fonts, archives, etc.)", skipped) + } + + return nil +} diff --git a/internal/cmd/root/root.go b/internal/cmd/root/root.go index 33cc1a5..49adf43 100644 --- a/internal/cmd/root/root.go +++ b/internal/cmd/root/root.go @@ -20,6 +20,7 @@ import ( deploymentCmd "github.com/zeabur/cli/internal/cmd/deployment" domainCmd "github.com/zeabur/cli/internal/cmd/domain" emailCmd "github.com/zeabur/cli/internal/cmd/email" + fileCmd "github.com/zeabur/cli/internal/cmd/file" profileCmd "github.com/zeabur/cli/internal/cmd/profile" projectCmd "github.com/zeabur/cli/internal/cmd/project" serverCmd "github.com/zeabur/cli/internal/cmd/server" @@ -161,6 +162,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, commit, date string) (*cobra.Comman cmd.AddCommand(completionCmd.NewCmdCompletion(f)) cmd.AddCommand(variableCmd.NewCmdVariable(f)) cmd.AddCommand(emailCmd.NewCmdEmail(f)) + cmd.AddCommand(fileCmd.NewCmdFile(f)) cmd.AddCommand(aihubCmd.NewCmdAIHub(f)) // replace default help command with our custom one that supports --all diff --git a/pkg/api/file.go b/pkg/api/file.go new file mode 100644 index 0000000..c73b4aa --- /dev/null +++ b/pkg/api/file.go @@ -0,0 +1,117 @@ +package api + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" +) + +var binaryExtensions = map[string]bool{ + ".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".bmp": true, + ".ico": true, ".svg": true, ".webp": true, ".avif": true, + ".woff": true, ".woff2": true, ".ttf": true, ".otf": true, ".eot": true, + ".zip": true, ".tar": true, ".gz": true, ".bz2": true, ".7z": true, ".rar": true, + ".pdf": true, ".doc": true, ".docx": true, ".xls": true, ".xlsx": true, + ".exe": true, ".dll": true, ".so": true, ".dylib": true, ".wasm": true, + ".mp3": true, ".mp4": true, ".wav": true, ".ogg": true, ".webm": true, + ".bin": true, ".dat": true, ".db": true, ".sqlite": true, +} + +// ListUploadFiles lists files in an uploaded project. +func (c *client) ListUploadFiles(ctx context.Context, uploadID string, path *string) ([]string, error) { + var query struct { + Files []string `graphql:"files(uploadID: $uploadID, path: $path)"` + } + + err := c.Query(ctx, &query, V{ + "uploadID": ObjectID(uploadID), + "path": path, + }) + if err != nil { + return nil, err + } + + return query.Files, nil +} + +// ReadUploadFile reads the content of a file in an uploaded project. +func (c *client) ReadUploadFile(ctx context.Context, uploadID string, path string) (string, error) { + var query struct { + FileContent string `graphql:"fileContent(uploadID: $uploadID, path: $path)"` + } + + err := c.Query(ctx, &query, V{ + "uploadID": ObjectID(uploadID), + "path": path, + }) + if err != nil { + return "", err + } + + return query.FileContent, nil +} + +// PullUploadFiles downloads all files from an upload to a local directory. +// Returns (pulled count, skipped binary count, error). +func (c *client) PullUploadFiles(ctx context.Context, uploadID string, targetDir string) (int, int, error) { + return c.pullDir(ctx, uploadID, "", targetDir) +} + +func (c *client) pullDir(ctx context.Context, uploadID string, remotePath string, localDir string) (int, int, error) { + baseDir := filepath.Clean(localDir) + + var pathPtr *string + if remotePath != "" { + pathPtr = &remotePath + } + + entries, err := c.ListUploadFiles(ctx, uploadID, pathPtr) + if err != nil { + return 0, 0, fmt.Errorf("list %q: %w", remotePath, err) + } + + count, skipped := 0, 0 + for _, entry := range entries { + fullRemote := remotePath + entry + fullLocal := filepath.Join(baseDir, fullRemote) + + rel, relErr := filepath.Rel(baseDir, filepath.Clean(fullLocal)) + if relErr != nil || strings.HasPrefix(rel, "..") { + return count, skipped, fmt.Errorf("path traversal detected: %q", fullRemote) + } + + if strings.HasSuffix(entry, "/") { + if err := os.MkdirAll(fullLocal, 0o755); err != nil { + return count, skipped, fmt.Errorf("mkdir %q: %w", fullLocal, err) + } + n, s, err := c.pullDir(ctx, uploadID, fullRemote, localDir) + count += n + skipped += s + if err != nil { + return count, skipped, err + } + } else { + ext := strings.ToLower(filepath.Ext(entry)) + if binaryExtensions[ext] { + skipped++ + continue + } + content, err := c.ReadUploadFile(ctx, uploadID, fullRemote) + if err != nil { + return count, skipped, fmt.Errorf("read %q: %w", fullRemote, err) + } + dir := filepath.Dir(fullLocal) + if err := os.MkdirAll(dir, 0o755); err != nil { + return count, skipped, fmt.Errorf("mkdir %q: %w", dir, err) + } + if err := os.WriteFile(fullLocal, []byte(content), 0o644); err != nil { + return count, skipped, fmt.Errorf("write %q: %w", fullLocal, err) + } + count++ + } + } + + return count, skipped, nil +} diff --git a/pkg/api/interface.go b/pkg/api/interface.go index 81e33af..38734f1 100644 --- a/pkg/api/interface.go +++ b/pkg/api/interface.go @@ -23,6 +23,7 @@ type Client interface { AIHubAPI ZSendAPI RegisteredDomainAPI + FileAPI } type ( @@ -178,6 +179,12 @@ type ( UpdateRegistrantContact(ctx context.Context, registeredDomainID string, input model.UpdateRegistrantContactInput) error } + FileAPI interface { + ListUploadFiles(ctx context.Context, uploadID string, path *string) ([]string, error) + ReadUploadFile(ctx context.Context, uploadID string, path string) (string, error) + PullUploadFiles(ctx context.Context, uploadID string, targetDir string) (int, int, error) + } + ZSendAPI interface { GetZSendOnboardingStatus(ctx context.Context) (*model.ZSendOnboardingStatus, error) GetZSendUserStatus(ctx context.Context) (*model.ZSendUserStatus, error)