diff --git a/cmd/potokd/main.go b/cmd/potokd/main.go index af11151..379b6eb 100644 --- a/cmd/potokd/main.go +++ b/cmd/potokd/main.go @@ -49,6 +49,8 @@ func main() { 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("PUT /vaults/{name}/manifest", handler.APIKeyAuth(nethttp.HandlerFunc(handler.PutManifest))) + nethttp.Handle("GET /vaults/{name}/manifest", handler.APIKeyAuth(nethttp.HandlerFunc(handler.GetManifest))) nethttp.Handle("GET /me", handler.APIKeyAuth(nethttp.HandlerFunc(handler.Me))) log.Fatal(nethttp.ListenAndServe(cfg.Addr, nil)) } diff --git a/internal/server/http/http.go b/internal/server/http/http.go index 1581a6f..2a155a4 100644 --- a/internal/server/http/http.go +++ b/internal/server/http/http.go @@ -22,6 +22,8 @@ type Store interface { 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) + GetManifest(ctx context.Context, vaultID string) (store.Manifest, error) + PutManifest(ctx context.Context, vaultID string, expectedGeneration int64, ciphertext []byte) (store.Manifest, error) } type Handler struct { diff --git a/internal/server/http/manifest.go b/internal/server/http/manifest.go new file mode 100644 index 0000000..6bddef9 --- /dev/null +++ b/internal/server/http/manifest.go @@ -0,0 +1,119 @@ +package http + +import ( + "encoding/base64" + "encoding/json" + "net/http" + + "github.com/mtiluk/potok/internal/server/store" +) + +func (h *Handler) PutManifest(w http.ResponseWriter, r *http.Request) { + user, ok := UserFromContext(r.Context()) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + vaultName := r.PathValue("name") + if vaultName == "" { + http.Error(w, "vault name is 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 + } + + var body struct { + ExpectedGeneration int64 `json:"expected_generation"` + Ciphertext string `json:"ciphertext"` + } + + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "malformed request", http.StatusBadRequest) + return + } + + if body.ExpectedGeneration < 0 { + http.Error(w, "expected_generation must not be negative", http.StatusBadRequest) + return + } + + if body.Ciphertext == "" { + http.Error(w, "ciphertext is required", http.StatusBadRequest) + return + } + + ciphertext, err := base64.StdEncoding.DecodeString(body.Ciphertext) + if err != nil { + http.Error(w, "ciphertext must be base64-encoded", http.StatusBadRequest) + return + } + + manifest, err := h.store.PutManifest(r.Context(), vault.ID, body.ExpectedGeneration, ciphertext) + if err != nil { + if err == store.ErrManifestConflict { + http.Error(w, "manifest generation conflict", http.StatusConflict) + return + } + + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(struct { + Generation int64 `json:"generation"` + }{manifest.Generation}) +} + +func (h *Handler) GetManifest(w http.ResponseWriter, r *http.Request) { + user, ok := UserFromContext(r.Context()) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + vaultName := r.PathValue("name") + if vaultName == "" { + http.Error(w, "vault name is 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 + } + + manifest, err := h.store.GetManifest(r.Context(), vault.ID) + if err != nil { + if err == store.ErrManifestNotFound { + http.Error(w, "manifest not found", http.StatusNotFound) + return + } + + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(struct { + Generation int64 `json:"generation"` + Ciphertext string `json:"ciphertext"` + }{manifest.Generation, base64.StdEncoding.EncodeToString(manifest.Ciphertext)}) +} diff --git a/internal/server/http/manifest_test.go b/internal/server/http/manifest_test.go new file mode 100644 index 0000000..c78256a --- /dev/null +++ b/internal/server/http/manifest_test.go @@ -0,0 +1,278 @@ +package http + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/mtiluk/potok/internal/server/blobstore" + "github.com/mtiluk/potok/internal/server/store" +) + +func newManifestTestHandler(t *testing.T, s *store.Store) *Handler { + t.Helper() + return &Handler{ + store: s, + blobStore: *blobstore.New(t.TempDir()), + } +} + +func putManifestBody(t *testing.T, expectedGeneration int64, ciphertext []byte) *bytes.Reader { + t.Helper() + body, err := json.Marshal(struct { + ExpectedGeneration int64 `json:"expected_generation"` + Ciphertext string `json:"ciphertext"` + }{expectedGeneration, base64.StdEncoding.EncodeToString(ciphertext)}) + if err != nil { + t.Fatalf("marshal put-manifest body: %v", err) + } + return bytes.NewReader(body) +} + +func TestPutManifestEndpoint(t *testing.T) { + s := newTestStore(t) + handler := newManifestTestHandler(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) + } + + putRaw := func(apiKey, vaultName string, body *bytes.Reader) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPut, "/vaults/"+vaultName+"/manifest", body) + req.Header.Set("Authorization", "Bearer "+apiKey) + if vaultName != "" { + req.SetPathValue("name", vaultName) + } + rec := httptest.NewRecorder() + handler.APIKeyAuth(http.HandlerFunc(handler.PutManifest)).ServeHTTP(rec, req) + return rec + } + + t.Run("creates the first manifest at generation 1", func(t *testing.T) { + rec := putRaw(user.APIKey, "notes", putManifestBody(t, 0, []byte("ciphertext-v1"))) + if rec.Code != http.StatusOK { + t.Fatalf("expected status code %d, got %d (body: %s)", http.StatusOK, rec.Code, rec.Body.String()) + } + + var resp struct { + Generation int64 `json:"generation"` + } + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.Generation != 1 { + t.Errorf("generation = %d, want 1", resp.Generation) + } + }) + + t.Run("rejects a second first-write once a manifest exists", func(t *testing.T) { + rec := putRaw(user.APIKey, "notes", putManifestBody(t, 0, []byte("ciphertext-conflict"))) + if rec.Code != http.StatusConflict { + t.Errorf("expected status code %d, got %d (body: %s)", http.StatusConflict, rec.Code, rec.Body.String()) + } + }) + + t.Run("advances the generation when expected_generation matches", func(t *testing.T) { + rec := putRaw(user.APIKey, "notes", putManifestBody(t, 1, []byte("ciphertext-v2"))) + if rec.Code != http.StatusOK { + t.Fatalf("expected status code %d, got %d (body: %s)", http.StatusOK, rec.Code, rec.Body.String()) + } + + var resp struct { + Generation int64 `json:"generation"` + } + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.Generation != 2 { + t.Errorf("generation = %d, want 2", resp.Generation) + } + }) + + t.Run("rejects a stale expected_generation", func(t *testing.T) { + rec := putRaw(user.APIKey, "notes", putManifestBody(t, 1, []byte("ciphertext-stale"))) + if rec.Code != http.StatusConflict { + t.Errorf("expected status code %d, got %d (body: %s)", http.StatusConflict, rec.Code, rec.Body.String()) + } + }) + + t.Run("rejects a negative expected_generation", func(t *testing.T) { + rec := putRaw(user.APIKey, "notes", putManifestBody(t, -1, []byte("x"))) + if rec.Code != http.StatusBadRequest { + t.Errorf("expected status code %d, got %d (body: %s)", http.StatusBadRequest, rec.Code, rec.Body.String()) + } + }) + + t.Run("rejects an empty ciphertext", func(t *testing.T) { + rec := putRaw(user.APIKey, "notes", putManifestBody(t, 2, nil)) + if rec.Code != http.StatusBadRequest { + t.Errorf("expected status code %d, got %d (body: %s)", http.StatusBadRequest, rec.Code, rec.Body.String()) + } + }) + + t.Run("rejects malformed JSON", func(t *testing.T) { + rec := putRaw(user.APIKey, "notes", bytes.NewReader([]byte("not json"))) + if rec.Code != http.StatusBadRequest { + t.Errorf("expected status code %d, got %d (body: %s)", http.StatusBadRequest, rec.Code, rec.Body.String()) + } + }) + + t.Run("rejects non-base64 ciphertext", func(t *testing.T) { + raw := bytes.NewReader([]byte(`{"expected_generation":2,"ciphertext":"not-valid-base64!!"}`)) + rec := putRaw(user.APIKey, "notes", raw) + 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 := putRaw(user.APIKey, "", putManifestBody(t, 0, []byte("x"))) + 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 := putRaw(user.APIKey, "does-not-exist", putManifestBody(t, 0, []byte("x"))) + 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 := putRaw(otherUser.APIKey, "notes", putManifestBody(t, 0, []byte("x"))) + 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/manifest", putManifestBody(t, 0, []byte("x"))) + req.SetPathValue("name", "notes") + rec := httptest.NewRecorder() + handler.PutManifest(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Errorf("expected status code %d, got %d", http.StatusUnauthorized, rec.Code) + } + }) +} + +func TestGetManifestEndpoint(t *testing.T) { + s := newTestStore(t) + handler := newManifestTestHandler(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, manifest") + + seedReq := httptest.NewRequest(http.MethodPut, "/vaults/notes/manifest", putManifestBody(t, 0, content)) + seedReq.Header.Set("Authorization", "Bearer "+user.APIKey) + seedReq.SetPathValue("name", "notes") + seedRec := httptest.NewRecorder() + handler.APIKeyAuth(http.HandlerFunc(handler.PutManifest)).ServeHTTP(seedRec, seedReq) + if seedRec.Code != http.StatusOK { + t.Fatalf("seed PutManifest failed: %d (body: %s)", seedRec.Code, seedRec.Body.String()) + } + + get := func(apiKey, vaultName string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/vaults/"+vaultName+"/manifest", nil) + req.Header.Set("Authorization", "Bearer "+apiKey) + if vaultName != "" { + req.SetPathValue("name", vaultName) + } + rec := httptest.NewRecorder() + handler.APIKeyAuth(http.HandlerFunc(handler.GetManifest)).ServeHTTP(rec, req) + return rec + } + + t.Run("returns the stored generation and ciphertext", func(t *testing.T) { + rec := get(user.APIKey, "notes") + if rec.Code != http.StatusOK { + t.Fatalf("expected status code %d, got %d (body: %s)", http.StatusOK, rec.Code, rec.Body.String()) + } + + var resp struct { + Generation int64 `json:"generation"` + Ciphertext string `json:"ciphertext"` + } + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.Generation != 1 { + t.Errorf("generation = %d, want 1", resp.Generation) + } + + got, err := base64.StdEncoding.DecodeString(resp.Ciphertext) + if err != nil { + t.Fatalf("decode ciphertext: %v", err) + } + if !bytes.Equal(got, content) { + t.Errorf("ciphertext = %q, want %q", got, content) + } + }) + + t.Run("manifest not found for a vault with no manifest pushed yet", func(t *testing.T) { + if _, err := s.CreateVault(context.Background(), user.ID, "empty-vault"); err != nil { + t.Fatalf("seed CreateVault(empty-vault): %v", err) + } + rec := get(user.APIKey, "empty-vault") + 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") + 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, "") + 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 manifest", func(t *testing.T) { + rec := get(otherUser.APIKey, "notes") + 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/manifest", nil) + req.SetPathValue("name", "notes") + rec := httptest.NewRecorder() + handler.GetManifest(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Errorf("expected status code %d, got %d", http.StatusUnauthorized, rec.Code) + } + }) +} diff --git a/internal/server/store/store.go b/internal/server/store/store.go index 31eeba0..47b2c4e 100644 --- a/internal/server/store/store.go +++ b/internal/server/store/store.go @@ -16,6 +16,8 @@ var ErrUserExists = fmt.Errorf("user already exists") var ErrUserNotFound = fmt.Errorf("user not found") var ErrVaultNotFound = fmt.Errorf("vault not found") var ErrVaultExists = fmt.Errorf("vault already exists") +var ErrManifestNotFound = fmt.Errorf("manifest not found") +var ErrManifestConflict = fmt.Errorf("manifest generation conflict") type Store struct { db *sql.DB @@ -44,6 +46,13 @@ type Blob struct { CreatedAt time.Time `json:"created_at"` } +type Manifest struct { + VaultID string `json:"vault_id"` + Generation int64 `json:"generation"` + Ciphertext []byte `json:"-"` + UpdatedAt time.Time `json:"updated_at"` +} + func Open(ctx context.Context, dsn string) (*Store, error) { db, err := sql.Open("sqlite", dsn) if err != nil { @@ -250,3 +259,60 @@ func (s *Store) PutBlob(ctx context.Context, vaultID, blobID string, sizeBytes i } return nil } + +func (s *Store) GetManifest(ctx context.Context, vaultID string) (Manifest, error) { + var m Manifest + err := s.db.QueryRowContext(ctx, ` + SELECT vault_id, generation, ciphertext, updated_at + FROM manifests + WHERE vault_id = ?`, + vaultID, + ).Scan(&m.VaultID, &m.Generation, &m.Ciphertext, &m.UpdatedAt) + + if err != nil { + if err == sql.ErrNoRows { + return Manifest{}, ErrManifestNotFound + } + + return Manifest{}, fmt.Errorf("store: get manifest: %w", err) + } + return m, nil +} + +// PutManifest writes a new manifest generation using optimistic concurrency: +// expectedGeneration must be 0 for the vault's first manifest (no row yet), or +// match the current row's generation for every write after that. A mismatch — +// including a second "first write" once a manifest already exists — returns +// ErrManifestConflict rather than silently overwriting a concurrent change. +func (s *Store) PutManifest(ctx context.Context, vaultID string, expectedGeneration int64, ciphertext []byte) (Manifest, error) { + newGeneration := expectedGeneration + 1 + var m Manifest + var err error + + if expectedGeneration == 0 { + err = s.db.QueryRowContext(ctx, ` + INSERT INTO manifests (vault_id, generation, ciphertext) + VALUES (?, ?, ?) + ON CONFLICT (vault_id) DO NOTHING + RETURNING vault_id, generation, ciphertext, updated_at`, + vaultID, newGeneration, ciphertext, + ).Scan(&m.VaultID, &m.Generation, &m.Ciphertext, &m.UpdatedAt) + } else { + err = s.db.QueryRowContext(ctx, ` + UPDATE manifests + SET generation = ?, ciphertext = ?, updated_at = CURRENT_TIMESTAMP + WHERE vault_id = ? AND generation = ? + RETURNING vault_id, generation, ciphertext, updated_at`, + newGeneration, ciphertext, vaultID, expectedGeneration, + ).Scan(&m.VaultID, &m.Generation, &m.Ciphertext, &m.UpdatedAt) + } + + if err != nil { + if err == sql.ErrNoRows { + return Manifest{}, ErrManifestConflict + } + + return Manifest{}, fmt.Errorf("store: put manifest: %w", err) + } + return m, nil +}