tinyrelaycommit

Commit 73dc995c4300

browse files at this commit | history

1diff --git a/docs/extensions/custom-view-transforms.md b/docs/extensions/custom-view-transforms.md
2index 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
27diff --git a/docs/views.md b/docs/views.md
28index 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
40diff --git a/internal/daemon/custom_views.go b/internal/daemon/custom_views.go
41index 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
82diff --git a/internal/daemon/custom_views_access.go b/internal/daemon/custom_views_access.go
83new file mode 100644
84index 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+}
207diff --git a/internal/daemon/custom_views_access_test.go b/internal/daemon/custom_views_access_test.go
208new file mode 100644
209index 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+}
416diff --git a/internal/daemon/custom_views_http.go b/internal/daemon/custom_views_http.go
417index 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)
463diff --git a/internal/daemon/custom_views_test.go b/internal/daemon/custom_views_test.go
464index 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)
476diff --git a/internal/daemon/custom_views_transform.go b/internal/daemon/custom_views_transform.go
477index 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 }
501diff --git a/internal/daemon/services.go b/internal/daemon/services.go
502index 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 }
514diff --git a/internal/daemon/tenant.go b/internal/daemon/tenant.go
515index 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 }
529diff --git a/internal/gates/gates.go b/internal/gates/gates.go
530index 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) {
586diff --git a/internal/gates/signer_test.go b/internal/gates/signer_test.go
587index 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+}

About

ownerc01503e8035f
visibilitypublic
head73dc995c4300

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs