From 3567bb26a48505b040b9d87a783180b03879b738 Mon Sep 17 00:00:00 2001 From: mleku Date: Sun, 2 Nov 2025 21:55:50 +0000 Subject: [PATCH] Enhance blob storage functionality with file extension support - Added an `Extension` field to `BlobMetadata` to store file extensions alongside existing metadata. - Updated the `SaveBlob` method to handle file extensions, ensuring they are stored and retrieved correctly. - Modified the `GetBlob` method to read blob data from the filesystem based on the stored extension. - Enhanced the `Storage` struct to manage blob files in a specified directory, improving organization and access. - Introduced utility functions for determining file extensions from MIME types, facilitating better file handling. - Added comprehensive tests for new functionalities, ensuring robust behavior across blob operations. --- pkg/blossom/blob.go | 18 +- pkg/blossom/handlers.go | 78 ++- pkg/blossom/http_test.go | 756 ++++++++++++++++++++++++++++ pkg/blossom/integration_test.go | 852 ++++++++++++++++++++++++++++++++ pkg/blossom/server.go | 66 +-- pkg/blossom/storage.go | 195 +++++--- pkg/blossom/utils.go | 32 ++ pkg/blossom/utils_test.go | 389 +++++++++++++++ 8 files changed, 2239 insertions(+), 147 deletions(-) create mode 100644 pkg/blossom/http_test.go create mode 100644 pkg/blossom/integration_test.go create mode 100644 pkg/blossom/utils_test.go diff --git a/pkg/blossom/blob.go b/pkg/blossom/blob.go index cf15300..e08e1a3 100644 --- a/pkg/blossom/blob.go +++ b/pkg/blossom/blob.go @@ -17,10 +17,11 @@ type BlobDescriptor struct { // BlobMetadata stores metadata about a blob in the database type BlobMetadata struct { - Pubkey []byte `json:"pubkey"` - MimeType string `json:"mime_type"` - Uploaded int64 `json:"uploaded"` - Size int64 `json:"size"` + Pubkey []byte `json:"pubkey"` + MimeType string `json:"mime_type"` + Uploaded int64 `json:"uploaded"` + Size int64 `json:"size"` + Extension string `json:"extension"` // File extension (e.g., ".png", ".pdf") } // NewBlobDescriptor creates a new blob descriptor @@ -45,10 +46,11 @@ func NewBlobMetadata(pubkey []byte, mimeType string, size int64) *BlobMetadata { mimeType = "application/octet-stream" } return &BlobMetadata{ - Pubkey: pubkey, - MimeType: mimeType, - Uploaded: time.Now().Unix(), - Size: size, + Pubkey: pubkey, + MimeType: mimeType, + Uploaded: time.Now().Unix(), + Size: size, + Extension: "", // Will be set by SaveBlob } } diff --git a/pkg/blossom/handlers.go b/pkg/blossom/handlers.go index 6a597f5..6d7d8fa 100644 --- a/pkg/blossom/handlers.go +++ b/pkg/blossom/handlers.go @@ -19,7 +19,7 @@ import ( // handleGetBlob handles GET / requests (BUD-01) func (s *Server) handleGetBlob(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/") - + // Extract SHA256 and extension sha256Hex, ext, err := ExtractSHA256FromPath(path) if err != nil { @@ -104,7 +104,7 @@ func (s *Server) handleGetBlob(w http.ResponseWriter, r *http.Request) { // handleHeadBlob handles HEAD / requests (BUD-01) func (s *Server) handleHeadBlob(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/") - + // Extract SHA256 and extension sha256Hex, ext, err := ExtractSHA256FromPath(path) if err != nil { @@ -166,7 +166,7 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { // Check ACL pubkey, _ := GetPubkeyFromRequest(r) remoteAddr := s.getRemoteAddr(r) - + if !s.checkACL(pubkey, remoteAddr, "write") { s.setErrorResponse(w, http.StatusForbidden, "insufficient permissions") return @@ -180,7 +180,7 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { } if int64(len(body)) > s.maxBlobSize { - s.setErrorResponse(w, http.StatusRequestEntityTooLarge, + s.setErrorResponse(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("blob too large: max %d bytes", s.maxBlobSize)) return } @@ -220,16 +220,22 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { GetFileExtensionFromPath(r.URL.Path), ) + // Extract extension from path or infer from MIME type + ext := GetFileExtensionFromPath(r.URL.Path) + if ext == "" { + ext = GetExtensionFromMimeType(mimeType) + } + // Check allowed MIME types if len(s.allowedMimeTypes) > 0 && !s.allowedMimeTypes[mimeType] { - s.setErrorResponse(w, http.StatusUnsupportedMediaType, + s.setErrorResponse(w, http.StatusUnsupportedMediaType, fmt.Sprintf("MIME type %s not allowed", mimeType)) return } // Save blob if it doesn't exist if !exists { - if err = s.storage.SaveBlob(sha256Hash, body, pubkey, mimeType); err != nil { + if err = s.storage.SaveBlob(sha256Hash, body, pubkey, mimeType, ext); err != nil { log.E.F("error saving blob: %v", err) s.setErrorResponse(w, http.StatusInternalServerError, "error saving blob") return @@ -242,7 +248,7 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { s.setErrorResponse(w, http.StatusInternalServerError, "internal server error") return } - + // Allow if same pubkey or if ACL allows if !utils.FastEqual(metadata.Pubkey, pubkey) && !s.checkACL(pubkey, remoteAddr, "admin") { s.setErrorResponse(w, http.StatusConflict, "blob already exists") @@ -251,27 +257,7 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { } // Build URL with extension - ext := "" - if mimeExt := GetMimeTypeFromExtension(GetFileExtensionFromPath(r.URL.Path)); mimeExt != "application/octet-stream" { - // Try to infer extension from MIME type - for extName, mime := range map[string]string{ - ".pdf": "application/pdf", - ".png": "image/png", - ".jpg": "image/jpeg", - ".gif": "image/gif", - ".webp": "image/webp", - } { - if mime == mimeType { - ext = extName - break - } - } - } - blobURL := BuildBlobURL(s.baseURL, sha256Hex, ext) - if !strings.HasSuffix(blobURL, "/") && !strings.HasPrefix(ext, "/") { - blobURL = s.baseURL + "/" + sha256Hex + ext - } // Create descriptor descriptor := NewBlobDescriptor( @@ -382,7 +368,7 @@ func (s *Server) handleUploadRequirements(w http.ResponseWriter, r *http.Request // handleListBlobs handles GET /list/ requests (BUD-02) func (s *Server) handleListBlobs(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/") - + // Extract pubkey from path: list/ if !strings.HasPrefix(path, "list/") { s.setErrorResponse(w, http.StatusBadRequest, "invalid path") @@ -462,7 +448,7 @@ func (s *Server) handleListBlobs(w http.ResponseWriter, r *http.Request) { // handleDeleteBlob handles DELETE / requests (BUD-02) func (s *Server) handleDeleteBlob(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/") - + // Extract SHA256 sha256Hex, _, err := ExtractSHA256FromPath(path) if err != nil { @@ -522,7 +508,7 @@ func (s *Server) handleMirror(w http.ResponseWriter, r *http.Request) { // Check ACL pubkey, _ := GetPubkeyFromRequest(r) remoteAddr := s.getRemoteAddr(r) - + if !s.checkACL(pubkey, remoteAddr, "write") { s.setErrorResponse(w, http.StatusForbidden, "insufficient permissions") return @@ -560,7 +546,7 @@ func (s *Server) handleMirror(w http.ResponseWriter, r *http.Request) { defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - s.setErrorResponse(w, http.StatusBadGateway, + s.setErrorResponse(w, http.StatusBadGateway, fmt.Sprintf("remote server returned status %d", resp.StatusCode)) return } @@ -605,15 +591,21 @@ func (s *Server) handleMirror(w http.ResponseWriter, r *http.Request) { GetFileExtensionFromPath(mirrorURL.Path), ) + // Extract extension from path or infer from MIME type + ext := GetFileExtensionFromPath(mirrorURL.Path) + if ext == "" { + ext = GetExtensionFromMimeType(mimeType) + } + // Save blob - if err = s.storage.SaveBlob(sha256Hash, body, pubkey, mimeType); err != nil { + if err = s.storage.SaveBlob(sha256Hash, body, pubkey, mimeType, ext); err != nil { log.E.F("error saving mirrored blob: %v", err) s.setErrorResponse(w, http.StatusInternalServerError, "error saving blob") return } // Build URL - blobURL := BuildBlobURL(s.baseURL, sha256Hex, "") + blobURL := BuildBlobURL(s.baseURL, sha256Hex, ext) // Create descriptor descriptor := NewBlobDescriptor( @@ -637,7 +629,7 @@ func (s *Server) handleMediaUpload(w http.ResponseWriter, r *http.Request) { // Check ACL pubkey, _ := GetPubkeyFromRequest(r) remoteAddr := s.getRemoteAddr(r) - + if !s.checkACL(pubkey, remoteAddr, "write") { s.setErrorResponse(w, http.StatusForbidden, "insufficient permissions") return @@ -677,24 +669,31 @@ func (s *Server) handleMediaUpload(w http.ResponseWriter, r *http.Request) { } // Optimize media (placeholder - actual optimization would be implemented here) - optimizedData, mimeType := OptimizeMedia(body, DetectMimeType( + originalMimeType := DetectMimeType( r.Header.Get("Content-Type"), GetFileExtensionFromPath(r.URL.Path), - )) + ) + optimizedData, mimeType := OptimizeMedia(body, originalMimeType) + + // Extract extension from path or infer from MIME type + ext := GetFileExtensionFromPath(r.URL.Path) + if ext == "" { + ext = GetExtensionFromMimeType(mimeType) + } // Calculate optimized blob SHA256 optimizedHash := CalculateSHA256(optimizedData) optimizedHex := hex.Enc(optimizedHash) // Save optimized blob - if err = s.storage.SaveBlob(optimizedHash, optimizedData, pubkey, mimeType); err != nil { + if err = s.storage.SaveBlob(optimizedHash, optimizedData, pubkey, mimeType, ext); err != nil { log.E.F("error saving optimized blob: %v", err) s.setErrorResponse(w, http.StatusInternalServerError, "error saving blob") return } // Build URL - blobURL := BuildBlobURL(s.baseURL, optimizedHex, "") + blobURL := BuildBlobURL(s.baseURL, optimizedHex, ext) // Create descriptor descriptor := NewBlobDescriptor( @@ -725,7 +724,7 @@ func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) { // Check ACL pubkey, _ := GetPubkeyFromRequest(r) remoteAddr := s.getRemoteAddr(r) - + if !s.checkACL(pubkey, remoteAddr, "read") { s.setErrorResponse(w, http.StatusForbidden, "insufficient permissions") return @@ -780,4 +779,3 @@ func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } - diff --git a/pkg/blossom/http_test.go b/pkg/blossom/http_test.go new file mode 100644 index 0000000..b3a9e83 --- /dev/null +++ b/pkg/blossom/http_test.go @@ -0,0 +1,756 @@ +package blossom + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "next.orly.dev/pkg/encoders/event" + "next.orly.dev/pkg/encoders/hex" + "next.orly.dev/pkg/encoders/tag" + "next.orly.dev/pkg/encoders/timestamp" +) + +// TestHTTPGetBlob tests GET / endpoint +func TestHTTPGetBlob(t *testing.T) { + server, cleanup := testSetup(t) + defer cleanup() + + // Upload a blob first + testData := []byte("test blob content") + sha256Hash := CalculateSHA256(testData) + pubkey := []byte("testpubkey123456789012345678901234") + + err := server.storage.SaveBlob(sha256Hash, testData, pubkey, "text/plain", "") + if err != nil { + t.Fatalf("Failed to save blob: %v", err) + } + + sha256Hex := hex.Enc(sha256Hash) + + // Test GET request + req := httptest.NewRequest("GET", "/"+sha256Hex, nil) + w := httptest.NewRecorder() + server.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + body := w.Body.Bytes() + if !bytes.Equal(body, testData) { + t.Error("Response body mismatch") + } + + if w.Header().Get("Content-Type") != "text/plain" { + t.Errorf("Expected Content-Type text/plain, got %s", w.Header().Get("Content-Type")) + } +} + +// TestHTTPHeadBlob tests HEAD / endpoint +func TestHTTPHeadBlob(t *testing.T) { + server, cleanup := testSetup(t) + defer cleanup() + + testData := []byte("test blob content") + sha256Hash := CalculateSHA256(testData) + pubkey := []byte("testpubkey123456789012345678901234") + + err := server.storage.SaveBlob(sha256Hash, testData, pubkey, "text/plain", "") + if err != nil { + t.Fatalf("Failed to save blob: %v", err) + } + + sha256Hex := hex.Enc(sha256Hash) + + req := httptest.NewRequest("HEAD", "/"+sha256Hex, nil) + w := httptest.NewRecorder() + server.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + if w.Body.Len() != 0 { + t.Error("HEAD request should not return body") + } + + if w.Header().Get("Content-Length") != "18" { + t.Errorf("Expected Content-Length 18, got %s", w.Header().Get("Content-Length")) + } +} + +// TestHTTPUpload tests PUT /upload endpoint +func TestHTTPUpload(t *testing.T) { + server, cleanup := testSetup(t) + defer cleanup() + + _, signer := createTestKeypair(t) + + testData := []byte("test upload data") + sha256Hash := CalculateSHA256(testData) + + // Create auth event + authEv := createAuthEvent(t, signer, "upload", sha256Hash, 3600) + + // Create request + req := httptest.NewRequest("PUT", "/upload", bytes.NewReader(testData)) + req.Header.Set("Authorization", createAuthHeader(authEv)) + req.Header.Set("Content-Type", "text/plain") + + w := httptest.NewRecorder() + server.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + // Parse response + var desc BlobDescriptor + if err := json.Unmarshal(w.Body.Bytes(), &desc); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if desc.SHA256 != hex.Enc(sha256Hash) { + t.Errorf("SHA256 mismatch: expected %s, got %s", hex.Enc(sha256Hash), desc.SHA256) + } + + if desc.Size != int64(len(testData)) { + t.Errorf("Size mismatch: expected %d, got %d", len(testData), desc.Size) + } + + // Verify blob was saved + exists, err := server.storage.HasBlob(sha256Hash) + if err != nil { + t.Fatalf("Failed to check blob: %v", err) + } + if !exists { + t.Error("Blob should exist after upload") + } +} + +// TestHTTPUploadRequirements tests HEAD /upload endpoint +func TestHTTPUploadRequirements(t *testing.T) { + server, cleanup := testSetup(t) + defer cleanup() + + testData := []byte("test data") + sha256Hash := CalculateSHA256(testData) + + req := httptest.NewRequest("HEAD", "/upload", nil) + req.Header.Set("X-SHA-256", hex.Enc(sha256Hash)) + req.Header.Set("X-Content-Length", "9") + req.Header.Set("X-Content-Type", "text/plain") + + w := httptest.NewRecorder() + server.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Header().Get("X-Reason")) + } +} + +// TestHTTPUploadTooLarge tests upload size limit +func TestHTTPUploadTooLarge(t *testing.T) { + server, cleanup := testSetup(t) + defer cleanup() + + // Create request with size exceeding limit + req := httptest.NewRequest("HEAD", "/upload", nil) + req.Header.Set("X-SHA-256", hex.Enc(CalculateSHA256([]byte("test")))) + req.Header.Set("X-Content-Length", "200000000") // 200MB + req.Header.Set("X-Content-Type", "application/octet-stream") + + w := httptest.NewRecorder() + server.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusRequestEntityTooLarge { + t.Errorf("Expected status 413, got %d", w.Code) + } +} + +// TestHTTPListBlobs tests GET /list/ endpoint +func TestHTTPListBlobs(t *testing.T) { + server, cleanup := testSetup(t) + defer cleanup() + + _, signer := createTestKeypair(t) + pubkey := signer.Pub() + pubkeyHex := hex.Enc(pubkey) + + // Upload multiple blobs + for i := 0; i < 3; i++ { + testData := []byte("test data " + string(rune('A'+i))) + sha256Hash := CalculateSHA256(testData) + err := server.storage.SaveBlob(sha256Hash, testData, pubkey, "text/plain", "") + if err != nil { + t.Fatalf("Failed to save blob: %v", err) + } + } + + // Create auth event + authEv := createAuthEvent(t, signer, "list", nil, 3600) + + req := httptest.NewRequest("GET", "/list/"+pubkeyHex, nil) + req.Header.Set("Authorization", createAuthHeader(authEv)) + + w := httptest.NewRecorder() + server.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + var descriptors []BlobDescriptor + if err := json.Unmarshal(w.Body.Bytes(), &descriptors); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if len(descriptors) != 3 { + t.Errorf("Expected 3 blobs, got %d", len(descriptors)) + } +} + +// TestHTTPDeleteBlob tests DELETE / endpoint +func TestHTTPDeleteBlob(t *testing.T) { + server, cleanup := testSetup(t) + defer cleanup() + + _, signer := createTestKeypair(t) + pubkey := signer.Pub() + + testData := []byte("test delete data") + sha256Hash := CalculateSHA256(testData) + + // Upload blob first + err := server.storage.SaveBlob(sha256Hash, testData, pubkey, "text/plain", "") + if err != nil { + t.Fatalf("Failed to save blob: %v", err) + } + + // Create auth event + authEv := createAuthEvent(t, signer, "delete", sha256Hash, 3600) + + sha256Hex := hex.Enc(sha256Hash) + req := httptest.NewRequest("DELETE", "/"+sha256Hex, nil) + req.Header.Set("Authorization", createAuthHeader(authEv)) + + w := httptest.NewRecorder() + server.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify blob was deleted + exists, err := server.storage.HasBlob(sha256Hash) + if err != nil { + t.Fatalf("Failed to check blob: %v", err) + } + if exists { + t.Error("Blob should not exist after delete") + } +} + +// TestHTTPMirror tests PUT /mirror endpoint +func TestHTTPMirror(t *testing.T) { + server, cleanup := testSetup(t) + defer cleanup() + + _, signer := createTestKeypair(t) + + // Create a mock remote server + testData := []byte("mirrored blob data") + sha256Hash := CalculateSHA256(testData) + sha256Hex := hex.Enc(sha256Hash) + + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.Write(testData) + })) + defer mockServer.Close() + + // Create mirror request + mirrorReq := map[string]string{ + "url": mockServer.URL + "/" + sha256Hex, + } + reqBody, _ := json.Marshal(mirrorReq) + + authEv := createAuthEvent(t, signer, "upload", sha256Hash, 3600) + + req := httptest.NewRequest("PUT", "/mirror", bytes.NewReader(reqBody)) + req.Header.Set("Authorization", createAuthHeader(authEv)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + server.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify blob was saved + exists, err := server.storage.HasBlob(sha256Hash) + if err != nil { + t.Fatalf("Failed to check blob: %v", err) + } + if !exists { + t.Error("Blob should exist after mirror") + } +} + +// TestHTTPMediaUpload tests PUT /media endpoint +func TestHTTPMediaUpload(t *testing.T) { + server, cleanup := testSetup(t) + defer cleanup() + + _, signer := createTestKeypair(t) + + testData := []byte("test media data") + sha256Hash := CalculateSHA256(testData) + + authEv := createAuthEvent(t, signer, "media", sha256Hash, 3600) + + req := httptest.NewRequest("PUT", "/media", bytes.NewReader(testData)) + req.Header.Set("Authorization", createAuthHeader(authEv)) + req.Header.Set("Content-Type", "image/png") + + w := httptest.NewRecorder() + server.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + var desc BlobDescriptor + if err := json.Unmarshal(w.Body.Bytes(), &desc); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if desc.SHA256 == "" { + t.Error("Expected SHA256 in response") + } +} + +// TestHTTPReport tests PUT /report endpoint +func TestHTTPReport(t *testing.T) { + server, cleanup := testSetup(t) + defer cleanup() + + _, signer := createTestKeypair(t) + pubkey := signer.Pub() + + // Upload a blob first + testData := []byte("test blob") + sha256Hash := CalculateSHA256(testData) + + err := server.storage.SaveBlob(sha256Hash, testData, pubkey, "text/plain", "") + if err != nil { + t.Fatalf("Failed to save blob: %v", err) + } + + // Create report event (kind 1984) + reportEv := &event.E{ + CreatedAt: timestamp.Now().V, + Kind: 1984, + Tags: tag.NewS(tag.NewFromAny("x", hex.Enc(sha256Hash))), + Content: []byte("This blob violates policy"), + Pubkey: pubkey, + } + + if err := reportEv.Sign(signer); err != nil { + t.Fatalf("Failed to sign report: %v", err) + } + + reqBody := reportEv.Serialize() + req := httptest.NewRequest("PUT", "/report", bytes.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + server.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } +} + +// TestHTTPRangeRequest tests range request support +func TestHTTPRangeRequest(t *testing.T) { + server, cleanup := testSetup(t) + defer cleanup() + + testData := []byte("0123456789abcdef") + sha256Hash := CalculateSHA256(testData) + pubkey := []byte("testpubkey123456789012345678901234") + + err := server.storage.SaveBlob(sha256Hash, testData, pubkey, "text/plain", "") + if err != nil { + t.Fatalf("Failed to save blob: %v", err) + } + + sha256Hex := hex.Enc(sha256Hash) + + // Test range request + req := httptest.NewRequest("GET", "/"+sha256Hex, nil) + req.Header.Set("Range", "bytes=4-9") + + w := httptest.NewRecorder() + server.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusPartialContent { + t.Errorf("Expected status 206, got %d", w.Code) + } + + body := w.Body.Bytes() + expected := testData[4:10] + if !bytes.Equal(body, expected) { + t.Errorf("Range response mismatch: expected %s, got %s", string(expected), string(body)) + } + + if w.Header().Get("Content-Range") == "" { + t.Error("Missing Content-Range header") + } +} + +// TestHTTPNotFound tests 404 handling +func TestHTTPNotFound(t *testing.T) { + server, cleanup := testSetup(t) + defer cleanup() + + req := httptest.NewRequest("GET", "/nonexistent123456789012345678901234567890123456789012345678901234567890", nil) + w := httptest.NewRecorder() + server.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("Expected status 404, got %d", w.Code) + } +} + +// TestHTTPServerIntegration tests full server integration +func TestHTTPServerIntegration(t *testing.T) { + server, cleanup := testSetup(t) + defer cleanup() + + // Start HTTP server + httpServer := httptest.NewServer(server.Handler()) + defer httpServer.Close() + + _, signer := createTestKeypair(t) + + // Upload blob via HTTP + testData := []byte("integration test data") + sha256Hash := CalculateSHA256(testData) + sha256Hex := hex.Enc(sha256Hash) + + authEv := createAuthEvent(t, signer, "upload", sha256Hash, 3600) + + uploadReq, _ := http.NewRequest("PUT", httpServer.URL+"/upload", bytes.NewReader(testData)) + uploadReq.Header.Set("Authorization", createAuthHeader(authEv)) + uploadReq.Header.Set("Content-Type", "text/plain") + + client := &http.Client{} + resp, err := client.Do(uploadReq) + if err != nil { + t.Fatalf("Failed to upload: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("Upload failed: status %d, body: %s", resp.StatusCode, string(body)) + } + + // Retrieve blob via HTTP + getReq, _ := http.NewRequest("GET", httpServer.URL+"/"+sha256Hex, nil) + getResp, err := client.Do(getReq) + if err != nil { + t.Fatalf("Failed to get blob: %v", err) + } + defer getResp.Body.Close() + + if getResp.StatusCode != http.StatusOK { + t.Fatalf("Get failed: status %d", getResp.StatusCode) + } + + body, _ := io.ReadAll(getResp.Body) + if !bytes.Equal(body, testData) { + t.Error("Retrieved blob data mismatch") + } +} + +// TestCORSHeaders tests CORS header handling +func TestCORSHeaders(t *testing.T) { + server, cleanup := testSetup(t) + defer cleanup() + + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + + server.Handler().ServeHTTP(w, req) + + if w.Header().Get("Access-Control-Allow-Origin") != "*" { + t.Error("Missing CORS header") + } +} + +// TestAuthorizationRequired tests authorization requirement +func TestAuthorizationRequired(t *testing.T) { + server, cleanup := testSetup(t) + defer cleanup() + + // Configure server to require auth + server.requireAuth = true + + testData := []byte("test") + sha256Hash := CalculateSHA256(testData) + pubkey := []byte("testpubkey123456789012345678901234") + + err := server.storage.SaveBlob(sha256Hash, testData, pubkey, "text/plain", "") + if err != nil { + t.Fatalf("Failed to save blob: %v", err) + } + + sha256Hex := hex.Enc(sha256Hash) + + // Request without auth should fail + req := httptest.NewRequest("GET", "/"+sha256Hex, nil) + w := httptest.NewRecorder() + server.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status 401, got %d", w.Code) + } +} + +// TestACLIntegration tests ACL integration +func TestACLIntegration(t *testing.T) { + server, cleanup := testSetup(t) + defer cleanup() + + // Note: This test assumes ACL is configured + // In a real scenario, you'd set up a proper ACL instance + + _, signer := createTestKeypair(t) + testData := []byte("test") + sha256Hash := CalculateSHA256(testData) + + authEv := createAuthEvent(t, signer, "upload", sha256Hash, 3600) + + req := httptest.NewRequest("PUT", "/upload", bytes.NewReader(testData)) + req.Header.Set("Authorization", createAuthHeader(authEv)) + + w := httptest.NewRecorder() + server.Handler().ServeHTTP(w, req) + + // Should succeed if ACL allows, or fail if not + // The exact behavior depends on ACL configuration + if w.Code != http.StatusOK && w.Code != http.StatusForbidden { + t.Errorf("Unexpected status: %d", w.Code) + } +} + +// TestMimeTypeDetection tests MIME type detection from various sources +func TestMimeTypeDetection(t *testing.T) { + tests := []struct { + contentType string + ext string + expected string + }{ + {"image/png", "", "image/png"}, + {"", ".png", "image/png"}, + {"", ".pdf", "application/pdf"}, + {"application/pdf", ".txt", "application/pdf"}, + {"", ".unknown", "application/octet-stream"}, + {"", "", "application/octet-stream"}, + } + + for _, tt := range tests { + result := DetectMimeType(tt.contentType, tt.ext) + if result != tt.expected { + t.Errorf("DetectMimeType(%q, %q) = %q, want %q", + tt.contentType, tt.ext, result, tt.expected) + } + } +} + +// TestSHA256Validation tests SHA256 validation +func TestSHA256Validation(t *testing.T) { + validHashes := []string{ + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "abc123def456789012345678901234567890123456789012345678901234567890", + } + + invalidHashes := []string{ + "", + "abc", + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855x", + "12345", + } + + for _, hash := range validHashes { + if !ValidateSHA256Hex(hash) { + t.Errorf("Hash %s should be valid", hash) + } + } + + for _, hash := range invalidHashes { + if ValidateSHA256Hex(hash) { + t.Errorf("Hash %s should be invalid", hash) + } + } +} + +// TestBlobURLBuilding tests URL building +func TestBlobURLBuilding(t *testing.T) { + baseURL := "https://example.com" + sha256Hex := "abc123def456" + ext := ".pdf" + + url := BuildBlobURL(baseURL, sha256Hex, ext) + expected := baseURL + sha256Hex + ext + + if url != expected { + t.Errorf("Expected %s, got %s", expected, url) + } + + // Test without extension + url2 := BuildBlobURL(baseURL, sha256Hex, "") + expected2 := baseURL + sha256Hex + + if url2 != expected2 { + t.Errorf("Expected %s, got %s", expected2, url2) + } +} + +// TestErrorResponses tests error response formatting +func TestErrorResponses(t *testing.T) { + server, cleanup := testSetup(t) + defer cleanup() + + w := httptest.NewRecorder() + + server.setErrorResponse(w, http.StatusBadRequest, "Invalid request") + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code) + } + + if w.Header().Get("X-Reason") == "" { + t.Error("Missing X-Reason header") + } +} + +// TestExtractSHA256FromURL tests URL hash extraction +func TestExtractSHA256FromURL(t *testing.T) { + tests := []struct { + url string + expected string + hasError bool + }{ + {"https://example.com/abc123def456", "abc123def456", false}, + {"https://example.com/user/path/abc123def456.pdf", "abc123def456", false}, + {"https://example.com/", "", true}, + {"no hash here", "", true}, + } + + for _, tt := range tests { + hash, err := ExtractSHA256FromURL(tt.url) + if tt.hasError { + if err == nil { + t.Errorf("Expected error for URL %s", tt.url) + } + } else { + if err != nil { + t.Errorf("Unexpected error for URL %s: %v", tt.url, err) + } + if hash != tt.expected { + t.Errorf("Expected %s, got %s for URL %s", tt.expected, hash, tt.url) + } + } + } +} + +// TestStorageReport tests report storage +func TestStorageReport(t *testing.T) { + server, cleanup := testSetup(t) + defer cleanup() + + sha256Hash := CalculateSHA256([]byte("test")) + reportData := []byte("report data") + + err := server.storage.SaveReport(sha256Hash, reportData) + if err != nil { + t.Fatalf("Failed to save report: %v", err) + } + + // Reports are stored but not retrieved in current implementation + // This test verifies the operation doesn't fail +} + +// BenchmarkStorageOperations benchmarks storage operations +func BenchmarkStorageOperations(b *testing.B) { + server, cleanup := testSetup(&testing.T{}) + defer cleanup() + + testData := []byte("benchmark test data") + sha256Hash := CalculateSHA256(testData) + pubkey := []byte("testpubkey123456789012345678901234") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = server.storage.SaveBlob(sha256Hash, testData, pubkey, "text/plain", "") + _, _, _ = server.storage.GetBlob(sha256Hash) + _ = server.storage.DeleteBlob(sha256Hash, pubkey) + } +} + +// TestConcurrentUploads tests concurrent uploads +func TestConcurrentUploads(t *testing.T) { + server, cleanup := testSetup(t) + defer cleanup() + + _, signer := createTestKeypair(t) + + const numUploads = 10 + done := make(chan error, numUploads) + + for i := 0; i < numUploads; i++ { + go func(id int) { + testData := []byte("concurrent test " + string(rune('A'+id))) + sha256Hash := CalculateSHA256(testData) + authEv := createAuthEvent(t, signer, "upload", sha256Hash, 3600) + + req := httptest.NewRequest("PUT", "/upload", bytes.NewReader(testData)) + req.Header.Set("Authorization", createAuthHeader(authEv)) + + w := httptest.NewRecorder() + server.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + done <- &testError{code: w.Code, body: w.Body.String()} + return + } + done <- nil + }(i) + } + + for i := 0; i < numUploads; i++ { + if err := <-done; err != nil { + t.Errorf("Concurrent upload failed: %v", err) + } + } +} + +type testError struct { + code int + body string +} + +func (e *testError) Error() string { + return strings.Join([]string{"HTTP", string(rune(e.code)), e.body}, " ") +} + diff --git a/pkg/blossom/integration_test.go b/pkg/blossom/integration_test.go new file mode 100644 index 0000000..fb5be51 --- /dev/null +++ b/pkg/blossom/integration_test.go @@ -0,0 +1,852 @@ +package blossom + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "next.orly.dev/pkg/encoders/event" + "next.orly.dev/pkg/encoders/hex" + "next.orly.dev/pkg/encoders/tag" + "next.orly.dev/pkg/encoders/timestamp" +) + +// TestFullServerIntegration tests a complete workflow with a real HTTP server +func TestFullServerIntegration(t *testing.T) { + server, cleanup := testSetup(t) + defer cleanup() + + // Start real HTTP server + httpServer := httptest.NewServer(server.Handler()) + defer httpServer.Close() + + baseURL := httpServer.URL + client := &http.Client{Timeout: 10 * time.Second} + + // Create test keypair + _, signer := createTestKeypair(t) + pubkey := signer.Pub() + pubkeyHex := hex.Enc(pubkey) + + // Step 1: Upload a blob + testData := []byte("integration test blob content") + sha256Hash := CalculateSHA256(testData) + sha256Hex := hex.Enc(sha256Hash) + + authEv := createAuthEvent(t, signer, "upload", sha256Hash, 3600) + + uploadReq, err := http.NewRequest("PUT", baseURL+"/upload", bytes.NewReader(testData)) + if err != nil { + t.Fatalf("Failed to create upload request: %v", err) + } + uploadReq.Header.Set("Authorization", createAuthHeader(authEv)) + uploadReq.Header.Set("Content-Type", "text/plain") + + uploadResp, err := client.Do(uploadReq) + if err != nil { + t.Fatalf("Failed to upload: %v", err) + } + defer uploadResp.Body.Close() + + if uploadResp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(uploadResp.Body) + t.Fatalf("Upload failed: status %d, body: %s", uploadResp.StatusCode, string(body)) + } + + var uploadDesc BlobDescriptor + if err := json.NewDecoder(uploadResp.Body).Decode(&uploadDesc); err != nil { + t.Fatalf("Failed to parse upload response: %v", err) + } + + if uploadDesc.SHA256 != sha256Hex { + t.Errorf("SHA256 mismatch: expected %s, got %s", sha256Hex, uploadDesc.SHA256) + } + + // Step 2: Retrieve the blob + getReq, err := http.NewRequest("GET", baseURL+"/"+sha256Hex, nil) + if err != nil { + t.Fatalf("Failed to create GET request: %v", err) + } + + getResp, err := client.Do(getReq) + if err != nil { + t.Fatalf("Failed to get blob: %v", err) + } + defer getResp.Body.Close() + + if getResp.StatusCode != http.StatusOK { + t.Fatalf("Get failed: status %d", getResp.StatusCode) + } + + retrievedData, err := io.ReadAll(getResp.Body) + if err != nil { + t.Fatalf("Failed to read response: %v", err) + } + + if !bytes.Equal(retrievedData, testData) { + t.Error("Retrieved blob data mismatch") + } + + // Step 3: List blobs + listAuthEv := createAuthEvent(t, signer, "list", nil, 3600) + listReq, err := http.NewRequest("GET", baseURL+"/list/"+pubkeyHex, nil) + if err != nil { + t.Fatalf("Failed to create list request: %v", err) + } + listReq.Header.Set("Authorization", createAuthHeader(listAuthEv)) + + listResp, err := client.Do(listReq) + if err != nil { + t.Fatalf("Failed to list blobs: %v", err) + } + defer listResp.Body.Close() + + if listResp.StatusCode != http.StatusOK { + t.Fatalf("List failed: status %d", listResp.StatusCode) + } + + var descriptors []BlobDescriptor + if err := json.NewDecoder(listResp.Body).Decode(&descriptors); err != nil { + t.Fatalf("Failed to parse list response: %v", err) + } + + if len(descriptors) == 0 { + t.Error("Expected at least one blob in list") + } + + // Step 4: Delete the blob + deleteAuthEv := createAuthEvent(t, signer, "delete", sha256Hash, 3600) + deleteReq, err := http.NewRequest("DELETE", baseURL+"/"+sha256Hex, nil) + if err != nil { + t.Fatalf("Failed to create delete request: %v", err) + } + deleteReq.Header.Set("Authorization", createAuthHeader(deleteAuthEv)) + + deleteResp, err := client.Do(deleteReq) + if err != nil { + t.Fatalf("Failed to delete blob: %v", err) + } + defer deleteResp.Body.Close() + + if deleteResp.StatusCode != http.StatusOK { + t.Fatalf("Delete failed: status %d", deleteResp.StatusCode) + } + + // Step 5: Verify blob is gone + getResp2, err := client.Do(getReq) + if err != nil { + t.Fatalf("Failed to get blob: %v", err) + } + defer getResp2.Body.Close() + + if getResp2.StatusCode != http.StatusNotFound { + t.Errorf("Expected 404 after delete, got %d", getResp2.StatusCode) + } +} + +// TestServerWithMultipleBlobs tests multiple blob operations +func TestServerWithMultipleBlobs(t *testing.T) { + server, cleanup := testSetup(t) + defer cleanup() + + httpServer := httptest.NewServer(server.Handler()) + defer httpServer.Close() + + _, signer := createTestKeypair(t) + pubkey := signer.Pub() + pubkeyHex := hex.Enc(pubkey) + + // Upload multiple blobs + const numBlobs = 5 + var hashes []string + var data []byte + + for i := 0; i < numBlobs; i++ { + testData := []byte(fmt.Sprintf("blob %d content", i)) + sha256Hash := CalculateSHA256(testData) + sha256Hex := hex.Enc(sha256Hash) + hashes = append(hashes, sha256Hex) + data = append(data, testData...) + + authEv := createAuthEvent(t, signer, "upload", sha256Hash, 3600) + + req, _ := http.NewRequest("PUT", httpServer.URL+"/upload", bytes.NewReader(testData)) + req.Header.Set("Authorization", createAuthHeader(authEv)) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Failed to upload blob %d: %v", i, err) + } + resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("Upload %d failed: status %d", i, resp.StatusCode) + } + } + + // List all blobs + authEv := createAuthEvent(t, signer, "list", nil, 3600) + req, _ := http.NewRequest("GET", httpServer.URL+"/list/"+pubkeyHex, nil) + req.Header.Set("Authorization", createAuthHeader(authEv)) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Failed to list blobs: %v", err) + } + defer resp.Body.Close() + + var descriptors []BlobDescriptor + json.NewDecoder(resp.Body).Decode(&descriptors) + + if len(descriptors) != numBlobs { + t.Errorf("Expected %d blobs, got %d", numBlobs, len(descriptors)) + } +} + +// TestServerCORS tests CORS headers on all endpoints +func TestServerCORS(t *testing.T) { + server, cleanup := testSetup(t) + defer cleanup() + + httpServer := httptest.NewServer(server.Handler()) + defer httpServer.Close() + + endpoints := []struct { + method string + path string + }{ + {"GET", "/test123456789012345678901234567890123456789012345678901234567890"}, + {"HEAD", "/test123456789012345678901234567890123456789012345678901234567890"}, + {"PUT", "/upload"}, + {"HEAD", "/upload"}, + {"GET", "/list/test123456789012345678901234567890123456789012345678901234567890"}, + {"PUT", "/media"}, + {"HEAD", "/media"}, + {"PUT", "/mirror"}, + {"PUT", "/report"}, + {"DELETE", "/test123456789012345678901234567890123456789012345678901234567890"}, + {"OPTIONS", "/"}, + } + + for _, ep := range endpoints { + req, _ := http.NewRequest(ep.method, httpServer.URL+ep.path, nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Errorf("Failed to test %s %s: %v", ep.method, ep.path, err) + continue + } + resp.Body.Close() + + corsHeader := resp.Header.Get("Access-Control-Allow-Origin") + if corsHeader != "*" { + t.Errorf("Missing CORS header on %s %s", ep.method, ep.path) + } + } +} + +// TestServerRangeRequests tests range request handling +func TestServerRangeRequests(t *testing.T) { + server, cleanup := testSetup(t) + defer cleanup() + + httpServer := httptest.NewServer(server.Handler()) + defer httpServer.Close() + + // Upload a blob + testData := []byte("0123456789abcdefghij") + sha256Hash := CalculateSHA256(testData) + pubkey := []byte("testpubkey123456789012345678901234") + + err := server.storage.SaveBlob(sha256Hash, testData, pubkey, "text/plain", "") + if err != nil { + t.Fatalf("Failed to save blob: %v", err) + } + + sha256Hex := hex.Enc(sha256Hash) + + // Test various range requests + tests := []struct { + rangeHeader string + expected string + status int + }{ + {"bytes=0-4", "01234", http.StatusPartialContent}, + {"bytes=5-9", "56789", http.StatusPartialContent}, + {"bytes=10-", "abcdefghij", http.StatusPartialContent}, + {"bytes=-5", "hij", http.StatusPartialContent}, + {"bytes=0-0", "0", http.StatusPartialContent}, + {"bytes=100-200", "", http.StatusRequestedRangeNotSatisfiable}, + } + + for _, tt := range tests { + req, _ := http.NewRequest("GET", httpServer.URL+"/"+sha256Hex, nil) + req.Header.Set("Range", tt.rangeHeader) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Errorf("Failed to request range %s: %v", tt.rangeHeader, err) + continue + } + + if resp.StatusCode != tt.status { + t.Errorf("Range %s: expected status %d, got %d", tt.rangeHeader, tt.status, resp.StatusCode) + resp.Body.Close() + continue + } + + if tt.status == http.StatusPartialContent { + body, _ := io.ReadAll(resp.Body) + if string(body) != tt.expected { + t.Errorf("Range %s: expected %q, got %q", tt.rangeHeader, tt.expected, string(body)) + } + + if resp.Header.Get("Content-Range") == "" { + t.Errorf("Range %s: missing Content-Range header", tt.rangeHeader) + } + } + + resp.Body.Close() + } +} + +// TestServerAuthorizationFlow tests complete authorization flow +func TestServerAuthorizationFlow(t *testing.T) { + server, cleanup := testSetup(t) + defer cleanup() + + _, signer := createTestKeypair(t) + + testData := []byte("authorized blob") + sha256Hash := CalculateSHA256(testData) + + // Test with valid authorization + authEv := createAuthEvent(t, signer, "upload", sha256Hash, 3600) + + req := httptest.NewRequest("PUT", "/upload", bytes.NewReader(testData)) + req.Header.Set("Authorization", createAuthHeader(authEv)) + + w := httptest.NewRecorder() + server.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Valid auth failed: status %d, body: %s", w.Code, w.Body.String()) + } + + // Test with expired authorization + expiredAuthEv := createAuthEvent(t, signer, "upload", sha256Hash, -3600) + + req2 := httptest.NewRequest("PUT", "/upload", bytes.NewReader(testData)) + req2.Header.Set("Authorization", createAuthHeader(expiredAuthEv)) + + w2 := httptest.NewRecorder() + server.Handler().ServeHTTP(w2, req2) + + if w2.Code != http.StatusUnauthorized { + t.Errorf("Expired auth should fail: status %d", w2.Code) + } + + // Test with wrong verb + wrongVerbAuthEv := createAuthEvent(t, signer, "delete", sha256Hash, 3600) + + req3 := httptest.NewRequest("PUT", "/upload", bytes.NewReader(testData)) + req3.Header.Set("Authorization", createAuthHeader(wrongVerbAuthEv)) + + w3 := httptest.NewRecorder() + server.Handler().ServeHTTP(w3, req3) + + if w3.Code != http.StatusUnauthorized { + t.Errorf("Wrong verb auth should fail: status %d", w3.Code) + } +} + +// TestServerUploadRequirementsFlow tests upload requirements check flow +func TestServerUploadRequirementsFlow(t *testing.T) { + server, cleanup := testSetup(t) + defer cleanup() + + testData := []byte("test") + sha256Hash := CalculateSHA256(testData) + + // Test HEAD /upload with valid requirements + req := httptest.NewRequest("HEAD", "/upload", nil) + req.Header.Set("X-SHA-256", hex.Enc(sha256Hash)) + req.Header.Set("X-Content-Length", "4") + req.Header.Set("X-Content-Type", "text/plain") + + w := httptest.NewRecorder() + server.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Upload requirements check failed: status %d", w.Code) + } + + // Test HEAD /upload with missing header + req2 := httptest.NewRequest("HEAD", "/upload", nil) + w2 := httptest.NewRecorder() + server.Handler().ServeHTTP(w2, req2) + + if w2.Code != http.StatusBadRequest { + t.Errorf("Expected BadRequest for missing header, got %d", w2.Code) + } + + // Test HEAD /upload with invalid hash + req3 := httptest.NewRequest("HEAD", "/upload", nil) + req3.Header.Set("X-SHA-256", "invalid") + req3.Header.Set("X-Content-Length", "4") + + w3 := httptest.NewRecorder() + server.Handler().ServeHTTP(w3, req3) + + if w3.Code != http.StatusBadRequest { + t.Errorf("Expected BadRequest for invalid hash, got %d", w3.Code) + } +} + +// TestServerMirrorFlow tests mirror endpoint flow +func TestServerMirrorFlow(t *testing.T) { + server, cleanup := testSetup(t) + defer cleanup() + + _, signer := createTestKeypair(t) + + // Create mock remote server + remoteData := []byte("remote blob data") + sha256Hash := CalculateSHA256(remoteData) + sha256Hex := hex.Enc(sha256Hash) + + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/pdf") + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(remoteData))) + w.Write(remoteData) + })) + defer mockServer.Close() + + // Mirror the blob + mirrorReq := map[string]string{ + "url": mockServer.URL + "/" + sha256Hex, + } + reqBody, _ := json.Marshal(mirrorReq) + + authEv := createAuthEvent(t, signer, "upload", sha256Hash, 3600) + + req := httptest.NewRequest("PUT", "/mirror", bytes.NewReader(reqBody)) + req.Header.Set("Authorization", createAuthHeader(authEv)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + server.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Mirror failed: status %d, body: %s", w.Code, w.Body.String()) + } + + // Verify blob was stored + exists, err := server.storage.HasBlob(sha256Hash) + if err != nil { + t.Fatalf("Failed to check blob: %v", err) + } + if !exists { + t.Error("Blob should exist after mirror") + } +} + +// TestServerReportFlow tests report endpoint flow +func TestServerReportFlow(t *testing.T) { + server, cleanup := testSetup(t) + defer cleanup() + + _, signer := createTestKeypair(t) + pubkey := signer.Pub() + + // Upload a blob first + testData := []byte("reportable blob") + sha256Hash := CalculateSHA256(testData) + + err := server.storage.SaveBlob(sha256Hash, testData, pubkey, "text/plain", "") + if err != nil { + t.Fatalf("Failed to save blob: %v", err) + } + + // Create report event + reportEv := &event.E{ + CreatedAt: timestamp.Now().V, + Kind: 1984, + Tags: tag.NewS(tag.NewFromAny("x", hex.Enc(sha256Hash))), + Content: []byte("This blob should be reported"), + Pubkey: pubkey, + } + + if err := reportEv.Sign(signer); err != nil { + t.Fatalf("Failed to sign report: %v", err) + } + + reqBody := reportEv.Serialize() + req := httptest.NewRequest("PUT", "/report", bytes.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + server.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Report failed: status %d, body: %s", w.Code, w.Body.String()) + } +} + +// TestServerErrorHandling tests various error scenarios +func TestServerErrorHandling(t *testing.T) { + server, cleanup := testSetup(t) + defer cleanup() + + tests := []struct { + name string + method string + path string + headers map[string]string + body []byte + statusCode int + }{ + { + name: "Invalid path", + method: "GET", + path: "/invalid", + statusCode: http.StatusBadRequest, + }, + { + name: "Non-existent blob", + method: "GET", + path: "/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + statusCode: http.StatusNotFound, + }, + { + name: "Missing auth header", + method: "PUT", + path: "/upload", + body: []byte("test"), + statusCode: http.StatusUnauthorized, + }, + { + name: "Invalid JSON in mirror", + method: "PUT", + path: "/mirror", + body: []byte("invalid json"), + statusCode: http.StatusBadRequest, + }, + { + name: "Invalid JSON in report", + method: "PUT", + path: "/report", + body: []byte("invalid json"), + statusCode: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var body io.Reader + if tt.body != nil { + body = bytes.NewReader(tt.body) + } + + req := httptest.NewRequest(tt.method, tt.path, body) + for k, v := range tt.headers { + req.Header.Set(k, v) + } + + w := httptest.NewRecorder() + server.Handler().ServeHTTP(w, req) + + if w.Code != tt.statusCode { + t.Errorf("Expected status %d, got %d: %s", tt.statusCode, w.Code, w.Body.String()) + } + }) + } +} + +// TestServerMediaOptimization tests media optimization endpoint +func TestServerMediaOptimization(t *testing.T) { + server, cleanup := testSetup(t) + defer cleanup() + + _, signer := createTestKeypair(t) + + testData := []byte("test media for optimization") + sha256Hash := CalculateSHA256(testData) + + authEv := createAuthEvent(t, signer, "media", sha256Hash, 3600) + + req := httptest.NewRequest("PUT", "/media", bytes.NewReader(testData)) + req.Header.Set("Authorization", createAuthHeader(authEv)) + req.Header.Set("Content-Type", "image/png") + + w := httptest.NewRecorder() + server.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Media upload failed: status %d, body: %s", w.Code, w.Body.String()) + } + + var desc BlobDescriptor + if err := json.Unmarshal(w.Body.Bytes(), &desc); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if desc.SHA256 == "" { + t.Error("Expected SHA256 in response") + } + + // Test HEAD /media + req2 := httptest.NewRequest("HEAD", "/media", nil) + w2 := httptest.NewRecorder() + server.Handler().ServeHTTP(w2, req2) + + if w2.Code != http.StatusOK { + t.Errorf("HEAD /media failed: status %d", w2.Code) + } +} + +// TestServerListWithQueryParams tests list endpoint with query parameters +func TestServerListWithQueryParams(t *testing.T) { + server, cleanup := testSetup(t) + defer cleanup() + + _, signer := createTestKeypair(t) + pubkey := signer.Pub() + pubkeyHex := hex.Enc(pubkey) + + // Upload blobs at different times + now := time.Now().Unix() + blobs := []struct { + data []byte + timestamp int64 + }{ + {[]byte("blob 1"), now - 1000}, + {[]byte("blob 2"), now - 500}, + {[]byte("blob 3"), now}, + } + + for _, b := range blobs { + sha256Hash := CalculateSHA256(b.data) + // Manually set uploaded timestamp + err := server.storage.SaveBlob(sha256Hash, b.data, pubkey, "text/plain", "") + if err != nil { + t.Fatalf("Failed to save blob: %v", err) + } + } + + // List with since parameter + authEv := createAuthEvent(t, signer, "list", nil, 3600) + req := httptest.NewRequest("GET", "/list/"+pubkeyHex+"?since="+fmt.Sprintf("%d", now-600), nil) + req.Header.Set("Authorization", createAuthHeader(authEv)) + + w := httptest.NewRecorder() + server.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("List failed: status %d", w.Code) + } + + var descriptors []BlobDescriptor + if err := json.NewDecoder(w.Body).Decode(&descriptors); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + // Should only get blobs uploaded after since timestamp + if len(descriptors) != 1 { + t.Errorf("Expected 1 blob, got %d", len(descriptors)) + } +} + +// TestServerConcurrentOperations tests concurrent operations on server +func TestServerConcurrentOperations(t *testing.T) { + server, cleanup := testSetup(t) + defer cleanup() + + httpServer := httptest.NewServer(server.Handler()) + defer httpServer.Close() + + _, signer := createTestKeypair(t) + + const numOps = 20 + done := make(chan error, numOps) + + for i := 0; i < numOps; i++ { + go func(id int) { + testData := []byte(fmt.Sprintf("concurrent op %d", id)) + sha256Hash := CalculateSHA256(testData) + sha256Hex := hex.Enc(sha256Hash) + + // Upload + authEv := createAuthEvent(t, signer, "upload", sha256Hash, 3600) + req, _ := http.NewRequest("PUT", httpServer.URL+"/upload", bytes.NewReader(testData)) + req.Header.Set("Authorization", createAuthHeader(authEv)) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + done <- err + return + } + resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + done <- fmt.Errorf("upload failed: %d", resp.StatusCode) + return + } + + // Get + req2, _ := http.NewRequest("GET", httpServer.URL+"/"+sha256Hex, nil) + resp2, err := http.DefaultClient.Do(req2) + if err != nil { + done <- err + return + } + resp2.Body.Close() + + if resp2.StatusCode != http.StatusOK { + done <- fmt.Errorf("get failed: %d", resp2.StatusCode) + return + } + + done <- nil + }(i) + } + + for i := 0; i < numOps; i++ { + if err := <-done; err != nil { + t.Errorf("Concurrent operation failed: %v", err) + } + } +} + +// TestServerBlobExtensionHandling tests blob retrieval with file extensions +func TestServerBlobExtensionHandling(t *testing.T) { + server, cleanup := testSetup(t) + defer cleanup() + + testData := []byte("test PDF content") + sha256Hash := CalculateSHA256(testData) + pubkey := []byte("testpubkey123456789012345678901234") + + err := server.storage.SaveBlob(sha256Hash, testData, pubkey, "application/pdf", "") + if err != nil { + t.Fatalf("Failed to save blob: %v", err) + } + + sha256Hex := hex.Enc(sha256Hash) + + // Test GET with extension + req := httptest.NewRequest("GET", "/"+sha256Hex+".pdf", nil) + w := httptest.NewRecorder() + server.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("GET with extension failed: status %d", w.Code) + } + + // Should still return correct MIME type + if w.Header().Get("Content-Type") != "application/pdf" { + t.Errorf("Expected application/pdf, got %s", w.Header().Get("Content-Type")) + } +} + +// TestServerBlobAlreadyExists tests uploading existing blob +func TestServerBlobAlreadyExists(t *testing.T) { + server, cleanup := testSetup(t) + defer cleanup() + + _, signer := createTestKeypair(t) + pubkey := signer.Pub() + + testData := []byte("existing blob") + sha256Hash := CalculateSHA256(testData) + + // Upload blob first time + err := server.storage.SaveBlob(sha256Hash, testData, pubkey, "text/plain", "") + if err != nil { + t.Fatalf("Failed to save blob: %v", err) + } + + // Try to upload same blob again + authEv := createAuthEvent(t, signer, "upload", sha256Hash, 3600) + + req := httptest.NewRequest("PUT", "/upload", bytes.NewReader(testData)) + req.Header.Set("Authorization", createAuthHeader(authEv)) + + w := httptest.NewRecorder() + server.Handler().ServeHTTP(w, req) + + // Should succeed and return existing blob descriptor + if w.Code != http.StatusOK { + t.Errorf("Re-upload should succeed: status %d", w.Code) + } +} + +// TestServerInvalidAuthorization tests various invalid authorization scenarios +func TestServerInvalidAuthorization(t *testing.T) { + server, cleanup := testSetup(t) + defer cleanup() + + _, signer := createTestKeypair(t) + + testData := []byte("test") + sha256Hash := CalculateSHA256(testData) + + tests := []struct { + name string + modifyEv func(*event.E) + expectErr bool + }{ + { + name: "Missing expiration", + modifyEv: func(ev *event.E) { + ev.Tags = tag.NewS(tag.NewFromAny("t", "upload")) + }, + expectErr: true, + }, + { + name: "Wrong kind", + modifyEv: func(ev *event.E) { + ev.Kind = 1 + }, + expectErr: true, + }, + { + name: "Wrong verb", + modifyEv: func(ev *event.E) { + ev.Tags = tag.NewS( + tag.NewFromAny("t", "delete"), + tag.NewFromAny("expiration", timestamp.FromUnix(time.Now().Unix()+3600).String()), + ) + }, + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ev := createAuthEvent(t, signer, "upload", sha256Hash, 3600) + tt.modifyEv(ev) + + req := httptest.NewRequest("PUT", "/upload", bytes.NewReader(testData)) + req.Header.Set("Authorization", createAuthHeader(ev)) + + w := httptest.NewRecorder() + server.Handler().ServeHTTP(w, req) + + if tt.expectErr { + if w.Code == http.StatusOK { + t.Error("Expected error but got success") + } + } else { + if w.Code != http.StatusOK { + t.Errorf("Expected success but got error: status %d", w.Code) + } + } + }) + } +} + diff --git a/pkg/blossom/server.go b/pkg/blossom/server.go index 058db28..d5a5a44 100644 --- a/pkg/blossom/server.go +++ b/pkg/blossom/server.go @@ -14,19 +14,20 @@ type Server struct { storage *Storage acl *acl.S baseURL string - + // Configuration - maxBlobSize int64 + maxBlobSize int64 allowedMimeTypes map[string]bool - requireAuth bool + requireAuth bool } // Config holds configuration for the Blossom server type Config struct { - BaseURL string - MaxBlobSize int64 + BaseURL string + MaxBlobSize int64 AllowedMimeTypes []string - RequireAuth bool + RequireAuth bool + BlobDir string // Directory for storing blob files } // NewServer creates a new Blossom server instance @@ -38,8 +39,8 @@ func NewServer(db *database.D, aclRegistry *acl.S, cfg *Config) *Server { } } - storage := NewStorage(db) - + storage := NewStorage(db, cfg.BlobDir) + // Build allowed MIME types map allowedMap := make(map[string]bool) if len(cfg.AllowedMimeTypes) > 0 { @@ -49,13 +50,13 @@ func NewServer(db *database.D, aclRegistry *acl.S, cfg *Config) *Server { } return &Server{ - db: db, - storage: storage, - acl: aclRegistry, - baseURL: cfg.BaseURL, - maxBlobSize: cfg.MaxBlobSize, + db: db, + storage: storage, + acl: aclRegistry, + baseURL: cfg.BaseURL, + maxBlobSize: cfg.MaxBlobSize, allowedMimeTypes: allowedMap, - requireAuth: cfg.RequireAuth, + requireAuth: cfg.RequireAuth, } } @@ -73,41 +74,41 @@ func (s *Server) Handler() http.Handler { // Route based on path and method path := r.URL.Path - + // Remove leading slash path = strings.TrimPrefix(path, "/") - + // Handle specific endpoints switch { case r.Method == http.MethodGet && path == "upload": // This shouldn't happen, but handle gracefully http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return - + case r.Method == http.MethodHead && path == "upload": s.handleUploadRequirements(w, r) return - + case r.Method == http.MethodPut && path == "upload": s.handleUpload(w, r) return - + case r.Method == http.MethodHead && path == "media": s.handleMediaHead(w, r) return - + case r.Method == http.MethodPut && path == "media": s.handleMediaUpload(w, r) return - + case r.Method == http.MethodPut && path == "mirror": s.handleMirror(w, r) return - + case r.Method == http.MethodPut && path == "report": s.handleReport(w, r) return - + case strings.HasPrefix(path, "list/"): if r.Method == http.MethodGet { s.handleListBlobs(w, r) @@ -115,22 +116,22 @@ func (s *Server) Handler() http.Handler { } http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return - + case r.Method == http.MethodGet: // Handle GET / s.handleGetBlob(w, r) return - + case r.Method == http.MethodHead: // Handle HEAD / s.handleHeadBlob(w, r) return - + case r.Method == http.MethodDelete: // Handle DELETE / s.handleDeleteBlob(w, r) return - + default: http.Error(w, "Not found", http.StatusNotFound) return @@ -163,12 +164,12 @@ func (s *Server) getRemoteAddr(r *http.Request) string { return strings.TrimSpace(parts[0]) } } - + // Check X-Real-IP header if realIP := r.Header.Get("X-Real-IP"); realIP != "" { return realIP } - + // Fall back to RemoteAddr return r.RemoteAddr } @@ -182,7 +183,7 @@ func (s *Server) checkACL( } level := s.acl.GetAccessLevel(pubkey, remoteAddr) - + // Map ACL levels to permissions levelMap := map[string]int{ "none": 0, @@ -191,10 +192,9 @@ func (s *Server) checkACL( "admin": 3, "owner": 4, } - + required := levelMap[requiredLevel] actual := levelMap[level] - + return actual >= required } - diff --git a/pkg/blossom/storage.go b/pkg/blossom/storage.go index af675c5..58644f9 100644 --- a/pkg/blossom/storage.go +++ b/pkg/blossom/storage.go @@ -2,6 +2,8 @@ package blossom import ( "encoding/json" + "os" + "path/filepath" "github.com/dgraph-io/badger/v4" "lol.mleku.dev/chk" @@ -14,8 +16,7 @@ import ( ) const ( - // Database key prefixes - prefixBlobData = "blob:data:" + // Database key prefixes (metadata and indexes only, blob data stored as files) prefixBlobMeta = "blob:meta:" prefixBlobIndex = "blob:index:" prefixBlobReport = "blob:report:" @@ -23,17 +24,32 @@ const ( // Storage provides blob storage operations type Storage struct { - db *database.D + db *database.D + blobDir string // Directory for storing blob files } // NewStorage creates a new storage instance -func NewStorage(db *database.D) *Storage { - return &Storage{db: db} +func NewStorage(db *database.D, blobDir string) *Storage { + // Ensure blob directory exists + if err := os.MkdirAll(blobDir, 0755); err != nil { + log.E.F("failed to create blob directory %s: %v", blobDir, err) + } + + return &Storage{ + db: db, + blobDir: blobDir, + } +} + +// getBlobPath returns the filesystem path for a blob given its hash and extension +func (s *Storage) getBlobPath(sha256Hex string, ext string) string { + filename := sha256Hex + ext + return filepath.Join(s.blobDir, filename) } // SaveBlob stores a blob with its metadata func (s *Storage) SaveBlob( - sha256Hash []byte, data []byte, pubkey []byte, mimeType string, + sha256Hash []byte, data []byte, pubkey []byte, mimeType string, extension string, ) (err error) { sha256Hex := hex.Enc(sha256Hash) @@ -47,20 +63,38 @@ func (s *Storage) SaveBlob( return } - // Create metadata + // If extension not provided, infer from MIME type + if extension == "" { + extension = GetExtensionFromMimeType(mimeType) + } + + // Create metadata with extension metadata := NewBlobMetadata(pubkey, mimeType, int64(len(data))) + metadata.Extension = extension var metaData []byte if metaData, err = metadata.Serialize(); chk.E(err) { return } - // Store blob data - dataKey := prefixBlobData + sha256Hex - if err = s.db.Update(func(txn *badger.Txn) error { - if err := txn.Set([]byte(dataKey), data); err != nil { - return err - } + // Get blob file path + blobPath := s.getBlobPath(sha256Hex, extension) + // Check if blob file already exists (deduplication) + if _, err = os.Stat(blobPath); err == nil { + // File exists, just update metadata and index + log.D.F("blob file already exists: %s", blobPath) + } else if !os.IsNotExist(err) { + return errorf.E("error checking blob file: %w", err) + } else { + // Write blob data to file + if err = os.WriteFile(blobPath, data, 0644); chk.E(err) { + return errorf.E("failed to write blob file: %w", err) + } + log.D.F("wrote blob file: %s (%d bytes)", blobPath, len(data)) + } + + // Store metadata and index in database + if err = s.db.Update(func(txn *badger.Txn) error { // Store metadata metaKey := prefixBlobMeta + sha256Hex if err := txn.Set([]byte(metaKey), metaData); err != nil { @@ -85,25 +119,8 @@ func (s *Storage) SaveBlob( // GetBlob retrieves blob data by SHA256 hash func (s *Storage) GetBlob(sha256Hash []byte) (data []byte, metadata *BlobMetadata, err error) { sha256Hex := hex.Enc(sha256Hash) - dataKey := prefixBlobData + sha256Hex - var blobData []byte - if err = s.db.View(func(txn *badger.Txn) error { - item, err := txn.Get([]byte(dataKey)) - if err != nil { - return err - } - - return item.Value(func(val []byte) error { - blobData = make([]byte, len(val)) - copy(blobData, val) - return nil - }) - }); chk.E(err) { - return - } - - // Get metadata + // Get metadata first to get extension metaKey := prefixBlobMeta + sha256Hex if err = s.db.View(func(txn *badger.Txn) error { item, err := txn.Get([]byte(metaKey)) @@ -121,55 +138,96 @@ func (s *Storage) GetBlob(sha256Hash []byte) (data []byte, metadata *BlobMetadat return } - data = blobData - return -} - -// HasBlob checks if a blob exists -func (s *Storage) HasBlob(sha256Hash []byte) (exists bool, err error) { - sha256Hex := hex.Enc(sha256Hash) - dataKey := prefixBlobData + sha256Hex - - if err = s.db.View(func(txn *badger.Txn) error { - _, err := txn.Get([]byte(dataKey)) - if err == badger.ErrKeyNotFound { - exists = false - return nil + // Read blob data from file + blobPath := s.getBlobPath(sha256Hex, metadata.Extension) + data, err = os.ReadFile(blobPath) + if err != nil { + if os.IsNotExist(err) { + err = badger.ErrKeyNotFound } - if err != nil { - return err - } - exists = true - return nil - }); chk.E(err) { return } return } -// DeleteBlob deletes a blob and its metadata -func (s *Storage) DeleteBlob(sha256Hash []byte, pubkey []byte) (err error) { +// HasBlob checks if a blob exists +func (s *Storage) HasBlob(sha256Hash []byte) (exists bool, err error) { sha256Hex := hex.Enc(sha256Hash) - dataKey := prefixBlobData + sha256Hex - metaKey := prefixBlobMeta + sha256Hex - indexKey := prefixBlobIndex + hex.Enc(pubkey) + ":" + sha256Hex - if err = s.db.Update(func(txn *badger.Txn) error { - // Verify blob exists - _, err := txn.Get([]byte(dataKey)) + // Get metadata to find extension + metaKey := prefixBlobMeta + sha256Hex + var metadata *BlobMetadata + if err = s.db.View(func(txn *badger.Txn) error { + item, err := txn.Get([]byte(metaKey)) if err == badger.ErrKeyNotFound { - return errorf.E("blob %s not found", sha256Hex) + return badger.ErrKeyNotFound } if err != nil { return err } - // Delete blob data - if err := txn.Delete([]byte(dataKey)); err != nil { + return item.Value(func(val []byte) error { + if metadata, err = DeserializeBlobMetadata(val); err != nil { + return err + } + return nil + }) + }); err == badger.ErrKeyNotFound { + exists = false + return false, nil + } + if err != nil { + return + } + + // Check if file exists + blobPath := s.getBlobPath(sha256Hex, metadata.Extension) + if _, err = os.Stat(blobPath); err == nil { + exists = true + return + } + if os.IsNotExist(err) { + exists = false + err = nil + return + } + return +} + +// DeleteBlob deletes a blob and its metadata +func (s *Storage) DeleteBlob(sha256Hash []byte, pubkey []byte) (err error) { + sha256Hex := hex.Enc(sha256Hash) + + // Get metadata to find extension + metaKey := prefixBlobMeta + sha256Hex + var metadata *BlobMetadata + if err = s.db.View(func(txn *badger.Txn) error { + item, err := txn.Get([]byte(metaKey)) + if err == badger.ErrKeyNotFound { + return badger.ErrKeyNotFound + } + if err != nil { return err } + return item.Value(func(val []byte) error { + if metadata, err = DeserializeBlobMetadata(val); err != nil { + return err + } + return nil + }) + }); err == badger.ErrKeyNotFound { + return errorf.E("blob %s not found", sha256Hex) + } + if err != nil { + return + } + + blobPath := s.getBlobPath(sha256Hex, metadata.Extension) + indexKey := prefixBlobIndex + hex.Enc(pubkey) + ":" + sha256Hex + + if err = s.db.Update(func(txn *badger.Txn) error { // Delete metadata if err := txn.Delete([]byte(metaKey)); err != nil { return err @@ -185,6 +243,12 @@ func (s *Storage) DeleteBlob(sha256Hash []byte, pubkey []byte) (err error) { return } + // Delete blob file + if err = os.Remove(blobPath); err != nil && !os.IsNotExist(err) { + log.E.F("failed to delete blob file %s: %v", blobPath, err) + // Don't fail if file doesn't exist + } + log.D.F("deleted blob %s for pubkey %s", sha256Hex, hex.Enc(pubkey)) return } @@ -236,10 +300,9 @@ func (s *Storage) ListBlobs( continue } - // Verify blob exists - dataKey := prefixBlobData + sha256Hex - _, errGet := txn.Get([]byte(dataKey)) - if errGet != nil { + // Verify blob file exists + blobPath := s.getBlobPath(sha256Hex, metadata.Extension) + if _, errGet := os.Stat(blobPath); errGet != nil { continue } diff --git a/pkg/blossom/utils.go b/pkg/blossom/utils.go index 458870c..8e704de 100644 --- a/pkg/blossom/utils.go +++ b/pkg/blossom/utils.go @@ -248,3 +248,35 @@ func GetFileExtensionFromPath(path string) string { return ext } +// GetExtensionFromMimeType returns file extension based on MIME type +func GetExtensionFromMimeType(mimeType string) string { + // Reverse lookup of GetMimeTypeFromExtension + mimeToExt := map[string]string{ + "application/pdf": ".pdf", + "image/png": ".png", + "image/jpeg": ".jpg", + "image/gif": ".gif", + "image/webp": ".webp", + "image/svg+xml": ".svg", + "video/mp4": ".mp4", + "video/webm": ".webm", + "audio/mpeg": ".mp3", + "audio/wav": ".wav", + "audio/ogg": ".ogg", + "text/plain": ".txt", + "text/html": ".html", + "text/css": ".css", + "application/javascript": ".js", + "application/json": ".json", + "application/xml": ".xml", + "application/zip": ".zip", + "application/x-tar": ".tar", + "application/gzip": ".gz", + } + + if ext, ok := mimeToExt[mimeType]; ok { + return ext + } + return "" // No extension for unknown MIME types +} + diff --git a/pkg/blossom/utils_test.go b/pkg/blossom/utils_test.go new file mode 100644 index 0000000..f382979 --- /dev/null +++ b/pkg/blossom/utils_test.go @@ -0,0 +1,389 @@ +package blossom + +import ( + "bytes" + "context" + "encoding/base64" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "next.orly.dev/pkg/acl" + "next.orly.dev/pkg/crypto/p256k" + "next.orly.dev/pkg/database" + "next.orly.dev/pkg/encoders/event" + "next.orly.dev/pkg/encoders/hex" + "next.orly.dev/pkg/encoders/tag" + "next.orly.dev/pkg/encoders/timestamp" +) + +// testSetup creates a test database, ACL, and server +func testSetup(t *testing.T) (*Server, func()) { + // Create temporary directory for database + tempDir, err := os.MkdirTemp("", "blossom-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + + // Create database + db, err := database.New(ctx, cancel, tempDir, "error") + if err != nil { + os.RemoveAll(tempDir) + t.Fatalf("Failed to create database: %v", err) + } + + // Create ACL registry + aclRegistry := acl.Registry + + // Create temporary directory for blob storage + blobDir, err := os.MkdirTemp("", "blossom-blobs-*") + if err != nil { + t.Fatalf("Failed to create blob temp dir: %v", err) + } + + // Create server + cfg := &Config{ + BaseURL: "http://localhost:8080", + MaxBlobSize: 100 * 1024 * 1024, // 100MB + AllowedMimeTypes: nil, + RequireAuth: false, + BlobDir: blobDir, + } + + server := NewServer(db, aclRegistry, cfg) + + cleanup := func() { + cancel() + db.Close() + os.RemoveAll(tempDir) + os.RemoveAll(blobDir) + } + + return server, cleanup +} + +// createTestKeypair creates a test keypair for signing events +func createTestKeypair(t *testing.T) ([]byte, *p256k.Signer) { + signer := &p256k.Signer{} + if err := signer.Generate(); err != nil { + t.Fatalf("Failed to generate keypair: %v", err) + } + pubkey := signer.Pub() + return pubkey, signer +} + +// createAuthEvent creates a valid kind 24242 authorization event +func createAuthEvent( + t *testing.T, signer *p256k.Signer, verb string, + sha256Hash []byte, expiresIn int64, +) *event.E { + now := time.Now().Unix() + expires := now + expiresIn + + tags := tag.NewS() + tags.Append(tag.NewFromAny("t", verb)) + tags.Append(tag.NewFromAny("expiration", timestamp.FromUnix(expires).String())) + + if sha256Hash != nil { + tags.Append(tag.NewFromAny("x", hex.Enc(sha256Hash))) + } + + ev := &event.E{ + CreatedAt: now, + Kind: BlossomAuthKind, + Tags: tags, + Content: []byte("Test authorization"), + Pubkey: signer.Pub(), + } + + // Sign event + if err := ev.Sign(signer); err != nil { + t.Fatalf("Failed to sign event: %v", err) + } + + return ev +} + +// createAuthHeader creates an Authorization header from an event +func createAuthHeader(ev *event.E) string { + eventJSON := ev.Serialize() + b64 := base64.StdEncoding.EncodeToString(eventJSON) + return "Nostr " + b64 +} + +// makeRequest creates an HTTP request with optional authorization +func makeRequest( + t *testing.T, method, path string, body []byte, authEv *event.E, +) *http.Request { + req := httptest.NewRequest(method, path, nil) + if body != nil { + req.Body = httptest.NewRequest(method, path, nil).Body + req.ContentLength = int64(len(body)) + } + + if authEv != nil { + req.Header.Set("Authorization", createAuthHeader(authEv)) + } + + return req +} + +// TestBlobDescriptor tests BlobDescriptor creation and serialization +func TestBlobDescriptor(t *testing.T) { + desc := NewBlobDescriptor( + "https://example.com/blob.pdf", + "abc123", + 1024, + "application/pdf", + 1234567890, + ) + + if desc.URL != "https://example.com/blob.pdf" { + t.Errorf("Expected URL %s, got %s", "https://example.com/blob.pdf", desc.URL) + } + if desc.SHA256 != "abc123" { + t.Errorf("Expected SHA256 %s, got %s", "abc123", desc.SHA256) + } + if desc.Size != 1024 { + t.Errorf("Expected Size %d, got %d", 1024, desc.Size) + } + if desc.Type != "application/pdf" { + t.Errorf("Expected Type %s, got %s", "application/pdf", desc.Type) + } + + // Test default MIME type + desc2 := NewBlobDescriptor("url", "hash", 0, "", 0) + if desc2.Type != "application/octet-stream" { + t.Errorf("Expected default MIME type, got %s", desc2.Type) + } +} + +// TestBlobMetadata tests BlobMetadata serialization +func TestBlobMetadata(t *testing.T) { + pubkey := []byte("testpubkey123456789012345678901234") + meta := NewBlobMetadata(pubkey, "image/png", 2048) + + if meta.Size != 2048 { + t.Errorf("Expected Size %d, got %d", 2048, meta.Size) + } + if meta.MimeType != "image/png" { + t.Errorf("Expected MIME type %s, got %s", "image/png", meta.MimeType) + } + + // Test serialization + data, err := meta.Serialize() + if err != nil { + t.Fatalf("Failed to serialize metadata: %v", err) + } + + // Test deserialization + meta2, err := DeserializeBlobMetadata(data) + if err != nil { + t.Fatalf("Failed to deserialize metadata: %v", err) + } + + if meta2.Size != meta.Size { + t.Errorf("Size mismatch after deserialize") + } + if meta2.MimeType != meta.MimeType { + t.Errorf("MIME type mismatch after deserialize") + } +} + +// TestUtils tests utility functions +func TestUtils(t *testing.T) { + data := []byte("test data") + hash := CalculateSHA256(data) + if len(hash) != 32 { + t.Errorf("Expected hash length 32, got %d", len(hash)) + } + + hashHex := CalculateSHA256Hex(data) + if len(hashHex) != 64 { + t.Errorf("Expected hex hash length 64, got %d", len(hashHex)) + } + + // Test ExtractSHA256FromPath + sha256Hex, ext, err := ExtractSHA256FromPath("abc123def456") + if err != nil { + t.Fatalf("Failed to extract SHA256: %v", err) + } + if sha256Hex != "abc123def456" { + t.Errorf("Expected %s, got %s", "abc123def456", sha256Hex) + } + if ext != "" { + t.Errorf("Expected empty ext, got %s", ext) + } + + sha256Hex, ext, err = ExtractSHA256FromPath("abc123def456.pdf") + if err != nil { + t.Fatalf("Failed to extract SHA256: %v", err) + } + if sha256Hex != "abc123def456" { + t.Errorf("Expected %s, got %s", "abc123def456", sha256Hex) + } + if ext != ".pdf" { + t.Errorf("Expected .pdf, got %s", ext) + } + + // Test MIME type detection + mime := GetMimeTypeFromExtension(".pdf") + if mime != "application/pdf" { + t.Errorf("Expected application/pdf, got %s", mime) + } + + mime = DetectMimeType("image/png", ".png") + if mime != "image/png" { + t.Errorf("Expected image/png, got %s", mime) + } + + mime = DetectMimeType("", ".jpg") + if mime != "image/jpeg" { + t.Errorf("Expected image/jpeg, got %s", mime) + } +} + +// TestStorage tests storage operations +func TestStorage(t *testing.T) { + server, cleanup := testSetup(t) + defer cleanup() + + storage := server.storage + + // Create test data + testData := []byte("test blob data") + sha256Hash := CalculateSHA256(testData) + pubkey := []byte("testpubkey123456789012345678901234") + + // Test SaveBlob + err := storage.SaveBlob(sha256Hash, testData, pubkey, "text/plain", "") + if err != nil { + t.Fatalf("Failed to save blob: %v", err) + } + + // Test HasBlob + exists, err := storage.HasBlob(sha256Hash) + if err != nil { + t.Fatalf("Failed to check blob existence: %v", err) + } + if !exists { + t.Error("Blob should exist after save") + } + + // Test GetBlob + blobData, metadata, err := storage.GetBlob(sha256Hash) + if err != nil { + t.Fatalf("Failed to get blob: %v", err) + } + if string(blobData) != string(testData) { + t.Error("Blob data mismatch") + } + if metadata.Size != int64(len(testData)) { + t.Errorf("Size mismatch: expected %d, got %d", len(testData), metadata.Size) + } + + // Test ListBlobs + descriptors, err := storage.ListBlobs(pubkey, 0, 0) + if err != nil { + t.Fatalf("Failed to list blobs: %v", err) + } + if len(descriptors) != 1 { + t.Errorf("Expected 1 blob, got %d", len(descriptors)) + } + + // Test DeleteBlob + err = storage.DeleteBlob(sha256Hash, pubkey) + if err != nil { + t.Fatalf("Failed to delete blob: %v", err) + } + + exists, err = storage.HasBlob(sha256Hash) + if err != nil { + t.Fatalf("Failed to check blob existence: %v", err) + } + if exists { + t.Error("Blob should not exist after delete") + } +} + +// TestAuthEvent tests authorization event validation +func TestAuthEvent(t *testing.T) { + pubkey, signer := createTestKeypair(t) + sha256Hash := CalculateSHA256([]byte("test")) + + // Create valid auth event + authEv := createAuthEvent(t, signer, "upload", sha256Hash, 3600) + + // Create HTTP request + req := httptest.NewRequest("PUT", "/upload", nil) + req.Header.Set("Authorization", createAuthHeader(authEv)) + + // Extract and validate + ev, err := ExtractAuthEvent(req) + if err != nil { + t.Fatalf("Failed to extract auth event: %v", err) + } + + if ev.Kind != BlossomAuthKind { + t.Errorf("Expected kind %d, got %d", BlossomAuthKind, ev.Kind) + } + + // Validate auth event + authEv2, err := ValidateAuthEvent(req, "upload", sha256Hash) + if err != nil { + t.Fatalf("Failed to validate auth event: %v", err) + } + + if authEv2.Verb != "upload" { + t.Errorf("Expected verb 'upload', got '%s'", authEv2.Verb) + } + + // Verify pubkey matches + if !bytes.Equal(authEv2.Pubkey, pubkey) { + t.Error("Pubkey mismatch") + } +} + +// TestAuthEventExpired tests expired authorization events +func TestAuthEventExpired(t *testing.T) { + _, signer := createTestKeypair(t) + sha256Hash := CalculateSHA256([]byte("test")) + + // Create expired auth event + authEv := createAuthEvent(t, signer, "upload", sha256Hash, -3600) + + req := httptest.NewRequest("PUT", "/upload", nil) + req.Header.Set("Authorization", createAuthHeader(authEv)) + + _, err := ValidateAuthEvent(req, "upload", sha256Hash) + if err == nil { + t.Error("Expected error for expired auth event") + } +} + +// TestServerHandler tests the server handler routing +func TestServerHandler(t *testing.T) { + server, cleanup := testSetup(t) + defer cleanup() + + handler := server.Handler() + + // Test OPTIONS request (CORS preflight) + req := httptest.NewRequest("OPTIONS", "/", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + // Check CORS headers + if w.Header().Get("Access-Control-Allow-Origin") != "*" { + t.Error("Missing CORS header") + } +} +