Expose permission-checked immutable asset metadata for shared depot adoption
Foundry Go checks / check (push) Failing after 6m24s
Foundry Go checks / check (pull_request) Failing after 4m36s

This commit is contained in:
2026-11-09 15:47:19 +08:00
parent 6a0a2e1912
commit 6b07c19728
3 changed files with 71 additions and 1 deletions
+45
View File
@@ -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")
+1
View File
@@ -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))
+25 -1
View File
@@ -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)