diff --git a/internal/platform/assets.go b/internal/platform/assets.go index b55891d..7d3c507 100644 --- a/internal/platform/assets.go +++ b/internal/platform/assets.go @@ -271,6 +271,51 @@ func assetReadable(r *http.Request, tx *sql.Tx, project, workspace, id, account } return false, nil } + +// Metadata uses the same read policy as original bytes; it is not a persistent grant. +func (s *service) getAssetMetadata(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodGet { + reject(w, 405, "method_not_allowed") + return nil + } + id, workspace := r.PathValue("asset"), r.PathValue("workspace") + if !uuidSlugPattern.MatchString(id) { + reject(w, 404, "not_found") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + var locked string + err = tx.QueryRowContext(r.Context(), `SELECT id::text FROM workspaces WHERE project=$1 AND id=$2 FOR UPDATE`, project, workspace).Scan(&locked) + if err != nil { + return err + } + allowed, err := assetReadable(r, tx, project, workspace, id, account) + if err != nil { + return err + } + if !allowed { + reject(w, 404, "not_found") + return nil + } + var info assetInfo + var digest string + err = tx.QueryRowContext(r.Context(), `SELECT id::text,mime_type,octet_length(bytes),width,height,digest FROM canvas_assets WHERE project=$1 AND workspace_id=$2 AND id=$3`, project, workspace, id).Scan(&info.ID, &info.MIME, &info.Bytes, &info.Width, &info.Height, &digest) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + info.URL = s.assetURL(project, workspace, id) + w.Header().Set("Cache-Control", "private, no-store") + reply(w, 200, response{"metadata_version": 1, "asset": info, "sha256": digest}) + return nil +} + func (s *service) getAsset(w http.ResponseWriter, r *http.Request, project, account string) error { if r.Method != http.MethodGet { reject(w, 405, "method_not_allowed") diff --git a/internal/platform/http.go b/internal/platform/http.go index 594c3fa..188a8f9 100644 --- a/internal/platform/http.go +++ b/internal/platform/http.go @@ -151,6 +151,7 @@ func Register(mux *http.ServeMux, db *sql.DB, callbacks map[string]string, origi mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials", s.protected(callbacks, s.materials)) mux.HandleFunc("/projects/{project}/workspaces/{workspace}/assets", s.protected(callbacks, s.uploadAsset)) mux.HandleFunc("/projects/{project}/workspaces/{workspace}/assets/operations/{request}", s.protected(callbacks, s.assetOperation)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/assets/{asset}/metadata", s.protected(callbacks, s.getAssetMetadata)) mux.HandleFunc("/projects/{project}/workspaces/{workspace}/assets/{asset}", s.protected(callbacks, s.getAsset)) mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}", s.protected(callbacks, s.material)) mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas", s.protected(callbacks, s.canvas)) diff --git a/internal/platform/integration_test.go b/internal/platform/integration_test.go index d136bc1..ac79743 100644 --- a/internal/platform/integration_test.go +++ b/internal/platform/integration_test.go @@ -584,9 +584,33 @@ func TestOnboardingAndSharing(t *testing.T) { t.Fatal("preview changed small original or privacy headers") } } + // Metadata discloses the same immutable identity only after a fresh read permission check. + metadataPath := workspacePath + "/assets/" + uploaded["id"].(string) + "/metadata" + for _, sample := range []struct { + token string + status int + }{{tokens[0], 200}, {tokens[1], 404}, {tokens[2], 404}} { + request := httptest.NewRequest("GET", metadataPath, nil) + request.Header.Set("Authorization", "Bearer "+sample.token) + result := httptest.NewRecorder() + mux.ServeHTTP(result, request) + if result.Code != sample.status { + t.Fatalf("metadata permission got %d want %d: %s", result.Code, sample.status, result.Body.String()) + } + if result.Code == 200 { + var metadata response + if err := json.Unmarshal(result.Body.Bytes(), &metadata); err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(assetPNG.Bytes()) + if metadata["metadata_version"] != float64(1) || metadata["sha256"] != hex.EncodeToString(digest[:]) || !modelEqual(metadata["asset"], uploaded) || result.Header().Get("Cache-Control") != "private, no-store" { + t.Fatalf("invalid metadata identity: %s", result.Body.String()) + } + } + } // Neither a snapshot nor an asset response may keep the workspace locked // while a slow downstream consumer is accepting its body. - for _, path := range []string{canvasPath, workspacePath + "/assets/" + uploaded["id"].(string)} { + for _, path := range []string{canvasPath, workspacePath + "/assets/" + uploaded["id"].(string), metadataPath} { var probeErr error writer := &readLockProbe{ResponseRecorder: httptest.NewRecorder(), probe: func() { lockTx, e := db.BeginTx(ctx, nil)