From 2f076341c866a570d57e8714a39383d9db4c8924 Mon Sep 17 00:00:00 2001 From: Michael Date: Thu, 10 Sep 2026 05:09:27 +0100 Subject: [PATCH] feat: add blob HTTP endpoints (unit 8) PUT/GET /vaults/{name}/blobs/{id}, wired behind APIKeyAuth. PutBlob hashes the body (SHA-256) and rejects a mismatch against the claimed id, short-circuits with 200 if the vault already has that blob (no disk write), otherwise writes via blobstore and records the row via store.PutBlob. GetBlob 404s on blobstore.ErrNotFound and streams the bytes back. store.HasBlob/PutBlob/GetBlob and the Store interface in http.go were fixed to key off vault_id (matching the blobs table's actual primary key) instead of user_id, and PutBlob no longer takes raw content bytes (the blobs table has no such column) - just size_bytes. Verified end-to-end locally with a real file over curl: hash mismatch, dedup short-circuit, not-found, and cross-user vault scoping all behave as expected. --- cmd/potokd/main.go | 5 +- internal/server/http/blob.go | 119 +++++++++++++++ internal/server/http/blob_test.go | 244 ++++++++++++++++++++++++++++++ internal/server/http/http.go | 11 +- internal/server/http/http_test.go | 22 ++- internal/server/store/store.go | 55 +++++++ 6 files changed, 444 insertions(+), 12 deletions(-) create mode 100644 internal/server/http/blob.go create mode 100644 internal/server/http/blob_test.go diff --git a/cmd/potokd/main.go b/cmd/potokd/main.go index bf305ca..ce92611 100644 --- a/cmd/potokd/main.go +++ b/cmd/potokd/main.go @@ -9,6 +9,7 @@ import ( nethttp "net/http" + "github.com/michaeltukdev/Potok/internal/server/blobstore" "github.com/michaeltukdev/Potok/internal/server/config" httpapi "github.com/michaeltukdev/Potok/internal/server/http" "github.com/michaeltukdev/Potok/internal/server/store" @@ -39,13 +40,15 @@ func main() { slog.Info("Database migrations completed successfully") slog.Info("Starting HTTP server") - handler := httpapi.NewHandler(conn) + handler := httpapi.NewHandler(conn, *blobstore.New(cfg.DataDir)) nethttp.HandleFunc("GET /health", handler.Health) nethttp.HandleFunc("POST /register", handler.Register) nethttp.Handle("POST /vaults", handler.APIKeyAuth(nethttp.HandlerFunc(handler.CreateVault))) nethttp.Handle("GET /vaults", handler.APIKeyAuth(nethttp.HandlerFunc(handler.ListVaults))) nethttp.Handle("GET /vaults/{name}", handler.APIKeyAuth(nethttp.HandlerFunc(handler.VaultByName))) nethttp.Handle("DELETE /vaults/{name}", handler.APIKeyAuth(nethttp.HandlerFunc(handler.DeleteVault))) + nethttp.Handle("PUT /vaults/{name}/blobs/{id}", handler.APIKeyAuth(nethttp.HandlerFunc(handler.PutBlob))) + nethttp.Handle("GET /vaults/{name}/blobs/{id}", handler.APIKeyAuth(nethttp.HandlerFunc(handler.GetBlob))) nethttp.Handle("GET /me", handler.APIKeyAuth(nethttp.HandlerFunc(handler.Me))) log.Fatal(nethttp.ListenAndServe(cfg.Addr, nil)) } diff --git a/internal/server/http/blob.go b/internal/server/http/blob.go new file mode 100644 index 0000000..5ae1a57 --- /dev/null +++ b/internal/server/http/blob.go @@ -0,0 +1,119 @@ +package http + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "errors" + "io" + "net/http" + + "github.com/michaeltukdev/Potok/internal/server/blobstore" + "github.com/michaeltukdev/Potok/internal/server/store" +) + +func (h *Handler) PutBlob(w http.ResponseWriter, r *http.Request) { + user, ok := UserFromContext(r.Context()) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + vaultName := r.PathValue("name") + blobID := r.PathValue("id") + + if vaultName == "" || blobID == "" { + http.Error(w, "vault name and blob id are required", http.StatusBadRequest) + return + } + + vault, err := h.store.VaultByName(r.Context(), user.ID, vaultName) + if err != nil { + if err == store.ErrVaultNotFound { + http.Error(w, "vault not found", http.StatusNotFound) + return + } + + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + sum := sha256.Sum256(body) + if hex.EncodeToString(sum[:]) != blobID { + http.Error(w, "blob id does not match content hash", http.StatusBadRequest) + return + } + + exists, err := h.store.HasBlob(r.Context(), vault.ID, blobID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if exists { + http.Error(w, "Blob already exists", http.StatusOK) + return + } + + if err := h.blobStore.Put(vault.ID, blobID, bytes.NewReader(body)); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if err := h.store.PutBlob(r.Context(), vault.ID, blobID, int64(len(body))); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusCreated) +} + +func (h *Handler) GetBlob(w http.ResponseWriter, r *http.Request) { + user, ok := UserFromContext(r.Context()) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + vaultName := r.PathValue("name") + blobID := r.PathValue("id") + + if vaultName == "" || blobID == "" { + http.Error(w, "vault name and blob id are required", http.StatusBadRequest) + return + } + + vault, err := h.store.VaultByName(r.Context(), user.ID, vaultName) + if err != nil { + if err == store.ErrVaultNotFound { + http.Error(w, "vault not found", http.StatusNotFound) + return + } + + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + blob, err := h.blobStore.Get(vault.ID, blobID) + if err != nil { + if errors.Is(err, blobstore.ErrNotFound) { + http.Error(w, "blob not found", http.StatusNotFound) + return + } + + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer blob.Close() + + w.Header().Set("Content-Type", "application/octet-stream") + if _, err := io.Copy(w, blob); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} diff --git a/internal/server/http/blob_test.go b/internal/server/http/blob_test.go new file mode 100644 index 0000000..c9267cb --- /dev/null +++ b/internal/server/http/blob_test.go @@ -0,0 +1,244 @@ +package http + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "net/http" + "net/http/httptest" + "testing" + + "github.com/michaeltukdev/Potok/internal/server/blobstore" + "github.com/michaeltukdev/Potok/internal/server/store" +) + +func newBlobTestHandler(t *testing.T, s *store.Store) *Handler { + t.Helper() + return &Handler{ + store: s, + blobStore: *blobstore.New(t.TempDir()), + } +} + +func hashHex(content []byte) string { + sum := sha256.Sum256(content) + return hex.EncodeToString(sum[:]) +} + +func TestPutBlobEndpoint(t *testing.T) { + s := newTestStore(t) + handler := newBlobTestHandler(t, s) + + user, err := s.CreateUser(context.Background(), "a@example.com", "hunter2") + if err != nil { + t.Fatalf("seed CreateUser: %v", err) + } + if _, err := s.CreateVault(context.Background(), user.ID, "notes"); err != nil { + t.Fatalf("seed CreateVault: %v", err) + } + otherUser, err := s.CreateUser(context.Background(), "b@example.com", "hunter2") + if err != nil { + t.Fatalf("seed CreateUser(other): %v", err) + } + + put := func(apiKey, vaultName, blobID string, body []byte) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPut, "/vaults/"+vaultName+"/blobs/"+blobID, bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+apiKey) + if vaultName != "" { + req.SetPathValue("name", vaultName) + } + if blobID != "" { + req.SetPathValue("id", blobID) + } + rec := httptest.NewRecorder() + handler.APIKeyAuth(http.HandlerFunc(handler.PutBlob)).ServeHTTP(rec, req) + return rec + } + + content := []byte("hello, potok") + id := hashHex(content) + + t.Run("stores a new blob and records it", func(t *testing.T) { + rec := put(user.APIKey, "notes", id, content) + if rec.Code != http.StatusCreated { + t.Fatalf("expected status code %d, got %d (body: %s)", http.StatusCreated, rec.Code, rec.Body.String()) + } + + vault, err := s.VaultByName(context.Background(), user.ID, "notes") + if err != nil { + t.Fatalf("VaultByName: %v", err) + } + has, err := s.HasBlob(context.Background(), vault.ID, id) + if err != nil { + t.Fatalf("HasBlob: %v", err) + } + if !has { + t.Error("HasBlob() = false after a successful Put, want true") + } + + rc, err := handler.blobStore.Get(vault.ID, id) + if err != nil { + t.Fatalf("blobStore.Get: %v", err) + } + defer rc.Close() + }) + + t.Run("short-circuits an already-stored blob", func(t *testing.T) { + rec := put(user.APIKey, "notes", id, content) + if rec.Code != http.StatusOK { + t.Errorf("expected status code %d, got %d (body: %s)", http.StatusOK, rec.Code, rec.Body.String()) + } + if got, want := rec.Body.String(), "Blob already exists\n"; got != want { + t.Errorf("body = %q, want %q", got, want) + } + }) + + t.Run("rejects content that doesn't match the claimed id", func(t *testing.T) { + rec := put(user.APIKey, "notes", id, []byte("tampered content")) + if rec.Code != http.StatusBadRequest { + t.Errorf("expected status code %d, got %d (body: %s)", http.StatusBadRequest, rec.Code, rec.Body.String()) + } + }) + + t.Run("missing vault name", func(t *testing.T) { + rec := put(user.APIKey, "", id, content) + if rec.Code != http.StatusBadRequest { + t.Errorf("expected status code %d, got %d (body: %s)", http.StatusBadRequest, rec.Code, rec.Body.String()) + } + }) + + t.Run("missing blob id", func(t *testing.T) { + rec := put(user.APIKey, "notes", "", content) + if rec.Code != http.StatusBadRequest { + t.Errorf("expected status code %d, got %d (body: %s)", http.StatusBadRequest, rec.Code, rec.Body.String()) + } + }) + + t.Run("vault not found", func(t *testing.T) { + rec := put(user.APIKey, "does-not-exist", id, content) + if rec.Code != http.StatusNotFound { + t.Errorf("expected status code %d, got %d (body: %s)", http.StatusNotFound, rec.Code, rec.Body.String()) + } + }) + + t.Run("cannot write to another user's vault", func(t *testing.T) { + rec := put(otherUser.APIKey, "notes", id, content) + if rec.Code != http.StatusNotFound { + t.Errorf("expected status code %d, got %d (body: %s)", http.StatusNotFound, rec.Code, rec.Body.String()) + } + }) + + t.Run("unauthorized without middleware", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPut, "/vaults/notes/blobs/"+id, bytes.NewReader(content)) + req.SetPathValue("name", "notes") + req.SetPathValue("id", id) + rec := httptest.NewRecorder() + handler.PutBlob(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Errorf("expected status code %d, got %d", http.StatusUnauthorized, rec.Code) + } + }) +} + +func TestGetBlobEndpoint(t *testing.T) { + s := newTestStore(t) + handler := newBlobTestHandler(t, s) + + user, err := s.CreateUser(context.Background(), "a@example.com", "hunter2") + if err != nil { + t.Fatalf("seed CreateUser: %v", err) + } + if _, err := s.CreateVault(context.Background(), user.ID, "notes"); err != nil { + t.Fatalf("seed CreateVault: %v", err) + } + otherUser, err := s.CreateUser(context.Background(), "b@example.com", "hunter2") + if err != nil { + t.Fatalf("seed CreateUser(other): %v", err) + } + + content := []byte("hello again, potok") + id := hashHex(content) + + seedReq := httptest.NewRequest(http.MethodPut, "/vaults/notes/blobs/"+id, bytes.NewReader(content)) + seedReq.Header.Set("Authorization", "Bearer "+user.APIKey) + seedReq.SetPathValue("name", "notes") + seedReq.SetPathValue("id", id) + seedRec := httptest.NewRecorder() + handler.APIKeyAuth(http.HandlerFunc(handler.PutBlob)).ServeHTTP(seedRec, seedReq) + if seedRec.Code != http.StatusCreated { + t.Fatalf("seed Put failed: %d (body: %s)", seedRec.Code, seedRec.Body.String()) + } + + get := func(apiKey, vaultName, blobID string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/vaults/"+vaultName+"/blobs/"+blobID, nil) + req.Header.Set("Authorization", "Bearer "+apiKey) + if vaultName != "" { + req.SetPathValue("name", vaultName) + } + if blobID != "" { + req.SetPathValue("id", blobID) + } + rec := httptest.NewRecorder() + handler.APIKeyAuth(http.HandlerFunc(handler.GetBlob)).ServeHTTP(rec, req) + return rec + } + + t.Run("returns the stored bytes", func(t *testing.T) { + rec := get(user.APIKey, "notes", id) + if rec.Code != http.StatusOK { + t.Fatalf("expected status code %d, got %d (body: %s)", http.StatusOK, rec.Code, rec.Body.String()) + } + if !bytes.Equal(rec.Body.Bytes(), content) { + t.Errorf("body = %q, want %q", rec.Body.Bytes(), content) + } + }) + + t.Run("blob not found", func(t *testing.T) { + rec := get(user.APIKey, "notes", hashHex([]byte("never uploaded"))) + if rec.Code != http.StatusNotFound { + t.Errorf("expected status code %d, got %d (body: %s)", http.StatusNotFound, rec.Code, rec.Body.String()) + } + }) + + t.Run("vault not found", func(t *testing.T) { + rec := get(user.APIKey, "does-not-exist", id) + if rec.Code != http.StatusNotFound { + t.Errorf("expected status code %d, got %d (body: %s)", http.StatusNotFound, rec.Code, rec.Body.String()) + } + }) + + t.Run("missing vault name", func(t *testing.T) { + rec := get(user.APIKey, "", id) + if rec.Code != http.StatusBadRequest { + t.Errorf("expected status code %d, got %d (body: %s)", http.StatusBadRequest, rec.Code, rec.Body.String()) + } + }) + + t.Run("missing blob id", func(t *testing.T) { + rec := get(user.APIKey, "notes", "") + if rec.Code != http.StatusBadRequest { + t.Errorf("expected status code %d, got %d (body: %s)", http.StatusBadRequest, rec.Code, rec.Body.String()) + } + }) + + t.Run("cannot read another user's vault blobs", func(t *testing.T) { + rec := get(otherUser.APIKey, "notes", id) + if rec.Code != http.StatusNotFound { + t.Errorf("expected status code %d, got %d (body: %s)", http.StatusNotFound, rec.Code, rec.Body.String()) + } + }) + + t.Run("unauthorized without middleware", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/vaults/notes/blobs/"+id, nil) + req.SetPathValue("name", "notes") + req.SetPathValue("id", id) + rec := httptest.NewRecorder() + handler.GetBlob(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Errorf("expected status code %d, got %d", http.StatusUnauthorized, rec.Code) + } + }) +} diff --git a/internal/server/http/http.go b/internal/server/http/http.go index 11e0d5f..c31416c 100644 --- a/internal/server/http/http.go +++ b/internal/server/http/http.go @@ -8,6 +8,7 @@ import ( "regexp" "time" + "github.com/michaeltukdev/Potok/internal/server/blobstore" "github.com/michaeltukdev/Potok/internal/server/store" ) @@ -18,14 +19,18 @@ type Store interface { DeleteVault(ctx context.Context, userID, name string) (bool, error) CreateUser(ctx context.Context, email, password string) (store.User, error) UserByAPIKey(ctx context.Context, apiKey string) (store.User, error) + GetBlob(ctx context.Context, userID, vaultName, blobName string) (store.Blob, error) + PutBlob(ctx context.Context, vaultID, blobID string, sizeBytes int64) error + HasBlob(ctx context.Context, vaultID, blobID string) (bool, error) } type Handler struct { - store Store + store Store + blobStore blobstore.Store } -func NewHandler(s Store) *Handler { - return &Handler{store: s} +func NewHandler(s Store, bs blobstore.Store) *Handler { + return &Handler{store: s, blobStore: bs} } func (h *Handler) Health(w http.ResponseWriter, r *http.Request) { diff --git a/internal/server/http/http_test.go b/internal/server/http/http_test.go index 89c322d..86131f5 100644 --- a/internal/server/http/http_test.go +++ b/internal/server/http/http_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "github.com/michaeltukdev/Potok/internal/server/blobstore" "github.com/michaeltukdev/Potok/internal/server/store" ) @@ -28,11 +29,16 @@ func newTestStore(t *testing.T) *store.Store { return s } +func newTestBlobStore(t *testing.T) blobstore.Store { + t.Helper() + return *blobstore.New(t.TempDir()) +} + func TestHealthEndpoint(t *testing.T) { req, _ := http.NewRequest(http.MethodGet, "/health", nil) response := httptest.NewRecorder() - handler := NewHandler(nil) + handler := NewHandler(nil, newTestBlobStore(t)) handler.Health(response, req) if response.Code != http.StatusOK { @@ -46,7 +52,7 @@ func TestHealthEndpoint(t *testing.T) { func TestMeEndpoint(t *testing.T) { store := newTestStore(t) - handler := NewHandler(store) + handler := NewHandler(store, newTestBlobStore(t)) user, err := store.CreateUser(context.Background(), "a@example.com", "hunter2") if err != nil { @@ -156,7 +162,7 @@ func TestCreateVaultEndpoint(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { s := newTestStore(t) - handler := NewHandler(s) + handler := NewHandler(s, newTestBlobStore(t)) user, err := s.CreateUser(context.Background(), "a@example.com", "hunter2") if err != nil { @@ -186,7 +192,7 @@ func TestCreateVaultEndpoint(t *testing.T) { } t.Run("unauthorized without middleware", func(t *testing.T) { - handler := NewHandler(newTestStore(t)) + handler := NewHandler(newTestStore(t), newTestBlobStore(t)) req := httptest.NewRequest(http.MethodPost, "/vaults", strings.NewReader(`{"name":"notes"}`)) rec := httptest.NewRecorder() handler.CreateVault(rec, req) @@ -199,7 +205,7 @@ func TestCreateVaultEndpoint(t *testing.T) { func TestListVaultsEndpoint(t *testing.T) { s := newTestStore(t) - handler := NewHandler(s) + handler := NewHandler(s, newTestBlobStore(t)) userA, err := s.CreateUser(context.Background(), "a@example.com", "hunter2") if err != nil { @@ -267,7 +273,7 @@ func TestListVaultsEndpoint(t *testing.T) { func TestVaultByNameEndpoint(t *testing.T) { s := newTestStore(t) - handler := NewHandler(s) + handler := NewHandler(s, newTestBlobStore(t)) user, err := s.CreateUser(context.Background(), "a@example.com", "hunter2") if err != nil { @@ -331,7 +337,7 @@ func TestVaultByNameEndpoint(t *testing.T) { func TestDeleteVaultEndpoint(t *testing.T) { s := newTestStore(t) - handler := NewHandler(s) + handler := NewHandler(s, newTestBlobStore(t)) user, err := s.CreateUser(context.Background(), "a@example.com", "hunter2") if err != nil { @@ -433,7 +439,7 @@ func TestRegisterEndpoint(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - handler := NewHandler(newTestStore(t)) + handler := NewHandler(newTestStore(t), newTestBlobStore(t)) post := func(body string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodPost, "/register", strings.NewReader(body)) diff --git a/internal/server/store/store.go b/internal/server/store/store.go index 85c6426..bde9813 100644 --- a/internal/server/store/store.go +++ b/internal/server/store/store.go @@ -37,6 +37,13 @@ type User struct { CreatedAt time.Time `json:"created_at"` } +type Blob struct { + VaultID string `json:"vault_id"` + ID string `json:"id"` + SizeBytes int64 `json:"size_bytes"` + CreatedAt time.Time `json:"created_at"` +} + func Open(ctx context.Context, dsn string) (*Store, error) { db, err := sql.Open("sqlite", dsn) if err != nil { @@ -195,3 +202,51 @@ func (s *Store) UserByAPIKey(ctx context.Context, apiKey string) (User, error) { } return user, nil } + +func (s *Store) GetBlob(ctx context.Context, userID, vaultName, blobName string) (Blob, error) { + var blob Blob + err := s.db.QueryRowContext(ctx, ` + SELECT vault_id, id, size_bytes, created_at + FROM blobs + WHERE vault_id = ? AND id = ?`, + vaultName, blobName, + ).Scan(&blob.VaultID, &blob.ID, &blob.SizeBytes, &blob.CreatedAt) + + if err != nil { + if err == sql.ErrNoRows { + return Blob{}, nil + } + + return Blob{}, fmt.Errorf("store: get blob: %w", err) + } + return blob, nil +} + +func (s *Store) HasBlob(ctx context.Context, vaultID, blobID string) (bool, error) { + var exists bool + err := s.db.QueryRowContext(ctx, ` + SELECT EXISTS( + SELECT 1 + FROM blobs + WHERE vault_id = ? AND id = ? + )`, + vaultID, blobID, + ).Scan(&exists) + + if err != nil { + return false, fmt.Errorf("store: has blob: %w", err) + } + return exists, nil +} + +func (s *Store) PutBlob(ctx context.Context, vaultID, blobID string, sizeBytes int64) error { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO blobs (vault_id, id, size_bytes) + VALUES (?, ?, ?) + `, vaultID, blobID, sizeBytes) + + if err != nil { + return fmt.Errorf("store: put blob: %w", err) + } + return nil +}