Skip to content
Open
16 changes: 13 additions & 3 deletions cmd/functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,10 @@ var (
Long: "Download the source code for a Function from the linked Supabase project.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return download.Run(cmd.Context(), args[0], flags.ProjectRef, useLegacyBundle, afero.NewOsFs())
if useApi {
useDocker = false
}
return download.Run(cmd.Context(), args[0], flags.ProjectRef, useLegacyBundle, useDocker, afero.NewOsFs())
},
}

Expand Down Expand Up @@ -138,6 +141,7 @@ func init() {
deployFlags.BoolVar(&useLegacyBundle, "legacy-bundle", false, "Use legacy bundling mechanism.")
functionsDeployCmd.MarkFlagsMutuallyExclusive("use-api", "use-docker", "legacy-bundle")
cobra.CheckErr(deployFlags.MarkHidden("legacy-bundle"))
cobra.CheckErr(deployFlags.MarkHidden("use-docker"))
deployFlags.UintVarP(&maxJobs, "jobs", "j", 1, "Maximum number of parallel jobs.")
deployFlags.BoolVar(noVerifyJWT, "no-verify-jwt", false, "Disable JWT verification for the Function.")
deployFlags.BoolVar(&prune, "prune", false, "Delete Functions that exist in Supabase project but not locally.")
Expand All @@ -152,8 +156,14 @@ func init() {
functionsServeCmd.MarkFlagsMutuallyExclusive("inspect", "inspect-mode")
functionsServeCmd.Flags().Bool("all", true, "Serve all Functions.")
cobra.CheckErr(functionsServeCmd.Flags().MarkHidden("all"))
functionsDownloadCmd.Flags().StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project.")
functionsDownloadCmd.Flags().BoolVar(&useLegacyBundle, "legacy-bundle", false, "Use legacy bundling mechanism.")
downloadFlags := functionsDownloadCmd.Flags()
downloadFlags.StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project.")
downloadFlags.BoolVar(&useLegacyBundle, "legacy-bundle", false, "Use legacy bundling mechanism.")
downloadFlags.BoolVar(&useApi, "use-api", false, "Use Management API to unbundle functions server-side.")
downloadFlags.BoolVar(&useDocker, "use-docker", true, "Use Docker to unbundle functions client-side.")
functionsDownloadCmd.MarkFlagsMutuallyExclusive("use-api", "use-docker", "legacy-bundle")
cobra.CheckErr(downloadFlags.MarkHidden("legacy-bundle"))
cobra.CheckErr(downloadFlags.MarkHidden("use-docker"))
functionsCmd.AddCommand(functionsListCmd)
functionsCmd.AddCommand(functionsDeleteCmd)
functionsCmd.AddCommand(functionsDeployCmd)
Expand Down
278 changes: 273 additions & 5 deletions internal/functions/download/download.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import (
"context"
"fmt"
"io"
"mime"
"mime/multipart"
"net/http"
"net/url"
"os"
"os/exec"
"path"
Expand Down Expand Up @@ -112,15 +115,30 @@ func downloadFunction(ctx context.Context, projectRef, slug, extractScriptPath s
return nil
}

func Run(ctx context.Context, slug string, projectRef string, useLegacyBundle bool, fsys afero.Fs) error {
func Run(ctx context.Context, slug, projectRef string, useLegacyBundle, useDocker bool, fsys afero.Fs) error {
// Sanity check
if err := flags.LoadConfig(fsys); err != nil {
return err
}

if useLegacyBundle {
return RunLegacy(ctx, slug, projectRef, fsys)
}
// 1. Sanity check
if err := flags.LoadConfig(fsys); err != nil {
return err

if useDocker {
if utils.IsDockerRunning(ctx) {
// download eszip file for client-side unbundling with edge-runtime
return downloadWithDockerUnbundle(ctx, slug, projectRef, fsys)
} else {
fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:"), "Docker is not running")
}
}
// 2. Download eszip to temp file

// Use server-side unbundling with multipart/form-data
return downloadWithServerSideUnbundle(ctx, slug, projectRef, fsys)
}

func downloadWithDockerUnbundle(ctx context.Context, slug string, projectRef string, fsys afero.Fs) error {
eszipPath, err := downloadOne(ctx, slug, projectRef, fsys)
if err != nil {
return err
Expand Down Expand Up @@ -238,3 +256,253 @@ deno_version = 2
func suggestLegacyBundle(slug string) string {
return fmt.Sprintf("\nIf your function is deployed using CLI < 1.120.0, trying running %s instead.", utils.Aqua("supabase functions download --legacy-bundle "+slug))
}

// New server-side unbundle implementation that mirrors Studio's entrypoint-based
// base-dir + relative path behaviour.
func downloadWithServerSideUnbundle(ctx context.Context, slug, projectRef string, fsys afero.Fs) error {
fmt.Fprintln(os.Stderr, "Downloading "+utils.Bold(slug))

metadata, err := getFunctionMetadata(ctx, projectRef, slug)
if err != nil {
return errors.Errorf("failed to get function metadata: %w", err)
}

entrypointUrl, err := url.Parse(*metadata.EntrypointPath)
if err != nil {
return errors.Errorf("failed to parse entrypoint URL: %w", err)
}

// Request multipart/form-data response using RequestEditorFn
resp, err := utils.GetSupabase().V1GetAFunctionBody(ctx, projectRef, slug, func(ctx context.Context, req *http.Request) error {
req.Header.Set("Accept", "multipart/form-data")
return nil
})
if err != nil {
return errors.Errorf("failed to download function: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return errors.Errorf("Error status %d: %w", resp.StatusCode, err)
}
return errors.Errorf("Error status %d: %s", resp.StatusCode, string(body))
}

// Parse the multipart response
mediaType, params, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
if err != nil {
return errors.Errorf("failed to parse content type: %w", err)
}

if !strings.HasPrefix(mediaType, "multipart/") {
return errors.Errorf("expected multipart response, got %s", mediaType)
}

// Root directory on disk: supabase/functions/<slug>
funcDir := filepath.Join(utils.FunctionsDir, slug)
if err := utils.MkdirIfNotExistFS(fsys, funcDir); err != nil {
return err
}

type partEntry struct {
path string
data []byte
}

var parts []partEntry

// Parse multipart form and buffer parts in memory.
mr := multipart.NewReader(resp.Body, params["boundary"])
for {
part, err := mr.NextPart()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return errors.Errorf("failed to read multipart: %w", err)
}

partPath, err := getPartPath(part)
if err != nil {
return err
}

data, err := io.ReadAll(part)
if err != nil {
return errors.Errorf("failed to read part data: %w", err)
}

if partPath == "" {
fmt.Fprintln(utils.GetDebugLogger(), "Skipping part without filename")
} else {
parts = append(parts, partEntry{path: partPath, data: data})
}
}

// Collect file paths (excluding empty ones) to infer the base directory.
var filepaths []string
for _, p := range parts {
if p.path != "" {
filepaths = append(filepaths, p.path)
}
}

baseDir := getBaseDirFromEntrypoint(entrypointUrl, filepaths)
fmt.Println("Function base directory: " + utils.Aqua(baseDir))

// Place each file under funcDir using a path relative to baseDir,
// mirroring Studio's getBasePath + relative() behavior.
for _, p := range parts {
if p.path == "" {
continue
}

relPath := getRelativePathFromBase(baseDir, p.path)
filePath, err := joinWithinDir(funcDir, relPath)
if err != nil {
return err
}

if err := utils.MkdirIfNotExistFS(fsys, filepath.Dir(filePath)); err != nil {
return err
}

if err := afero.WriteReader(fsys, filePath, bytes.NewReader(p.data)); err != nil {
return errors.Errorf("failed to write file: %w", err)
}
}

fmt.Println("Downloaded Function " + utils.Aqua(slug) + " from project " + utils.Aqua(projectRef) + ".")
return nil
}

// getPartPath extracts the filename for a multipart part, allowing for
// relative paths via the custom Supabase-Path header.
func getPartPath(part *multipart.Part) (string, error) {
// dedicated header to specify relative path, not expected to be used
if relPath := part.Header.Get("Supabase-Path"); relPath != "" {
return relPath, nil
}

// part.FileName() does not allow us to handle relative paths, so we parse Content-Disposition manually
cd := part.Header.Get("Content-Disposition")
if cd == "" {
return "", nil
}

_, params, err := mime.ParseMediaType(cd)
if err != nil {
return "", errors.Errorf("failed to parse content disposition: %w", err)
}

if filename := params["filename"]; filename != "" {
return filename, nil
}
return "", nil
}

// joinWithinDir safely joins base and rel ensuring the result stays within base directory
func joinWithinDir(base, rel string) (string, error) {
cleanRel := filepath.Clean(rel)
// Be forgiving: treat a rooted path as relative to base (e.g. "/foo" -> "foo")
if filepath.IsAbs(cleanRel) {
cleanRel = strings.TrimLeft(cleanRel, "/\\")
}
if cleanRel == ".." || strings.HasPrefix(cleanRel, "../") {
return "", errors.Errorf("invalid file path outside function directory: %s", rel)
}
joined := filepath.Join(base, cleanRel)
cleanJoined := filepath.Clean(joined)
cleanBase := filepath.Clean(base)
if cleanJoined != cleanBase && !strings.HasPrefix(cleanJoined, cleanBase+"/") {
return "", errors.Errorf("refusing to write outside function directory: %s", rel)
}
return joined, nil
}

// getBaseDirFromEntrypoint tries to infer the "base" directory for function
// files from the entrypoint URL and the list of filenames, similar to Studio's
// getBasePath logic.
func getBaseDirFromEntrypoint(entrypointUrl *url.URL, filenames []string) string {
if entrypointUrl.Path == "" {
return "/"
}

entryPath := filepath.ToSlash(entrypointUrl.Path)

// First, prefer relative filenames (no leading slash) when matching the entrypoint.
var baseDir string
for _, filename := range filenames {
if filename == "" {
continue
}
clean := filepath.ToSlash(filename)
if strings.HasPrefix(clean, "/") {
// Skip absolute paths like /tmp/...
continue
}
if strings.HasSuffix(entryPath, clean) {
baseDir = filepath.Dir(clean)
break
}
}

// If nothing matched among relative paths, fall back to any filename.
if baseDir == "" {
for _, filename := range filenames {
if filename == "" {
continue
}
clean := filepath.ToSlash(filename)
if strings.HasSuffix(entryPath, clean) {
baseDir = filepath.Dir(clean)
break
}
}
}

if baseDir != "" {
return baseDir
}

// Final fallback: derive from the entrypoint URL path itself.
baseDir = filepath.Dir(entrypointUrl.Path)
if baseDir != "" && baseDir != "." {
return baseDir
}
return "/"
}

// getRelativePathFromBase mirrors the Studio behaviour of making file paths
// relative to the "base" directory inferred from the entrypoint.
func getRelativePathFromBase(baseDir, filename string) string {
if filename == "" {
return ""
}

cleanBase := filepath.ToSlash(filepath.Clean(baseDir))
cleanFile := filepath.ToSlash(filepath.Clean(filename))

// If we don't have a meaningful base, just normalize to a relative path.
if cleanBase == "" || cleanBase == "/" || cleanBase == "." {
return strings.TrimLeft(cleanFile, "/")
}

// Try a straightforward relative path first (e.g. source/index.ts -> index.ts).
if rel, err := filepath.Rel(cleanBase, cleanFile); err == nil && rel != "." && !strings.HasPrefix(rel, "..") {
return filepath.ToSlash(rel)
}

// If the file path contains "/<baseDir>/" somewhere (e.g. /tmp/.../source/index.ts),
// strip everything up to and including that segment so we get a stable relative path
// like "index.ts" or "dir/file.ts".
segment := "/" + cleanBase + "/"
if idx := strings.Index(cleanFile, segment); idx >= 0 {
return cleanFile[idx+len(segment):]
}

// Last resort: return a normalized, slash-stripped path.
return strings.TrimLeft(cleanFile, "/")
}
Loading
Loading