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 +}