| 1 | diff --git a/docs/extensions/custom-view-transforms.md b/docs/extensions/custom-view-transforms.md |
| 2 | index c761045..c19e4c1 100644 |
| 3 | --- a/docs/extensions/custom-view-transforms.md |
| 4 | +++ b/docs/extensions/custom-view-transforms.md |
| 5 | @@ -93,15 +93,17 @@ SVG is served under the sandbox policy below. Acceptance does not authorize arbi |
| 6 | - One `["block","<decimal index>"]` for each source, in the same source order. These block tags follow the source tags; they are not adjacent source/block pairs. |
| 7 | - `["type","image/svg+xml"]` or `["type","image/png"]`, optional `engine`, and optional `expiration`. |
| 8 | |
| 9 | -If all sources expire, artifact expiration is the latest source expiration. Any nonexpiring source removes the expiration tag. Source deletion/replacement/maintenance detaches obsolete references and removes an orphan artifact; pending transform work can defer pruning. Expiry is therefore not an unconditional immediate HTTP-route check. Removing a view deletes all its artifacts. Refresh retains prior artifacts until valid replacements arrive. |
| 10 | +If all sources expire, artifact expiration is the latest source expiration. Any nonexpiring source removes the expiration tag. Source deletion/replacement/maintenance detaches obsolete references and removes an orphan artifact; pending transform work can defer pruning. Media and signed-record reads reject expired sources even before maintenance prunes them. Removing a view deletes all its artifacts. Refresh retains prior artifacts until valid replacements arrive. |
| 11 | |
| 12 | Public artifacts use `records.Generate` and enter normal event storage. Members-only artifacts use `records.Sign` and remain signed in the artifact store without being published to normal event queries. The signature attests to the relay's generated output, not endorsement by any source author. |
| 13 | |
| 14 | -`GET` and `HEAD /views/<name>/<hash>` serve the stored media type; `.svg` and `.png` aliases are accepted only when they match that type. Invalid/missing paths or type mismatches return 404; other methods return 405. Authentication failures return 401; members-audience requests require role `member`, `moderator` or `owner` and otherwise return 403. **Role `agent` is not admitted by this members-only route.** Public artifacts use the relay's general browse-read rule, not a per-source visibility check. |
| 15 | +`GET` and `HEAD /views/<name>/<hash>` serve the stored media type; `.svg` and `.png` aliases are accepted only when they match that type. Invalid/missing paths or type mismatches return 404; other methods return 405. Authentication failures return 401; members-audience requests require role `member`, `moderator` or `owner` and otherwise return 403. **Role `agent` is not admitted by this members-only route.** Public artifacts require the relay's general browse-read rule. Both audiences also require a current, unexpired source visible to the requester. A repository-state source must still contain a block matching the artifact's hash. If no source qualifies, the route returns 404. |
| 16 | |
| 17 | -Responses set stored `Content-Type`, `Content-Length`, `X-Content-Type-Options: nosniff`, and `Content-Security-Policy: sandbox; default-src 'none'; style-src 'unsafe-inline'`. `ETag` is quoted lowercase SHA-256 of served bytes. Exact `If-None-Match` equality yields 304 after access checks. Cache control is `private, no-cache` for members and `public, no-cache` for public artifacts. See [`custom_views_http.go`](../../internal/daemon/custom_views_http.go). |
| 18 | +Responses set stored `Content-Type`, `Content-Length`, `X-Content-Type-Options: nosniff`, and `Content-Security-Policy: sandbox; default-src 'none'; style-src 'unsafe-inline'`. `ETag` is quoted lowercase SHA-256 of served bytes. Exact `If-None-Match` equality yields 304 after access checks. Cache control is `private, no-cache` because access depends on the requester and current source state. See [`custom_views_http.go`](../../internal/daemon/custom_views_http.go). |
| 19 | |
| 20 | -**Disclosure boundary:** Audience is attached to the view/artifact, not inherited from a source's room, proposal, wiki approval or repository ACL. A public view can expose an artifact of a nonpublic or pending source; a members view can expose it to members who cannot read that source. Transformation itself uses current source presence, not author-specific read authorization. Only configure a transform/audience appropriate for everything its kinds/languages can select. Hash-based paths and relay signatures are not access control or encryption. |
| 21 | +Signed artifact records require access to every source named in their tags, and the tags must match the current source records. An image shared by several sources can be served through any readable source because its bytes are identical; the signed record stays hidden when it would disclose another source. Source checks also apply through nested artifact references and reject cycles. |
| 22 | + |
| 23 | +Transformation sends blocks from current, unexpired sources to the owner-configured endpoint. Reader access checks do not prevent that outbound disclosure. Configure transforms only for content you intend to send to their endpoints. |
| 24 | |
| 25 | ## Scheduling, errors and discovery |
| 26 | |
| 27 | diff --git a/docs/views.md b/docs/views.md |
| 28 | index e6c7c7f..091468d 100644 |
| 29 | --- a/docs/views.md |
| 30 | +++ b/docs/views.md |
| 31 | @@ -65,7 +65,7 @@ An artifact is a relay-signed kind 30078 record addressed `bind.ws/view/<name>/< |
| 32 | |
| 33 | Artifacts follow their sources. An artifact expires when its last source expires, and one that outlives every source that referred to it is deleted. Editing an event detaches the blocks it no longer carries, and the artifact is dropped once nothing points at it. Removing a view deletes every artifact it produced. |
| 34 | |
| 35 | -Each artifact is served at `/views/<name>/<hash>` with its stored media type, under a sandbox policy that blocks scripts and outside loads. The `.svg` and `.png` URLs remain available. A members-only view admits the member, moderator and owner roles, not the agent role; a public view follows the relay's general read rule. Neither audience inherits the source event's access controls. A public artifact can expose material from a private or pending source, and a members-only artifact can expose it to members who cannot read that source. Configure transforms and audiences only for content you intend to disclose. The relay's pages embed the artifact as an image with a native **Source** disclosure. Browsers revalidate images so a rebuild can replace them. |
| 36 | +Each artifact is served at `/views/<name>/<hash>` with its stored media type, under a sandbox policy that blocks scripts and outside loads. The `.svg` and `.png` URLs remain available. A members-only view admits the member, moderator and owner roles, not the agent role; a public view follows the relay's general read rule. Both audiences also require access to a current, unexpired source containing the block. Signed artifact records require access to every source they list. Configure transforms only for content you intend to send to their endpoints. The relay's pages embed the artifact as an image with a native **Source** disclosure. Browsers revalidate images so a rebuild can replace them. |
| 37 | |
| 38 | ## Failures |
| 39 | |
| 40 | diff --git a/internal/daemon/custom_views.go b/internal/daemon/custom_views.go |
| 41 | index fe76335..5d2585a 100644 |
| 42 | --- a/internal/daemon/custom_views.go |
| 43 | +++ b/internal/daemon/custom_views.go |
| 44 | @@ -23,11 +23,12 @@ import ( |
| 45 | |
| 46 | "github.com/FelineStateMachine/tinyrelay/internal/community" |
| 47 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 48 | - gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit" |
| 49 | + "github.com/FelineStateMachine/tinyrelay/internal/gates" |
| 50 | "github.com/FelineStateMachine/tinyrelay/internal/records" |
| 51 | "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 52 | "github.com/FelineStateMachine/tinyrelay/internal/telemetry" |
| 53 | "github.com/FelineStateMachine/tinyrelay/internal/views" |
| 54 | + gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit" |
| 55 | ) |
| 56 | |
| 57 | // customViewService owns custom view definitions, transform execution and |
| 58 | @@ -45,6 +46,7 @@ type customViewService struct { |
| 59 | loopback func() bool |
| 60 | resolveActor func(*http.Request) (string, error) |
| 61 | browseRead func(context.Context, string) error |
| 62 | + gate *gates.Gate |
| 63 | viewClient *http.Client |
| 64 | viewMu sync.Mutex |
| 65 | viewIndex map[int][]customView |
| 66 | @@ -62,6 +64,7 @@ type customViewServiceConfig struct { |
| 67 | Loopback func() bool |
| 68 | ResolveActor func(*http.Request) (string, error) |
| 69 | BrowseRead func(context.Context, string) error |
| 70 | + Gate *gates.Gate |
| 71 | } |
| 72 | |
| 73 | func newCustomViewService(cfg customViewServiceConfig) *customViewService { |
| 74 | @@ -76,6 +79,7 @@ func newCustomViewService(cfg customViewServiceConfig) *customViewService { |
| 75 | loopback: cfg.Loopback, |
| 76 | resolveActor: cfg.ResolveActor, |
| 77 | browseRead: cfg.BrowseRead, |
| 78 | + gate: cfg.Gate, |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | diff --git a/internal/daemon/custom_views_access.go b/internal/daemon/custom_views_access.go |
| 83 | new file mode 100644 |
| 84 | index 0000000..2fb1da4 |
| 85 | --- /dev/null |
| 86 | +++ b/internal/daemon/custom_views_access.go |
| 87 | @@ -0,0 +1,119 @@ |
| 88 | +package daemon |
| 89 | + |
| 90 | +import ( |
| 91 | + "context" |
| 92 | + "encoding/json" |
| 93 | + "strconv" |
| 94 | + "strings" |
| 95 | + "time" |
| 96 | + |
| 97 | + "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 98 | + "github.com/FelineStateMachine/tinyrelay/internal/relay" |
| 99 | + "github.com/FelineStateMachine/tinyrelay/internal/views" |
| 100 | +) |
| 101 | + |
| 102 | +func (t *customViewService) artifactEventVisible(ctx context.Context, e event.Event, s relay.Session) bool { |
| 103 | + if t.records == nil || e.PubKey != t.records.PublicKey() { |
| 104 | + return true |
| 105 | + } |
| 106 | + d := event.Tag(e, "d") |
| 107 | + if !strings.HasPrefix(d, "bind.ws/view/") { |
| 108 | + return true |
| 109 | + } |
| 110 | + var view, hash string |
| 111 | + if err := t.store.DB().QueryRowContext(ctx, `SELECT view,hash FROM custom_view_artifacts WHERE event_id=?`, e.ID).Scan(&view, &hash); err != nil { |
| 112 | + return false |
| 113 | + } |
| 114 | + if d != "bind.ws/view/"+view+"/"+hash { |
| 115 | + return false |
| 116 | + } |
| 117 | + refs := map[string]bool{} |
| 118 | + for _, tag := range e.Tags { |
| 119 | + if len(tag) == 2 && (tag[0] == "e" || tag[0] == "a") { |
| 120 | + refs[tag[1]] = true |
| 121 | + } |
| 122 | + } |
| 123 | + if len(refs) == 0 { |
| 124 | + return false |
| 125 | + } |
| 126 | + sources, ok := t.artifactSourceRows(ctx, view, hash) |
| 127 | + if !ok { |
| 128 | + return false |
| 129 | + } |
| 130 | + for _, source := range sources { |
| 131 | + if !refs[source] || !t.sourceVisible(ctx, source, s, hash) { |
| 132 | + return false |
| 133 | + } |
| 134 | + delete(refs, source) |
| 135 | + } |
| 136 | + return len(sources) > 0 && len(refs) == 0 |
| 137 | +} |
| 138 | + |
| 139 | +func (t *customViewService) artifactHTTPSourceVisible(ctx context.Context, view, hash string, s relay.Session) bool { |
| 140 | + sources, ok := t.artifactSourceRows(ctx, view, hash) |
| 141 | + if !ok { |
| 142 | + return false |
| 143 | + } |
| 144 | + for _, source := range sources { |
| 145 | + if t.sourceVisible(ctx, source, s, hash) { |
| 146 | + return true |
| 147 | + } |
| 148 | + } |
| 149 | + return false |
| 150 | +} |
| 151 | + |
| 152 | +func (t *customViewService) artifactSourceRows(ctx context.Context, view, hash string) ([]string, bool) { |
| 153 | + rows, err := t.store.DB().QueryContext(ctx, `SELECT source FROM custom_view_sources WHERE view=? AND hash=?`, view, hash) |
| 154 | + if err != nil { |
| 155 | + return nil, false |
| 156 | + } |
| 157 | + defer rows.Close() |
| 158 | + var sources []string |
| 159 | + for rows.Next() { |
| 160 | + var source string |
| 161 | + if rows.Scan(&source) != nil { |
| 162 | + _ = rows.Close() |
| 163 | + return nil, false |
| 164 | + } |
| 165 | + sources = append(sources, source) |
| 166 | + } |
| 167 | + if rows.Err() != nil { |
| 168 | + return nil, false |
| 169 | + } |
| 170 | + return sources, true |
| 171 | +} |
| 172 | + |
| 173 | +func (t *customViewService) sourceVisible(ctx context.Context, source string, s relay.Session, artifactHash string) bool { |
| 174 | + if t.gate == nil { |
| 175 | + return false |
| 176 | + } |
| 177 | + var raw string |
| 178 | + if tag := viewSourceTag(source); tag[0] == "a" { |
| 179 | + parts := strings.SplitN(source, ":", 3) |
| 180 | + if len(parts) != 3 { |
| 181 | + return false |
| 182 | + } |
| 183 | + if err := t.store.DB().QueryRowContext(ctx, `SELECT raw FROM events WHERE kind=? AND pubkey=? AND d=? AND (expires=0 OR expires>?) ORDER BY created_at DESC,id ASC LIMIT 1`, event.KIND_REPO_STATE, parts[1], parts[2], time.Now().Unix()).Scan(&raw); err != nil { |
| 184 | + return false |
| 185 | + } |
| 186 | + } else if err := t.store.DB().QueryRowContext(ctx, `SELECT raw FROM events WHERE id=? AND (expires=0 OR expires>?)`, source, time.Now().Unix()).Scan(&raw); err != nil { |
| 187 | + return false |
| 188 | + } |
| 189 | + var sourceEvent event.Event |
| 190 | + if err := json.Unmarshal([]byte(raw), &sourceEvent); err != nil || !t.gate.CanSee(ctx, sourceEvent, s, nil) { |
| 191 | + return false |
| 192 | + } |
| 193 | + if strings.HasPrefix(source, strconv.Itoa(event.KIND_REPO_STATE)+":") && artifactHash != "" { |
| 194 | + blocks, err := t.sourceBlocks(ctx, sourceEvent) |
| 195 | + if err != nil { |
| 196 | + return false |
| 197 | + } |
| 198 | + for _, block := range blocks { |
| 199 | + if views.Hash(block.Lang, block.Source) == artifactHash { |
| 200 | + return true |
| 201 | + } |
| 202 | + } |
| 203 | + return false |
| 204 | + } |
| 205 | + return true |
| 206 | +} |
| 207 | diff --git a/internal/daemon/custom_views_access_test.go b/internal/daemon/custom_views_access_test.go |
| 208 | new file mode 100644 |
| 209 | index 0000000..2077ce3 |
| 210 | --- /dev/null |
| 211 | +++ b/internal/daemon/custom_views_access_test.go |
| 212 | @@ -0,0 +1,203 @@ |
| 213 | +package daemon |
| 214 | + |
| 215 | +import ( |
| 216 | + "context" |
| 217 | + "fmt" |
| 218 | + "net/http" |
| 219 | + "net/http/httptest" |
| 220 | + "os" |
| 221 | + "path/filepath" |
| 222 | + "strings" |
| 223 | + "testing" |
| 224 | + "time" |
| 225 | + |
| 226 | + "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 227 | + "github.com/FelineStateMachine/tinyrelay/internal/relay" |
| 228 | + "github.com/FelineStateMachine/tinyrelay/internal/views" |
| 229 | +) |
| 230 | + |
| 231 | +func TestCustomViewArtifactSourceVisibilityCoversMediaAndEventReads(t *testing.T) { |
| 232 | + tenant, server, _ := viewTenant(t, []int{1}, nil) |
| 233 | + server.answer(http.StatusOK, svgArtifact(0)) |
| 234 | + first := signedEvent(t, testOwnerSecret, 1, time.Now().Unix(), nil, "```mermaid\ngraph TD; a-->b;\n```") |
| 235 | + if err := publishAs(t, tenant, first); err != nil { |
| 236 | + t.Fatal(err) |
| 237 | + } |
| 238 | + runPendingView(t, tenant, "diagrams", first.ID) |
| 239 | + second := signedEvent(t, testOwnerSecret, 1, time.Now().Unix()+1, nil, first.Content) |
| 240 | + if err := publishAs(t, tenant, second); err != nil { |
| 241 | + t.Fatal(err) |
| 242 | + } |
| 243 | + runPendingView(t, tenant, "diagrams", second.ID) |
| 244 | + ctx := context.Background() |
| 245 | + if _, err := tenant.store.DB().ExecContext(ctx, `INSERT INTO hidden_events(id,reason) VALUES(?,'test')`, first.ID); err != nil { |
| 246 | + t.Fatal(err) |
| 247 | + } |
| 248 | + _, image, ok := strings.Cut(views.Figure("diagrams", "mermaid", "graph TD; a-->b;"), `<img src="`) |
| 249 | + if !ok { |
| 250 | + t.Fatal("custom view has no image") |
| 251 | + } |
| 252 | + path, _, _ := strings.Cut(image, `"`) |
| 253 | + if response := getArtifact(t, tenant, path, ""); response.Code != http.StatusOK { |
| 254 | + t.Fatalf("visible source should serve shared media: %d", response.Code) |
| 255 | + } |
| 256 | + hash := views.Hash("mermaid", "graph TD; a-->b;") |
| 257 | + rows, err := tenant.Query(ctx, []event.Filter{{Kinds: []int{event.KIND_VIEW}, Tags: map[string][]string{"d": {"bind.ws/view/diagrams/" + hash}}}}, relay.Session{}) |
| 258 | + if err != nil { |
| 259 | + t.Fatal(err) |
| 260 | + } |
| 261 | + if len(rows) != 0 { |
| 262 | + t.Fatalf("artifact event leaked a hidden source: %d", len(rows)) |
| 263 | + } |
| 264 | +} |
| 265 | + |
| 266 | +func TestCustomViewArtifactSourceLifecycleBlocksMediaAndEventReads(t *testing.T) { |
| 267 | + tests := []struct { |
| 268 | + name string |
| 269 | + mutate func(context.Context, *Tenant, event.Event) error |
| 270 | + }{ |
| 271 | + {name: "hidden", mutate: func(ctx context.Context, tenant *Tenant, source event.Event) error { |
| 272 | + _, err := tenant.store.DB().ExecContext(ctx, `INSERT INTO hidden_events(id,reason) VALUES(?,'test')`, source.ID) |
| 273 | + return err |
| 274 | + }}, |
| 275 | + {name: "deleted", mutate: func(ctx context.Context, tenant *Tenant, source event.Event) error { |
| 276 | + _, err := tenant.store.DB().ExecContext(ctx, `DELETE FROM events WHERE id=?`, source.ID) |
| 277 | + return err |
| 278 | + }}, |
| 279 | + {name: "expired", mutate: func(ctx context.Context, tenant *Tenant, source event.Event) error { |
| 280 | + _, err := tenant.store.DB().ExecContext(ctx, `UPDATE events SET expires=? WHERE id=?`, time.Now().Unix()-1, source.ID) |
| 281 | + return err |
| 282 | + }}, |
| 283 | + } |
| 284 | + for _, tc := range tests { |
| 285 | + t.Run(tc.name, func(t *testing.T) { |
| 286 | + tenant, server, _ := viewTenant(t, []int{1}, nil) |
| 287 | + server.answer(http.StatusOK, svgArtifact(0)) |
| 288 | + source := signedEvent(t, testOwnerSecret, 1, time.Now().Unix(), nil, "```mermaid\nA\n```") |
| 289 | + if err := publishAs(t, tenant, source); err != nil { |
| 290 | + t.Fatal(err) |
| 291 | + } |
| 292 | + runPendingView(t, tenant, "diagrams", source.ID) |
| 293 | + hash := views.Hash("mermaid", "A") |
| 294 | + path := "/views/diagrams/" + hash + ".svg" |
| 295 | + if response := getArtifact(t, tenant, path, ""); response.Code != http.StatusOK { |
| 296 | + t.Fatalf("baseline artifact: %d", response.Code) |
| 297 | + } |
| 298 | + if err := tc.mutate(context.Background(), tenant, source); err != nil { |
| 299 | + t.Fatal(err) |
| 300 | + } |
| 301 | + if response := getArtifact(t, tenant, path, ""); response.Code != http.StatusNotFound { |
| 302 | + t.Fatalf("inaccessible source served media: %d", response.Code) |
| 303 | + } |
| 304 | + rows, err := tenant.Query(context.Background(), []event.Filter{{Kinds: []int{event.KIND_VIEW}, Tags: map[string][]string{"d": {"bind.ws/view/diagrams/" + hash}}}}, relay.Session{}) |
| 305 | + if err != nil { |
| 306 | + t.Fatal(err) |
| 307 | + } |
| 308 | + if len(rows) != 0 { |
| 309 | + t.Fatalf("inaccessible source leaked artifact event: %d", len(rows)) |
| 310 | + } |
| 311 | + }) |
| 312 | + } |
| 313 | +} |
| 314 | + |
| 315 | +func TestCustomViewDoesNotTransformExpiredQueuedSource(t *testing.T) { |
| 316 | + tenant, server, _ := viewTenant(t, []int{1}, nil) |
| 317 | + server.answer(http.StatusOK, svgArtifact(0)) |
| 318 | + now := time.Now().Unix() |
| 319 | + source := signedEvent(t, testOwnerSecret, 1, now, [][]string{{"expiration", fmt.Sprint(now + 3600)}}, "```mermaid\nA\n```") |
| 320 | + if err := publishAs(t, tenant, source); err != nil { |
| 321 | + t.Fatal(err) |
| 322 | + } |
| 323 | + queued := pendingViewIntents(t, tenant, "diagrams", source.ID) |
| 324 | + if len(queued) != 1 { |
| 325 | + t.Fatalf("queued intents = %d", len(queued)) |
| 326 | + } |
| 327 | + if _, err := tenant.store.DB().ExecContext(context.Background(), `UPDATE events SET expires=? WHERE id=?`, now-1, source.ID); err != nil { |
| 328 | + t.Fatal(err) |
| 329 | + } |
| 330 | + runViewIntent(t, tenant, queued[0]) |
| 331 | + if server.count() != 0 { |
| 332 | + t.Fatalf("expired source was sent to transform: %d requests", server.count()) |
| 333 | + } |
| 334 | +} |
| 335 | + |
| 336 | +func TestCustomViewArtifactCoordinateRequiresCurrentMatchingSource(t *testing.T) { |
| 337 | + tenant, server, _ := viewTenant(t, []int{event.KIND_REPO_STATE}, nil) |
| 338 | + ctx := context.Background() |
| 339 | + p := tenant.Policy() |
| 340 | + p.Features.Grasp = true |
| 341 | + if err := tenant.applyPolicy(ctx, p); err != nil { |
| 342 | + t.Fatal(err) |
| 343 | + } |
| 344 | + announcement := signedEvent(t, testOwnerSecret, event.KIND_REPO, time.Now().Unix(), [][]string{{"d", "docs"}, {"clone", "http://127.0.0.1:8080/npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d/docs.git"}, {"relays", "ws://127.0.0.1:8080"}}, "") |
| 345 | + if err := publishAs(t, tenant, announcement); err != nil { |
| 346 | + t.Fatal(err) |
| 347 | + } |
| 348 | + source := filepath.Join(t.TempDir(), "source.git") |
| 349 | + gitTest(t, "init", "--bare", source) |
| 350 | + work := filepath.Join(t.TempDir(), "work") |
| 351 | + gitTest(t, "clone", source, work) |
| 352 | + gitTest(t, "-C", work, "config", "user.email", "test@example.com") |
| 353 | + gitTest(t, "-C", work, "config", "user.name", "Test") |
| 354 | + if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("# Docs\n\n```mermaid\ngraph LR; a-->b;\n```\n"), 0o600); err != nil { |
| 355 | + t.Fatal(err) |
| 356 | + } |
| 357 | + gitTest(t, "-C", work, "add", "README.md") |
| 358 | + gitTest(t, "-C", work, "commit", "-m", "readme") |
| 359 | + gitTest(t, "-C", work, "branch", "-M", "main") |
| 360 | + sha := strings.TrimSpace(gitOutput(t, "-C", work, "rev-parse", "HEAD")) |
| 361 | + state := signedEvent(t, testOwnerSecret, event.KIND_REPO_STATE, time.Now().Unix(), [][]string{{"d", "docs"}, {"HEAD", "ref: refs/heads/main"}, {"refs/heads/main", sha}}, "") |
| 362 | + if err := publishAs(t, tenant, state); err != nil { |
| 363 | + t.Fatal(err) |
| 364 | + } |
| 365 | + relayServer := httptest.NewServer(tenant) |
| 366 | + defer relayServer.Close() |
| 367 | + gitTest(t, "-C", work, "push", relayServer.URL+"/npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d/docs.git", "refs/heads/main:refs/heads/main") |
| 368 | + queued := pendingViewIntents(t, tenant, "diagrams", state.ID) |
| 369 | + if len(queued) != 1 { |
| 370 | + t.Fatalf("state intents = %d", len(queued)) |
| 371 | + } |
| 372 | + server.answer(http.StatusOK, svgArtifact(0)) |
| 373 | + runViewIntent(t, tenant, queued[0]) |
| 374 | + hash := views.Hash("mermaid", "graph LR; a-->b;") |
| 375 | + path := "/views/diagrams/" + hash + ".svg" |
| 376 | + if blocks, blockErr := tenant.customViews.sourceBlocks(ctx, state); blockErr != nil { |
| 377 | + t.Fatalf("initial source blocks: %v", blockErr) |
| 378 | + } else if len(blocks) != 1 { |
| 379 | + t.Fatalf("initial source blocks = %+v", blocks) |
| 380 | + } |
| 381 | + if response := getArtifact(t, tenant, path, ""); response.Code != http.StatusOK { |
| 382 | + t.Fatalf("artifact through current state: %d", response.Code) |
| 383 | + } |
| 384 | + rows, err := tenant.Query(ctx, []event.Filter{{Kinds: []int{event.KIND_VIEW}, Tags: map[string][]string{"d": {"bind.ws/view/diagrams/" + hash}}}}, relay.Session{}) |
| 385 | + if err != nil { |
| 386 | + t.Fatal(err) |
| 387 | + } |
| 388 | + if len(rows) != 1 { |
| 389 | + t.Fatalf("artifact query through current state = %d, want 1", len(rows)) |
| 390 | + } |
| 391 | + if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("# Docs\n\nNo diagrams here.\n"), 0o600); err != nil { |
| 392 | + t.Fatal(err) |
| 393 | + } |
| 394 | + gitTest(t, "-C", work, "add", "README.md") |
| 395 | + gitTest(t, "-C", work, "commit", "-m", "remove diagram") |
| 396 | + newSHA := strings.TrimSpace(gitOutput(t, "-C", work, "rev-parse", "HEAD")) |
| 397 | + replacement := signedEvent(t, testOwnerSecret, event.KIND_REPO_STATE, time.Now().Unix()+1, [][]string{{"d", "docs"}, {"HEAD", "ref: refs/heads/main"}, {"refs/heads/main", newSHA}}, "") |
| 398 | + if err := publishAs(t, tenant, replacement); err != nil { |
| 399 | + t.Fatal(err) |
| 400 | + } |
| 401 | + gitTest(t, "-C", work, "push", relayServer.URL+"/npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d/docs.git", "refs/heads/main:refs/heads/main") |
| 402 | + if _, ok := storedArtifact(t, tenant, "diagrams", hash); !ok { |
| 403 | + t.Fatal("replacement removed the artifact before the access check") |
| 404 | + } |
| 405 | + if response := getArtifact(t, tenant, path, ""); response.Code != http.StatusNotFound { |
| 406 | + t.Fatalf("stale coordinate served media: %d", response.Code) |
| 407 | + } |
| 408 | + rows, err = tenant.Query(ctx, []event.Filter{{Kinds: []int{event.KIND_VIEW}, Tags: map[string][]string{"d": {"bind.ws/view/diagrams/" + hash}}}}, relay.Session{}) |
| 409 | + if err != nil { |
| 410 | + t.Fatal(err) |
| 411 | + } |
| 412 | + if len(rows) != 0 { |
| 413 | + t.Fatalf("stale coordinate leaked artifact event: %d", len(rows)) |
| 414 | + } |
| 415 | +} |
| 416 | diff --git a/internal/daemon/custom_views_http.go b/internal/daemon/custom_views_http.go |
| 417 | index 03cb22f..74bd870 100644 |
| 418 | --- a/internal/daemon/custom_views_http.go |
| 419 | +++ b/internal/daemon/custom_views_http.go |
| 420 | @@ -1,8 +1,7 @@ |
| 421 | package daemon |
| 422 | |
| 423 | // GET /views/<name>/<hash>[.<svg|png>] serves a custom view artifact under a |
| 424 | -// sandbox policy. A members-only view answers only members; a public one |
| 425 | -// follows the relay's read rule. |
| 426 | +// sandbox policy. Reads require both the view's audience and a visible source. |
| 427 | |
| 428 | import ( |
| 429 | "crypto/sha256" |
| 430 | @@ -11,6 +10,7 @@ import ( |
| 431 | "strconv" |
| 432 | "strings" |
| 433 | |
| 434 | + "github.com/FelineStateMachine/tinyrelay/internal/relay" |
| 435 | "github.com/FelineStateMachine/tinyrelay/internal/views" |
| 436 | ) |
| 437 | |
| 438 | @@ -84,6 +84,11 @@ func (t *customViewService) viewArtifactHTTP(w http.ResponseWriter, r *http.Requ |
| 439 | outcome = "unauthorized" |
| 440 | return |
| 441 | } |
| 442 | + if !t.artifactHTTPSourceVisible(ctx, name, hash, relay.Session{PubKeys: []string{actor}, RelayURL: t.publicURL}) { |
| 443 | + http.NotFound(w, r) |
| 444 | + outcome = "invalid" |
| 445 | + return |
| 446 | + } |
| 447 | body, err := record.body() |
| 448 | if err != nil { |
| 449 | http.Error(w, "artifact is unreadable", http.StatusInternalServerError) |
| 450 | @@ -96,11 +101,7 @@ func (t *customViewService) viewArtifactHTTP(w http.ResponseWriter, r *http.Requ |
| 451 | etagBytes := sha256.Sum256(body) |
| 452 | etag := `"` + hex.EncodeToString(etagBytes[:]) + `"` |
| 453 | w.Header().Set("ETag", etag) |
| 454 | - if record.Audience == "members" { |
| 455 | - w.Header().Set("Cache-Control", "private, no-cache") |
| 456 | - } else { |
| 457 | - w.Header().Set("Cache-Control", "public, no-cache") |
| 458 | - } |
| 459 | + w.Header().Set("Cache-Control", "private, no-cache") |
| 460 | outcome = "ok" |
| 461 | if r.Header.Get("If-None-Match") == etag { |
| 462 | w.WriteHeader(http.StatusNotModified) |
| 463 | diff --git a/internal/daemon/custom_views_test.go b/internal/daemon/custom_views_test.go |
| 464 | index 480e6a9..14679c6 100644 |
| 465 | --- a/internal/daemon/custom_views_test.go |
| 466 | +++ b/internal/daemon/custom_views_test.go |
| 467 | @@ -419,7 +419,7 @@ func TestCustomViewTransformSendsBlocksAndKeepsSignedArtifacts(t *testing.T) { |
| 468 | |
| 469 | // The route serves each artifact under a sandbox, by its own type. |
| 470 | res := getArtifact(t, tenant, "/views/diagrams/"+svgHash+".svg", "") |
| 471 | - if res.Code != http.StatusOK || res.Body.String() != testSVG || res.Header().Get("Content-Type") != "image/svg+xml" || res.Header().Get("Content-Security-Policy") != "sandbox; default-src 'none'; style-src 'unsafe-inline'" || res.Header().Get("X-Content-Type-Options") != "nosniff" || res.Header().Get("Cache-Control") != "public, no-cache" || res.Header().Get("ETag") == "" { |
| 472 | + if res.Code != http.StatusOK || res.Body.String() != testSVG || res.Header().Get("Content-Type") != "image/svg+xml" || res.Header().Get("Content-Security-Policy") != "sandbox; default-src 'none'; style-src 'unsafe-inline'" || res.Header().Get("X-Content-Type-Options") != "nosniff" || res.Header().Get("Cache-Control") != "private, no-cache" || res.Header().Get("ETag") == "" { |
| 473 | t.Fatalf("svg route: %d %v %s", res.Code, res.Header(), res.Body.String()) |
| 474 | } |
| 475 | conditional := httptest.NewRequest(http.MethodGet, "http://127.0.0.1:8080/views/diagrams/"+svgHash+".svg", nil) |
| 476 | diff --git a/internal/daemon/custom_views_transform.go b/internal/daemon/custom_views_transform.go |
| 477 | index d079e80..de2c440 100644 |
| 478 | --- a/internal/daemon/custom_views_transform.go |
| 479 | +++ b/internal/daemon/custom_views_transform.go |
| 480 | @@ -172,9 +172,9 @@ func (t *customViewService) sourceCurrent(ctx context.Context, e event.Event) (b |
| 481 | var id string |
| 482 | var err error |
| 483 | if e.Kind == event.KIND_REPO_STATE { |
| 484 | - err = t.store.DB().QueryRowContext(ctx, `SELECT id FROM events WHERE kind=? AND pubkey=? AND d=?`, e.Kind, e.PubKey, event.Tag(e, "d")).Scan(&id) |
| 485 | + err = t.store.DB().QueryRowContext(ctx, `SELECT id FROM events WHERE kind=? AND pubkey=? AND d=? AND (expires=0 OR expires>?) ORDER BY created_at DESC,id ASC LIMIT 1`, e.Kind, e.PubKey, event.Tag(e, "d"), time.Now().Unix()).Scan(&id) |
| 486 | } else { |
| 487 | - err = t.store.DB().QueryRowContext(ctx, `SELECT id FROM events WHERE id=?`, e.ID).Scan(&id) |
| 488 | + err = t.store.DB().QueryRowContext(ctx, `SELECT id FROM events WHERE id=? AND (expires=0 OR expires>?)`, e.ID, time.Now().Unix()).Scan(&id) |
| 489 | } |
| 490 | if errors.Is(err, sql.ErrNoRows) { |
| 491 | return false, nil |
| 492 | @@ -612,7 +612,7 @@ func (t *customViewService) sourcePresent(ctx context.Context, source string) (b |
| 493 | if len(parts) != 3 { |
| 494 | return false, nil |
| 495 | } |
| 496 | - err = t.store.DB().QueryRowContext(ctx, `SELECT 1 FROM events WHERE kind=? AND pubkey=? AND d=? LIMIT 1`, event.KIND_REPO_STATE, parts[1], parts[2]).Scan(&one) |
| 497 | + err = t.store.DB().QueryRowContext(ctx, `SELECT 1 FROM events WHERE kind=? AND pubkey=? AND d=? AND (expires=0 OR expires>?) LIMIT 1`, event.KIND_REPO_STATE, parts[1], parts[2], time.Now().Unix()).Scan(&one) |
| 498 | } else { |
| 499 | err = t.store.DB().QueryRowContext(ctx, `SELECT 1 FROM events WHERE id=? AND (expires=0 OR expires>?) LIMIT 1`, source, time.Now().Unix()).Scan(&one) |
| 500 | } |
| 501 | diff --git a/internal/daemon/services.go b/internal/daemon/services.go |
| 502 | index 875f697..9b1e2a8 100644 |
| 503 | --- a/internal/daemon/services.go |
| 504 | +++ b/internal/daemon/services.go |
| 505 | @@ -153,7 +153,7 @@ func (t *Tenant) initServices(ctx context.Context) error { |
| 506 | if err := t.reconcileAutomaticInbox(ctx, currentPolicy, currentPolicy); err != nil { |
| 507 | return err |
| 508 | } |
| 509 | - t.customViews = newCustomViewService(customViewServiceConfig{Store: t.store, Community: t.community, Records: t.records, Git: t.git, Telemetry: t.app.telemetry, TenantName: t.meta.Name, PublicURL: t.publicURL, Loopback: func() bool { return loopbackPublicURL(t.publicURL) }, ResolveActor: t.resolveUIActor, BrowseRead: t.browseRead}) |
| 510 | + t.customViews = newCustomViewService(customViewServiceConfig{Store: t.store, Community: t.community, Records: t.records, Git: t.git, Gate: t.gate, Telemetry: t.app.telemetry, TenantName: t.meta.Name, PublicURL: t.publicURL, Loopback: func() bool { return loopbackPublicURL(t.publicURL) }, ResolveActor: t.resolveUIActor, BrowseRead: t.browseRead}) |
| 511 | if err := t.customViews.initCustomViews(ctx); err != nil { |
| 512 | return err |
| 513 | } |
| 514 | diff --git a/internal/daemon/tenant.go b/internal/daemon/tenant.go |
| 515 | index 219581e..0598cfb 100644 |
| 516 | --- a/internal/daemon/tenant.go |
| 517 | +++ b/internal/daemon/tenant.go |
| 518 | @@ -104,7 +104,9 @@ func newTenant(ctx context.Context, cfg tenantConfig) (*Tenant, error) { |
| 519 | if err := t.community.EnsureSlugRoom(ctx, t.meta.Name, community.RoomAccessFor(p.Reads)); err != nil { |
| 520 | return nil, err |
| 521 | } |
| 522 | - t.gate, err = gates.New(gates.Config{Store: t.store, Community: t.community, Policy: t.Policy, Slug: t.meta.Name}) |
| 523 | + t.gate, err = gates.New(gates.Config{Store: t.store, Community: t.community, Policy: t.Policy, Slug: t.meta.Name, ArtifactVisible: func(ctx context.Context, e event.Event, s relay.Session) bool { |
| 524 | + return t.customViews != nil && t.customViews.artifactEventVisible(ctx, e, s) |
| 525 | + }}) |
| 526 | if err != nil { |
| 527 | return nil, err |
| 528 | } |
| 529 | diff --git a/internal/gates/gates.go b/internal/gates/gates.go |
| 530 | index 62d481c..27053d5 100644 |
| 531 | --- a/internal/gates/gates.go |
| 532 | +++ b/internal/gates/gates.go |
| 533 | @@ -18,10 +18,11 @@ import ( |
| 534 | // Config supplies the policy and optional durable services used by Gate. |
| 535 | // Store and Community enable checks that depend on persisted relay state. |
| 536 | type Config struct { |
| 537 | - Store *storage.Store |
| 538 | - Community *community.Service |
| 539 | - Policy func() policy.Policy |
| 540 | - Slug string |
| 541 | + Store *storage.Store |
| 542 | + Community *community.Service |
| 543 | + Policy func() policy.Policy |
| 544 | + Slug string |
| 545 | + ArtifactVisible func(context.Context, event.Event, relay.Session) bool |
| 546 | } |
| 547 | |
| 548 | // Gate evaluates event admission and visibility for one tenant configuration. |
| 549 | @@ -29,6 +30,11 @@ type Gate struct { |
| 550 | cfg Config |
| 551 | agents agentLimiter |
| 552 | } |
| 553 | +type artifactCheckContextKey struct{} |
| 554 | +type artifactCheckState struct { |
| 555 | + seen map[string]struct{} |
| 556 | + depth int |
| 557 | +} |
| 558 | |
| 559 | // agentLimiter is the per-agent sliding one-minute counter. It lives in |
| 560 | // memory: a restart forgives the last minute, which is the cheap and honest |
| 561 | @@ -692,6 +698,24 @@ func (g *Gate) Read(ctx context.Context, filters []event.Filter, s relay.Session |
| 562 | // ban, repository privacy, policy, room and recipient checks. |
| 563 | func (g *Gate) CanSee(ctx context.Context, e event.Event, s relay.Session, f *event.Filter) bool { |
| 564 | p := g.cfg.Policy() |
| 565 | + if e.Kind == event.KIND_VIEW && g.cfg.ArtifactVisible != nil { |
| 566 | + state, _ := ctx.Value(artifactCheckContextKey{}).(artifactCheckState) |
| 567 | + if state.seen == nil { |
| 568 | + state.seen = map[string]struct{}{} |
| 569 | + } |
| 570 | + if _, cycle := state.seen[e.ID]; cycle || state.depth >= 32 { |
| 571 | + return false |
| 572 | + } |
| 573 | + seen := make(map[string]struct{}, len(state.seen)+1) |
| 574 | + for id := range state.seen { |
| 575 | + seen[id] = struct{}{} |
| 576 | + } |
| 577 | + seen[e.ID] = struct{}{} |
| 578 | + ctx = context.WithValue(ctx, artifactCheckContextKey{}, artifactCheckState{seen: seen, depth: state.depth + 1}) |
| 579 | + if !g.cfg.ArtifactVisible(ctx, e, s) { |
| 580 | + return false |
| 581 | + } |
| 582 | + } |
| 583 | if community.IsAgentGrantRequest(e) { |
| 584 | operator := event.Tag(e, "p") |
| 585 | if !contains(s.PubKeys, e.PubKey) && !contains(s.PubKeys, operator) { |
| 586 | diff --git a/internal/gates/signer_test.go b/internal/gates/signer_test.go |
| 587 | index 90ccb3b..60d19ab 100644 |
| 588 | --- a/internal/gates/signer_test.go |
| 589 | +++ b/internal/gates/signer_test.go |
| 590 | @@ -40,3 +40,31 @@ func TestSignerRepliesReachUnauthenticatedRecipientSubscription(t *testing.T) { |
| 591 | }) |
| 592 | } |
| 593 | } |
| 594 | + |
| 595 | +func TestArtifactVisibilityTracksNestedSourcesAndCycles(t *testing.T) { |
| 596 | + p := policy.Defaults(strings.Repeat("a", 64)) |
| 597 | + nodes := map[string]event.Event{ |
| 598 | + "a": {ID: "a", Kind: event.KIND_VIEW}, |
| 599 | + "b": {ID: "b", Kind: event.KIND_VIEW}, |
| 600 | + } |
| 601 | + refs := map[string][]string{"a": {"b"}, "b": {"hidden"}} |
| 602 | + var gate *Gate |
| 603 | + gate, _ = New(Config{Policy: func() policy.Policy { return p }, ArtifactVisible: func(ctx context.Context, e event.Event, s relay.Session) bool { |
| 604 | + for _, id := range refs[e.ID] { |
| 605 | + if id == "hidden" { |
| 606 | + return false |
| 607 | + } |
| 608 | + if !gate.CanSee(ctx, nodes[id], s, nil) { |
| 609 | + return false |
| 610 | + } |
| 611 | + } |
| 612 | + return true |
| 613 | + }}) |
| 614 | + if gate.CanSee(context.Background(), nodes["a"], relay.Session{}, nil) { |
| 615 | + t.Fatal("nested artifact with hidden source became visible") |
| 616 | + } |
| 617 | + refs["b"] = []string{"a"} |
| 618 | + if gate.CanSee(context.Background(), nodes["a"], relay.Session{}, nil) { |
| 619 | + t.Fatal("cyclic artifact source became visible") |
| 620 | + } |
| 621 | +} |