diff --git a/e2e/domain_routing_test.go b/e2e/domain_routing_test.go index 1fdeb00..05ebf6d 100644 --- a/e2e/domain_routing_test.go +++ b/e2e/domain_routing_test.go @@ -32,6 +32,11 @@ func TestDomainRouting_BasicRouting(t *testing.T) { // Wait for deployment to be active time.Sleep(2 * time.Second) + // Get deployment details for debugging + deployment := GetDeployment(t, env, deploymentID) + t.Logf("Deployment created: ID=%s, CID=%s, Name=%s, Status=%s", + deploymentID, deployment["content_cid"], deployment["name"], deployment["status"]) + t.Run("Standard domain resolves", func(t *testing.T) { // Domain format: {deploymentName}.orama.network domain := fmt.Sprintf("%s.orama.network", deploymentName) diff --git a/pkg/gateway/handlers/deployments/static_handler.go b/pkg/gateway/handlers/deployments/static_handler.go index 00f7638..5feb87b 100644 --- a/pkg/gateway/handlers/deployments/static_handler.go +++ b/pkg/gateway/handlers/deployments/static_handler.go @@ -1,11 +1,14 @@ package deployments import ( + "archive/tar" + "compress/gzip" "context" "encoding/json" "fmt" "io" "net/http" + "os" "path/filepath" "strings" "time" @@ -88,8 +91,23 @@ func (h *StaticDeploymentHandler) HandleUpload(w http.ResponseWriter, r *http.Re zap.Int64("size", header.Size), ) - // Upload to IPFS - addResp, err := h.ipfsClient.Add(ctx, file, header.Filename) + // Extract tarball to temporary directory + tmpDir, err := os.MkdirTemp("", "static-deploy-*") + if err != nil { + h.logger.Error("Failed to create temp directory", zap.Error(err)) + http.Error(w, "Failed to process tarball", http.StatusInternalServerError) + return + } + defer os.RemoveAll(tmpDir) + + if err := extractTarball(file, tmpDir); err != nil { + h.logger.Error("Failed to extract tarball", zap.Error(err)) + http.Error(w, "Failed to extract tarball", http.StatusInternalServerError) + return + } + + // Upload extracted directory to IPFS + addResp, err := h.ipfsClient.AddDirectory(ctx, tmpDir) if err != nil { h.logger.Error("Failed to upload to IPFS", zap.Error(err)) http.Error(w, "Failed to upload content", http.StatusInternalServerError) @@ -232,3 +250,61 @@ func detectContentType(filename string) string { return "application/octet-stream" } + +// extractTarball extracts a .tar.gz file to the specified directory +func extractTarball(reader io.Reader, destDir string) error { + gzr, err := gzip.NewReader(reader) + if err != nil { + return fmt.Errorf("failed to create gzip reader: %w", err) + } + defer gzr.Close() + + tr := tar.NewReader(gzr) + + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("failed to read tar header: %w", err) + } + + // Build target path + target := filepath.Join(destDir, header.Name) + + // Prevent path traversal - clean both paths before comparing + cleanDest := filepath.Clean(destDir) + string(os.PathSeparator) + cleanTarget := filepath.Clean(target) + if !strings.HasPrefix(cleanTarget, cleanDest) && cleanTarget != filepath.Clean(destDir) { + return fmt.Errorf("invalid file path in tarball: %s", header.Name) + } + + switch header.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + case tar.TypeReg: + // Create parent directory if needed + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return fmt.Errorf("failed to create parent directory: %w", err) + } + + // Create file + f, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode)) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + + if _, err := io.Copy(f, tr); err != nil { + f.Close() + return fmt.Errorf("failed to write file: %w", err) + } + f.Close() + } + } + + return nil +} + diff --git a/pkg/ipfs/client.go b/pkg/ipfs/client.go index 7973291..3052202 100644 --- a/pkg/ipfs/client.go +++ b/pkg/ipfs/client.go @@ -10,6 +10,8 @@ import ( "mime/multipart" "net/http" "net/url" + "os" + "path/filepath" "strings" "time" @@ -19,6 +21,7 @@ import ( // IPFSClient defines the interface for IPFS operations type IPFSClient interface { Add(ctx context.Context, reader io.Reader, name string) (*AddResponse, error) + AddDirectory(ctx context.Context, dirPath string) (*AddResponse, error) Pin(ctx context.Context, cid string, name string, replicationFactor int) (*PinResponse, error) PinStatus(ctx context.Context, cid string) (*PinStatus, error) Get(ctx context.Context, cid string, ipfsAPIURL string) (io.ReadCloser, error) @@ -236,6 +239,104 @@ func (c *Client) Add(ctx context.Context, reader io.Reader, name string) (*AddRe return &last, nil } +// AddDirectory adds all files in a directory to IPFS and returns the root directory CID +func (c *Client) AddDirectory(ctx context.Context, dirPath string) (*AddResponse, error) { + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + + // Walk directory and add all files to multipart request + var totalSize int64 + err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip directories + if info.IsDir() { + return nil + } + + // Get relative path + relPath, err := filepath.Rel(dirPath, path) + if err != nil { + return fmt.Errorf("failed to get relative path: %w", err) + } + + // Read file + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read file %s: %w", path, err) + } + + totalSize += int64(len(data)) + + // Add file to multipart + part, err := writer.CreateFormFile("file", relPath) + if err != nil { + return fmt.Errorf("failed to create form file: %w", err) + } + + if _, err := part.Write(data); err != nil { + return fmt.Errorf("failed to write file data: %w", err) + } + + return nil + }) + + if err != nil { + return nil, err + } + + if err := writer.Close(); err != nil { + return nil, fmt.Errorf("failed to close writer: %w", err) + } + + // Add with wrap-in-directory to create a root directory node + apiURL := c.apiURL + "/add?wrap-in-directory=true" + + req, err := http.NewRequestWithContext(ctx, "POST", apiURL, &buf) + if err != nil { + return nil, fmt.Errorf("failed to create add request: %w", err) + } + + req.Header.Set("Content-Type", writer.FormDataContentType()) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("add request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("add failed with status %d: %s", resp.StatusCode, string(body)) + } + + // Read NDJSON responses - the last one will be the root directory + dec := json.NewDecoder(resp.Body) + var last AddResponse + + for { + var chunk AddResponse + if err := dec.Decode(&chunk); err != nil { + if errors.Is(err, io.EOF) { + break + } + return nil, fmt.Errorf("failed to decode add response: %w", err) + } + last = chunk + } + + if last.Cid == "" { + return nil, fmt.Errorf("no CID returned from IPFS") + } + + return &AddResponse{ + Cid: last.Cid, + Size: totalSize, + }, nil +} + // Pin pins a CID with specified replication factor // IPFS Cluster expects pin options (including name) as query parameters, not in JSON body func (c *Client) Pin(ctx context.Context, cid string, name string, replicationFactor int) (*PinResponse, error) {