tinyrelaycommit

Commit c2bdcb3f3d21

browse files at this commit | history

1diff --git a/internal/daemon/custom_views.go b/internal/daemon/custom_views.go
2index 9f0976f..6ecf63b 100644
3--- a/internal/daemon/custom_views.go
4+++ b/internal/daemon/custom_views.go
5@@ -422,7 +422,7 @@ func (t *Tenant) customViewRows(ctx context.Context, names ...string) ([]customV
6 query += ` WHERE name=?`
7 args = append(args, names[0])
8 }
9- query += ` ORDER BY created_at,name`
10+ query += ` ORDER BY created_at,rowid`
11 rows, err := t.store.DB().QueryContext(ctx, query, args...)
12 if err != nil {
13 return nil, fmt.Errorf("list custom views: %w", err)
14@@ -505,7 +505,7 @@ func (t *Tenant) queueCustomViews(ctx context.Context, e event.Event) {
15 if len(candidates) == 0 {
16 return
17 }
18- if e.Kind == event.KIND_REPO_STATE && (t.git == nil || t.git.IsPending(e.ID)) {
19+ if e.Kind == event.KIND_REPO_STATE && (t.git == nil || t.statePending(ctx, e.ID)) {
20 // The README is read from the objects, which have not arrived yet;
21 // releaseGit queues the state once they have.
22 return
23@@ -545,6 +545,14 @@ func (t *Tenant) viewIntent(view customView, e event.Event, run string) storage.
24 return storage.Intent{Kind: viewTransform, EventID: id, Target: view.Name, Payload: string(payload)}
25 }
26
27+// statePending reports whether a repository state still waits for its
28+// objects.
29+func (t *Tenant) statePending(ctx context.Context, id string) bool {
30+ var one int
31+ err := t.store.DB().QueryRowContext(ctx, `SELECT 1 FROM pending_events WHERE id=?`, id).Scan(&one)
32+ return err == nil || t.git.IsPending(id)
33+}
34+
35 // queueCustomViewBackfill queues the newest sources of a view's kinds, at
36 // most 500, written since the given time. Repository states are queued
37 // whether or not their README has blocks; the handler reads it.
38@@ -559,7 +567,7 @@ func (t *Tenant) queueCustomViewBackfill(ctx context.Context, view customView, s
39 continue
40 }
41 if e.Kind == event.KIND_REPO_STATE {
42- if t.git == nil || t.git.IsPending(e.ID) {
43+ if t.git == nil || t.statePending(ctx, e.ID) {
44 continue
45 }
46 } else if len(views.Matching(views.Blocks(e.Content), view.Languages)) == 0 {
47diff --git a/internal/daemon/custom_views_test.go b/internal/daemon/custom_views_test.go
48new file mode 100644
49index 0000000..57d155e
50--- /dev/null
51+++ b/internal/daemon/custom_views_test.go
52@@ -0,0 +1,944 @@
53+package daemon
54+
55+import (
56+ "context"
57+ "crypto/hmac"
58+ "crypto/sha256"
59+ "encoding/base64"
60+ "encoding/hex"
61+ "encoding/json"
62+ "io"
63+ "net/http"
64+ "net/http/httptest"
65+ "os"
66+ "path/filepath"
67+ "strconv"
68+ "strings"
69+ "sync"
70+ "testing"
71+ "time"
72+
73+ "github.com/FelineStateMachine/tinyrelay/internal/community"
74+ "github.com/FelineStateMachine/tinyrelay/internal/event"
75+ "github.com/FelineStateMachine/tinyrelay/internal/views"
76+ "github.com/FelineStateMachine/tinyrelay/internal/work"
77+)
78+
79+const (
80+ testSVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10"/></svg>`
81+ testPNGRaw = "\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"
82+)
83+
84+// transformServer stands in for a view transform: it records every request
85+// and answers with the artifacts it is told to.
86+type transformServer struct {
87+ mu sync.Mutex
88+ status int
89+ body string
90+ headers []http.Header
91+ requests [][]byte
92+}
93+
94+func (s *transformServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
95+ body, _ := io.ReadAll(r.Body)
96+ s.mu.Lock()
97+ s.headers = append(s.headers, r.Header.Clone())
98+ s.requests = append(s.requests, body)
99+ status, answer := s.status, s.body
100+ s.mu.Unlock()
101+ w.Header().Set("Content-Type", "application/json")
102+ w.WriteHeader(status)
103+ _, _ = w.Write([]byte(answer))
104+}
105+
106+func (s *transformServer) count() int {
107+ s.mu.Lock()
108+ defer s.mu.Unlock()
109+ return len(s.requests)
110+}
111+
112+func (s *transformServer) answer(status int, artifacts ...map[string]any) {
113+ s.mu.Lock()
114+ defer s.mu.Unlock()
115+ s.status = status
116+ raw, _ := json.Marshal(map[string]any{"artifacts": artifacts, "errors": []map[string]any{{"block": 9, "error": "unknown"}}})
117+ s.body = string(raw)
118+}
119+
120+func svgArtifact(block int) map[string]any {
121+ return map[string]any{"block": block, "type": "image/svg+xml", "body": testSVG, "engine": "mermaid"}
122+}
123+
124+func viewParams(values map[string]any) []json.RawMessage {
125+ raw, _ := json.Marshal(values)
126+ return []json.RawMessage{raw}
127+}
128+
129+func addView(t *testing.T, tenant *Tenant, values map[string]any) map[string]any {
130+ t.Helper()
131+ result, err := tenant.Execute(context.Background(), tenant.Policy().Owner, "addcustomview", viewParams(values))
132+ if err != nil {
133+ t.Fatalf("addcustomview: %v", err)
134+ }
135+ return result.(map[string]any)
136+}
137+
138+// viewTenant is a loopback tenant with a transform server and one view that
139+// watches the given kinds.
140+func viewTenant(t *testing.T, kinds []int, extra map[string]any) (*Tenant, *transformServer, map[string]any) {
141+ t.Helper()
142+ _, tenant := loopbackTenant(t)
143+ server := &transformServer{}
144+ server.answer(http.StatusOK)
145+ service := httptest.NewTLSServer(server)
146+ t.Cleanup(service.Close)
147+ tenant.viewClient = service.Client()
148+ values := map[string]any{"name": "diagrams", "kinds": kinds, "transform": service.URL + "/render?token=t0k3n", "languages": []string{"mermaid", "dot"}}
149+ for key, value := range extra {
150+ values[key] = value
151+ }
152+ return tenant, server, addView(t, tenant, values)
153+}
154+
155+// pendingViewIntents lists queued transforms of one source for one view,
156+// first attempt first.
157+func pendingViewIntents(t *testing.T, tenant *Tenant, name, eventID string) []work.Intent {
158+ t.Helper()
159+ rows, err := tenant.store.DB().QueryContext(context.Background(), "SELECT id,event_id,payload,next_at FROM work_intents WHERE kind=? AND target=? AND state='pending' AND (event_id=? OR event_id LIKE ? OR event_id LIKE ?) ORDER BY next_at,created_at,id", viewTransform, name, eventID, eventID+"#%", eventID+"@%")
160+ if err != nil {
161+ t.Fatal(err)
162+ }
163+ defer rows.Close()
164+ var out []work.Intent
165+ for rows.Next() {
166+ var item work.Intent
167+ var next int64
168+ if err := rows.Scan(&item.ID, &item.EventID, &item.Payload, &next); err != nil {
169+ t.Fatal(err)
170+ }
171+ item.Kind, item.Target, item.NextAt = viewTransform, name, time.Unix(next, 0)
172+ out = append(out, item)
173+ }
174+ return out
175+}
176+
177+// runViewIntent drives one intent the way the worker would and marks it
178+// complete.
179+func runViewIntent(t *testing.T, tenant *Tenant, intent work.Intent) {
180+ t.Helper()
181+ if err := tenant.handleViewTransform(context.Background(), intent); err != nil {
182+ t.Fatalf("handleViewTransform: %v", err)
183+ }
184+ if _, err := tenant.store.DB().ExecContext(context.Background(), "UPDATE work_intents SET state='completed' WHERE id=?", intent.ID); err != nil {
185+ t.Fatal(err)
186+ }
187+}
188+
189+func runPendingView(t *testing.T, tenant *Tenant, name, eventID string) {
190+ t.Helper()
191+ queued := pendingViewIntents(t, tenant, name, eventID)
192+ if len(queued) != 1 {
193+ t.Fatalf("queued intents for %s = %d", eventID, len(queued))
194+ }
195+ runViewIntent(t, tenant, queued[0])
196+}
197+
198+func storedArtifact(t *testing.T, tenant *Tenant, name, hash string) (event.Event, bool) {
199+ t.Helper()
200+ var raw string
201+ err := tenant.store.DB().QueryRowContext(context.Background(), "SELECT raw FROM events WHERE kind=? AND pubkey=? AND d=?", event.KIND_VIEW, tenant.records.PublicKey(), "bind.ws/view/"+name+"/"+hash).Scan(&raw)
202+ if err != nil {
203+ return event.Event{}, false
204+ }
205+ e, err := event.Parse([]byte(raw))
206+ if err != nil {
207+ t.Fatal(err)
208+ }
209+ return e, true
210+}
211+
212+func artifactTags(e event.Event, name string) []string {
213+ var out []string
214+ for _, tag := range e.Tags {
215+ if len(tag) >= 2 && tag[0] == name {
216+ out = append(out, tag[1])
217+ }
218+ }
219+ return out
220+}
221+
222+func signRequestAs(t *testing.T, r *http.Request, secret string) {
223+ t.Helper()
224+ hash := sha256.Sum256(nil)
225+ e := event.Event{Kind: 27235, CreatedAt: time.Now().Unix(), Tags: [][]string{{"u", r.URL.String()}, {"method", r.Method}, {"payload", hex.EncodeToString(hash[:])}}}
226+ if err := event.Sign(&e, secret); err != nil {
227+ t.Fatal(err)
228+ }
229+ raw, _ := json.Marshal(e)
230+ r.Header.Set("Authorization", "Nostr "+base64.StdEncoding.EncodeToString(raw))
231+}
232+
233+func getArtifact(t *testing.T, tenant *Tenant, path, secret string) *httptest.ResponseRecorder {
234+ t.Helper()
235+ req := httptest.NewRequest(http.MethodGet, "http://127.0.0.1:8080"+path, nil)
236+ if secret != "" {
237+ signRequestAs(t, req, secret)
238+ }
239+ res := httptest.NewRecorder()
240+ tenant.ServeHTTP(res, req)
241+ return res
242+}
243+
244+func TestCustomViewManagementIsOwnerOnlyAndValidated(t *testing.T) {
245+ _, tenant := testTenant(t)
246+ ctx := context.Background()
247+ owner := tenant.Policy().Owner
248+ member, _ := event.PublicKey(testMemberSecret)
249+ moderator, _ := event.PublicKey(testModSecret)
250+ guest := strings.Repeat("e", 64)
251+ setRole(t, tenant, member, "member")
252+ setRole(t, tenant, moderator, "moderator")
253+ methods, err := tenant.Execute(ctx, owner, "supportedmethods", nil)
254+ if err != nil {
255+ t.Fatal(err)
256+ }
257+ all := []string{"listcustomviews", "addcustomview", "removecustomview", "runcustomview", "pausecustomview", "resumecustomview"}
258+ for _, name := range all {
259+ if !containsString(methods.([]string), name) || !containsString(ManagementMethods(), name) {
260+ t.Fatalf("registry lacks %s", name)
261+ }
262+ }
263+ base := map[string]any{"name": "diagrams", "kinds": []int{1, 30023}, "transform": "https://render.example/tiny", "languages": []string{"mermaid"}}
264+ for _, actor := range []string{guest, member, moderator} {
265+ for _, method := range all {
266+ params := []json.RawMessage{json.RawMessage(`"diagrams"`)}
267+ if method == "addcustomview" {
268+ params = viewParams(base)
269+ }
270+ if _, err := tenant.Execute(ctx, actor, method, params); err == nil || !strings.HasPrefix(err.Error(), "restricted:") {
271+ t.Fatalf("%s by non-owner: %v", method, err)
272+ }
273+ }
274+ }
275+ with := func(changes map[string]any) map[string]any {
276+ out := map[string]any{}
277+ for key, value := range base {
278+ out[key] = value
279+ }
280+ for key, value := range changes {
281+ out[key] = value
282+ }
283+ return out
284+ }
285+ many := make([]int, viewKindMax+1)
286+ for _, tc := range []struct {
287+ name string
288+ values map[string]any
289+ want string
290+ }{
291+ {"bad name", with(map[string]any{"name": "Diagrams!"}), "invalid: name"},
292+ {"long name", with(map[string]any{"name": strings.Repeat("a", 33)}), "invalid: name"},
293+ {"no kinds", with(map[string]any{"kinds": []int{}}), "invalid: kinds"},
294+ {"too many kinds", with(map[string]any{"kinds": many}), "invalid: kinds"},
295+ {"bad kind", with(map[string]any{"kinds": []int{70000}}), "invalid: kinds must be between"},
296+ {"http transform", with(map[string]any{"transform": "http://render.example/tiny"}), "invalid: transform"},
297+ {"private transform", with(map[string]any{"transform": "https://10.0.0.1/tiny"}), "invalid: transform"},
298+ {"bad trigger", with(map[string]any{"trigger": "daily"}), "invalid: trigger"},
299+ {"bad audience", with(map[string]any{"audience": "owner"}), "invalid: audience"},
300+ {"no languages", with(map[string]any{"languages": []string{}}), "invalid: languages"},
301+ {"bad language", with(map[string]any{"languages": []string{"mer maid"}}), "invalid: languages"},
302+ {"max bytes cap", with(map[string]any{"max_bytes": viewMaxBytesCap + 1}), "invalid: max_bytes"},
303+ {"zero max bytes", with(map[string]any{"max_bytes": 0}), "invalid: max_bytes"},
304+ {"short secret", with(map[string]any{"secret": "short"}), "invalid: secret"},
305+ {"control in secret", with(map[string]any{"secret": strings.Repeat("a", 15) + "\n"}), "invalid: secret"},
306+ } {
307+ if _, err := tenant.Execute(ctx, owner, "addcustomview", viewParams(tc.values)); err == nil || !strings.HasPrefix(err.Error(), tc.want) {
308+ t.Errorf("%s: err = %v, want prefix %q", tc.name, err, tc.want)
309+ }
310+ }
311+ // A plain form sends kinds and languages as text; defaults fill trigger,
312+ // audience and the size limit; the secret is generated and shown once.
313+ added := addView(t, tenant, map[string]any{"name": "diagrams", "kinds": "30023, 1, 1", "transform": "https://render.example/tiny", "languages": "Mermaid, dot"})
314+ secret, _ := added["secret"].(string)
315+ if len(secret) != 64 || added["trigger"] != "write" || added["audience"] != "public" || added["maxBytes"] != viewDefaultMaxBytes || added["host"] != "render.example" {
316+ t.Fatalf("added = %v", added)
317+ }
318+ if kinds, _ := json.Marshal(added["kinds"]); string(kinds) != "[1,30023]" {
319+ t.Fatalf("kinds = %s", kinds)
320+ }
321+ if languages, _ := json.Marshal(added["languages"]); string(languages) != `["mermaid","dot"]` {
322+ t.Fatalf("languages = %s", languages)
323+ }
324+ if _, err := tenant.Execute(ctx, owner, "addcustomview", viewParams(base)); err == nil || !strings.Contains(err.Error(), "exists") {
325+ t.Fatalf("duplicate name: %v", err)
326+ }
327+ // An owner may paste the secret the transform already holds.
328+ pasted := addView(t, tenant, with(map[string]any{"name": "charts", "trigger": "hourly", "audience": "members", "max_bytes": 2048, "secret": "shared-secret-value-1234"}))
329+ if pasted["secret"] != "shared-secret-value-1234" || pasted["trigger"] != "hourly" || pasted["audience"] != "members" || pasted["maxBytes"] != 2048 {
330+ t.Fatalf("pasted = %v", pasted)
331+ }
332+ var stored string
333+ if err := tenant.store.DB().QueryRowContext(ctx, "SELECT secret FROM custom_views WHERE name='charts'").Scan(&stored); err != nil || stored != "shared-secret-value-1234" {
334+ t.Fatalf("stored secret %q %v", stored, err)
335+ }
336+ listed, err := tenant.Execute(ctx, owner, "listcustomviews", nil)
337+ if err != nil {
338+ t.Fatal(err)
339+ }
340+ rows := listed.([]map[string]any)
341+ if len(rows) != 2 || rows[0]["name"] != "diagrams" || rows[1]["name"] != "charts" {
342+ t.Fatalf("listing = %v", rows)
343+ }
344+ for _, row := range rows {
345+ if _, leaked := row["secret"]; leaked || row["state"] != "active" {
346+ t.Fatalf("listing row = %v", row)
347+ }
348+ }
349+ paused, err := tenant.Execute(ctx, owner, "pausecustomview", []json.RawMessage{json.RawMessage(`{"name":"diagrams"}`)})
350+ if err != nil || paused.(map[string]any)["enabled"] != false || paused.(map[string]any)["state"] != "paused" {
351+ t.Fatalf("pause: %v %v", paused, err)
352+ }
353+ if _, err := tenant.Execute(ctx, owner, "runcustomview", []json.RawMessage{json.RawMessage(`"diagrams"`)}); err == nil || !strings.Contains(err.Error(), "resume") {
354+ t.Fatalf("run while paused: %v", err)
355+ }
356+ for _, view := range tenant.customViewIndex(ctx)[1] {
357+ if view.Name == "diagrams" {
358+ t.Fatal("paused view stayed in the kind index")
359+ }
360+ }
361+ resumed, err := tenant.Execute(ctx, owner, "resumecustomview", []json.RawMessage{json.RawMessage(`"diagrams"`)})
362+ if err != nil || resumed.(map[string]any)["enabled"] != true {
363+ t.Fatalf("resume: %v %v", resumed, err)
364+ }
365+ ran, err := tenant.Execute(ctx, owner, "runcustomview", []json.RawMessage{json.RawMessage(`"diagrams"`)})
366+ if err != nil || ran.(map[string]any)["queued"] != 0 {
367+ t.Fatalf("run: %v %v", ran, err)
368+ }
369+ if _, err := tenant.Execute(ctx, owner, "removecustomview", []json.RawMessage{json.RawMessage(`"missing"`)}); err == nil || !strings.HasPrefix(err.Error(), "not found:") {
370+ t.Fatalf("remove missing: %v", err)
371+ }
372+ removed, err := tenant.Execute(ctx, owner, "removecustomview", []json.RawMessage{json.RawMessage(`"charts"`)})
373+ if err != nil || removed.(map[string]any)["removed"] != true {
374+ t.Fatalf("remove: %v %v", removed, err)
375+ }
376+ audit, err := tenant.Execute(ctx, owner, "listaudit", nil)
377+ if err != nil {
378+ t.Fatal(err)
379+ }
380+ seen := map[string]bool{}
381+ for _, row := range audit.([]community.AuditRow) {
382+ seen[row.Action] = true
383+ if strings.Contains(row.Detail, "render.example") || strings.Contains(row.Target, "render.example") || strings.Contains(row.Detail, secret) {
384+ t.Fatalf("audit carries a transform URL or secret: %+v", row)
385+ }
386+ }
387+ for _, action := range all {
388+ if action != "listcustomviews" && !seen[action] {
389+ t.Fatalf("audit lacks %s: %v", action, seen)
390+ }
391+ }
392+}
393+
394+func TestCustomViewTransformSendsBlocksAndKeepsSignedArtifacts(t *testing.T) {
395+ tenant, server, added := viewTenant(t, []int{1}, nil)
396+ ctx := context.Background()
397+ secret := added["secret"].(string)
398+ server.answer(http.StatusOK, svgArtifact(0), map[string]any{"block": 2, "type": "image/png", "body": base64.StdEncoding.EncodeToString([]byte(testPNGRaw))}, map[string]any{"block": 1, "type": "image/svg+xml", "body": testSVG})
399+ now := time.Now().Unix()
400+ content := "Look:\n\n```mermaid\ngraph TD;\n A-->B;\n```\n\n```go\nfunc main() {}\n```\n\n~~~ dot\ndigraph { a -> b }\n~~~\n"
401+ note := signedEvent(t, testOwnerSecret, 1, now, [][]string{{"expiration", strconv.FormatInt(now+86400, 10)}}, content)
402+ if err := publishAs(t, tenant, note); err != nil {
403+ t.Fatal(err)
404+ }
405+ plain := signedEvent(t, testOwnerSecret, 1, now+1, nil, "no blocks here\n```go\nx\n```")
406+ if err := publishAs(t, tenant, plain); err != nil {
407+ t.Fatal(err)
408+ }
409+ if len(pendingViewIntents(t, tenant, "diagrams", plain.ID)) != 0 {
410+ t.Fatal("an event without a matching block was queued")
411+ }
412+ runPendingView(t, tenant, "diagrams", note.ID)
413+ if server.count() != 1 {
414+ t.Fatalf("requests = %d", server.count())
415+ }
416+ headers, body := server.headers[0], server.requests[0]
417+ mac := hmac.New(sha256.New, []byte(secret))
418+ mac.Write(body)
419+ if headers.Get("Content-Type") != "application/json" || headers.Get("X-Tiny-View") != "diagrams" || headers.Get("X-Tiny-Relay") != "http://127.0.0.1:8080" || headers.Get("X-Tiny-Signature") != "sha256="+hex.EncodeToString(mac.Sum(nil)) {
420+ t.Fatalf("headers = %v", headers)
421+ }
422+ var request map[string]any
423+ if err := json.Unmarshal(body, &request); err != nil {
424+ t.Fatal(err)
425+ }
426+ if _, leaked := request["event"]; leaked || request["relay"] != "http://127.0.0.1:8080" || request["view"] != "diagrams" {
427+ t.Fatalf("request = %s", body)
428+ }
429+ if source := request["source"].(map[string]any); source["id"] != note.ID || source["kind"] != float64(1) || len(source) != 2 {
430+ t.Fatalf("source = %v", source)
431+ }
432+ if strings.Contains(string(body), "Look:") || strings.Contains(string(body), tenant.Policy().Owner) {
433+ t.Fatalf("request carries event content or author: %s", body)
434+ }
435+ blocks := request["blocks"].([]any)
436+ if len(blocks) != 2 {
437+ t.Fatalf("blocks = %v", blocks)
438+ }
439+ first, third := blocks[0].(map[string]any), blocks[1].(map[string]any)
440+ if first["index"] != float64(0) || first["lang"] != "mermaid" || first["source"] != "graph TD;\n A-->B;" || third["index"] != float64(2) || third["lang"] != "dot" || third["source"] != "digraph { a -> b }" {
441+ t.Fatalf("blocks = %v", blocks)
442+ }
443+ // The SVG artifact is a stored relay-signed record attached to the note.
444+ svgHash := views.Hash("mermaid", "graph TD;\n A-->B;")
445+ artifact, ok := storedArtifact(t, tenant, "diagrams", svgHash)
446+ if !ok {
447+ t.Fatal("svg artifact was not stored")
448+ }
449+ if artifact.Content != testSVG || artifact.PubKey != tenant.records.PublicKey() || event.Tag(artifact, "view") != "diagrams" || event.Tag(artifact, "e") != note.ID || event.Tag(artifact, "block") != "0" || event.Tag(artifact, "type") != "image/svg+xml" || event.Tag(artifact, "engine") != "mermaid" || event.Tag(artifact, "expiration") != strconv.FormatInt(now+86400, 10) || len(artifact.Tags[0]) != 1 || artifact.Tags[0][0] != "-" {
450+ t.Fatalf("artifact = %+v", artifact)
451+ }
452+ pngHash := views.Hash("dot", "digraph { a -> b }")
453+ png, ok := storedArtifact(t, tenant, "diagrams", pngHash)
454+ if !ok || png.Content != base64.StdEncoding.EncodeToString([]byte(testPNGRaw)) || event.Tag(png, "type") != "image/png" || event.Tag(png, "block") != "2" || event.Tag(png, "engine") != "" {
455+ t.Fatalf("png artifact = %+v %v", png, ok)
456+ }
457+ var count int
458+ if err := tenant.store.DB().QueryRowContext(ctx, "SELECT count(*) FROM custom_view_artifacts").Scan(&count); err != nil || count != 2 {
459+ t.Fatalf("artifacts = %d %v (an artifact for an unsent block was kept)", count, err)
460+ }
461+ var lastStatus string
462+ var failures int
463+ if err := tenant.store.DB().QueryRowContext(ctx, "SELECT last_status,failures FROM custom_views WHERE name='diagrams'").Scan(&lastStatus, &failures); err != nil || lastStatus != "ok" || failures != 0 {
464+ t.Fatalf("view state %q %d %v", lastStatus, failures, err)
465+ }
466+
467+ // The route serves each artifact under a sandbox, by its own type.
468+ res := getArtifact(t, tenant, "/views/diagrams/"+svgHash+".svg", "")
469+ 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, max-age=31536000, immutable" {
470+ t.Fatalf("svg route: %d %v %s", res.Code, res.Header(), res.Body.String())
471+ }
472+ res = getArtifact(t, tenant, "/views/diagrams/"+pngHash+".png", "")
473+ if res.Code != http.StatusOK || res.Body.String() != testPNGRaw || res.Header().Get("Content-Type") != "image/png" {
474+ t.Fatalf("png route: %d %v", res.Code, res.Header())
475+ }
476+ for _, path := range []string{"/views/diagrams/" + pngHash + ".svg", "/views/diagrams/" + strings.Repeat("0", 64) + ".svg", "/views/other/" + svgHash + ".svg", "/views/diagrams/" + svgHash + ".gif", "/views/diagrams/" + svgHash, "/views/diagrams"} {
477+ if res := getArtifact(t, tenant, path, ""); res.Code != http.StatusNotFound {
478+ t.Fatalf("%s: %d", path, res.Code)
479+ }
480+ }
481+
482+ // A second note with the same block reuses the artifact: no POST, a
483+ // fresh record with both sources.
484+ second := signedEvent(t, testOwnerSecret, 1, now+2, nil, "Again:\n\n```mermaid\ngraph TD;\n A-->B;\n```\n")
485+ if err := publishAs(t, tenant, second); err != nil {
486+ t.Fatal(err)
487+ }
488+ runPendingView(t, tenant, "diagrams", second.ID)
489+ if server.count() != 1 {
490+ t.Fatalf("requests after reuse = %d", server.count())
491+ }
492+ artifact, _ = storedArtifact(t, tenant, "diagrams", svgHash)
493+ if sources := artifactTags(artifact, "e"); len(sources) != 2 || sources[0] != note.ID || sources[1] != second.ID {
494+ t.Fatalf("sources after reuse = %v", sources)
495+ }
496+ if event.Tag(artifact, "expiration") != "" {
497+ t.Fatal("artifact kept an expiration while a source without one is attached")
498+ }
499+ if blocks := artifactTags(artifact, "block"); len(blocks) != 2 || blocks[0] != "0" || blocks[1] != "0" {
500+ t.Fatalf("block tags = %v", blocks)
501+ }
502+
503+ // Deleting the first note detaches it; deleting the second removes the
504+ // artifact and its record.
505+ deletion := signedEvent(t, testOwnerSecret, event.KIND_DELETION, now+3, [][]string{{"e", note.ID}}, "")
506+ if err := publishAs(t, tenant, deletion); err != nil {
507+ t.Fatal(err)
508+ }
509+ artifact, ok = storedArtifact(t, tenant, "diagrams", svgHash)
510+ if !ok || len(artifactTags(artifact, "e")) != 1 || event.Tag(artifact, "e") != second.ID {
511+ t.Fatalf("artifact after first deletion = %+v %v", artifact, ok)
512+ }
513+ if _, ok := storedArtifact(t, tenant, "diagrams", pngHash); ok {
514+ t.Fatal("png artifact survived the deletion of its only source")
515+ }
516+ if res := getArtifact(t, tenant, "/views/diagrams/"+pngHash+".png", ""); res.Code != http.StatusNotFound {
517+ t.Fatalf("png route after deletion: %d", res.Code)
518+ }
519+ deletion = signedEvent(t, testOwnerSecret, event.KIND_DELETION, now+4, [][]string{{"e", second.ID}}, "")
520+ if err := publishAs(t, tenant, deletion); err != nil {
521+ t.Fatal(err)
522+ }
523+ if _, ok := storedArtifact(t, tenant, "diagrams", svgHash); ok {
524+ t.Fatal("svg artifact survived the deletion of its last source")
525+ }
526+ if err := tenant.store.DB().QueryRowContext(ctx, "SELECT count(*) FROM custom_view_artifacts").Scan(&count); err != nil || count != 0 {
527+ t.Fatalf("artifacts after deletions = %d %v", count, err)
528+ }
529+
530+ // Expiry: an expired source loses its artifacts at the sweep.
531+ expiring := signedEvent(t, testOwnerSecret, 1, now+5, [][]string{{"expiration", strconv.FormatInt(now+30, 10)}}, "```dot\nx\n```")
532+ if err := publishAs(t, tenant, expiring); err != nil {
533+ t.Fatal(err)
534+ }
535+ server.answer(http.StatusOK, svgArtifact(0))
536+ runPendingView(t, tenant, "diagrams", expiring.ID)
537+ expiringHash := views.Hash("dot", "x")
538+ if _, ok := storedArtifact(t, tenant, "diagrams", expiringHash); !ok {
539+ t.Fatal("expiring artifact was not stored")
540+ }
541+ if err := tenant.sweep(ctx, now+60); err != nil {
542+ t.Fatal(err)
543+ }
544+ if _, ok := storedArtifact(t, tenant, "diagrams", expiringHash); ok {
545+ t.Fatal("artifact survived its source's expiry")
546+ }
547+ if res := getArtifact(t, tenant, "/views/diagrams/"+expiringHash+".svg", ""); res.Code != http.StatusNotFound {
548+ t.Fatalf("expired route: %d", res.Code)
549+ }
550+
551+ // Removing the view removes what is left.
552+ last := signedEvent(t, testOwnerSecret, 1, now+6, nil, "```dot\ny\n```")
553+ if err := publishAs(t, tenant, last); err != nil {
554+ t.Fatal(err)
555+ }
556+ runPendingView(t, tenant, "diagrams", last.ID)
557+ if _, err := tenant.Execute(ctx, tenant.Policy().Owner, "removecustomview", []json.RawMessage{json.RawMessage(`"diagrams"`)}); err != nil {
558+ t.Fatal(err)
559+ }
560+ if err := tenant.store.DB().QueryRowContext(ctx, "SELECT count(*) FROM events WHERE kind=? AND pubkey=?", event.KIND_VIEW, tenant.records.PublicKey()).Scan(&count); err != nil || count != 0 {
561+ t.Fatalf("records after removal = %d %v", count, err)
562+ }
563+}
564+
565+func TestCustomViewReplacementReusesAndDropsBlocks(t *testing.T) {
566+ tenant, server, _ := viewTenant(t, []int{30023}, nil)
567+ ctx := context.Background()
568+ now := time.Now().Unix()
569+ server.answer(http.StatusOK, svgArtifact(0), svgArtifact(1))
570+ first := signedEvent(t, testOwnerSecret, 30023, now, [][]string{{"d", "post"}}, "```mermaid\nA\n```\n\n```mermaid\nB\n```\n")
571+ if err := publishAs(t, tenant, first); err != nil {
572+ t.Fatal(err)
573+ }
574+ runPendingView(t, tenant, "diagrams", first.ID)
575+ hashA, hashB := views.Hash("mermaid", "A"), views.Hash("mermaid", "B")
576+ if _, ok := storedArtifact(t, tenant, "diagrams", hashA); !ok {
577+ t.Fatal("A missing")
578+ }
579+ if _, ok := storedArtifact(t, tenant, "diagrams", hashB); !ok {
580+ t.Fatal("B missing")
581+ }
582+ // The replacement keeps A and drops B; A is reused without a POST and
583+ // carries only the new version.
584+ second := signedEvent(t, testOwnerSecret, 30023, now+1, [][]string{{"d", "post"}}, "```mermaid\nA\n```\n")
585+ if err := publishAs(t, tenant, second); err != nil {
586+ t.Fatal(err)
587+ }
588+ runPendingView(t, tenant, "diagrams", second.ID)
589+ if server.count() != 1 {
590+ t.Fatalf("requests = %d", server.count())
591+ }
592+ artifact, ok := storedArtifact(t, tenant, "diagrams", hashA)
593+ if !ok || len(artifactTags(artifact, "e")) != 1 || event.Tag(artifact, "e") != second.ID {
594+ t.Fatalf("A after replacement = %+v %v", artifact, ok)
595+ }
596+ if _, ok := storedArtifact(t, tenant, "diagrams", hashB); ok {
597+ t.Fatal("B survived the replacement")
598+ }
599+ // A stale intent for the replaced version renders nothing.
600+ stale := tenant.viewIntent(customView{Name: "diagrams"}, first, "old")
601+ if err := tenant.handleViewTransform(ctx, work.Intent{ID: "stale", Kind: stale.Kind, EventID: stale.EventID, Target: stale.Target, Payload: stale.Payload}); err != nil {
602+ t.Fatal(err)
603+ }
604+ if server.count() != 1 {
605+ t.Fatal("a replaced version was sent to the transform")
606+ }
607+ // Deleting the address removes the artifact.
608+ deletion := signedEvent(t, testOwnerSecret, event.KIND_DELETION, now+2, [][]string{{"a", "30023:" + tenant.Policy().Owner + ":post"}}, "")
609+ if err := publishAs(t, tenant, deletion); err != nil {
610+ t.Fatal(err)
611+ }
612+ if _, ok := storedArtifact(t, tenant, "diagrams", hashA); ok {
613+ t.Fatal("A survived the address deletion")
614+ }
615+}
616+
617+func TestCustomViewTransformRetriesThenPauses(t *testing.T) {
618+ tenant, server, _ := viewTenant(t, []int{1}, nil)
619+ ctx := context.Background()
620+ now := time.Now().Unix()
621+ server.answer(http.StatusInternalServerError)
622+ note := signedEvent(t, testOwnerSecret, 1, now, nil, "```mermaid\nA\n```")
623+ if err := publishAs(t, tenant, note); err != nil {
624+ t.Fatal(err)
625+ }
626+ queued := pendingViewIntents(t, tenant, "diagrams", note.ID)
627+ if len(queued) != 1 {
628+ t.Fatalf("queued = %d", len(queued))
629+ }
630+ runViewIntent(t, tenant, queued[0])
631+ queued = pendingViewIntents(t, tenant, "diagrams", note.ID)
632+ if len(queued) != 1 || queued[0].EventID != note.ID+"#2" || queued[0].NextAt.Unix() < now+55 || queued[0].NextAt.Unix() > now+70 {
633+ t.Fatalf("second attempt = %+v", queued)
634+ }
635+ runViewIntent(t, tenant, queued[0])
636+ queued = pendingViewIntents(t, tenant, "diagrams", note.ID)
637+ if len(queued) != 1 || queued[0].EventID != note.ID+"#3" || queued[0].NextAt.Unix() < now+295 || queued[0].NextAt.Unix() > now+310 {
638+ t.Fatalf("third attempt = %+v", queued)
639+ }
640+ runViewIntent(t, tenant, queued[0])
641+ if queued = pendingViewIntents(t, tenant, "diagrams", note.ID); len(queued) != 0 {
642+ t.Fatalf("fourth attempt queued: %+v", queued)
643+ }
644+ var failures, enabled int
645+ var status string
646+ state := func() {
647+ t.Helper()
648+ if err := tenant.store.DB().QueryRowContext(ctx, "SELECT failures,enabled,last_status FROM custom_views WHERE name='diagrams'").Scan(&failures, &enabled, &status); err != nil {
649+ t.Fatal(err)
650+ }
651+ }
652+ state()
653+ if failures != 3 || enabled != 1 || status != "HTTP 500" {
654+ t.Fatalf("after three failures: %d %d %q", failures, enabled, status)
655+ }
656+ if server.count() != 3 {
657+ t.Fatalf("requests = %d", server.count())
658+ }
659+ // A 200 that is not JSON is a failure too.
660+ server.mu.Lock()
661+ server.status, server.body = http.StatusOK, "<html>"
662+ server.mu.Unlock()
663+ other := signedEvent(t, testOwnerSecret, 1, now+1, nil, "```mermaid\nB\n```")
664+ if err := publishAs(t, tenant, other); err != nil {
665+ t.Fatal(err)
666+ }
667+ runPendingView(t, tenant, "diagrams", other.ID)
668+ state()
669+ if failures != 4 || status != "invalid response" {
670+ t.Fatalf("after bad json: %d %q", failures, status)
671+ }
672+ // The twentieth failure in a row pauses the view; nothing new is queued.
673+ if _, err := tenant.store.DB().ExecContext(ctx, "UPDATE custom_views SET failures=19 WHERE name='diagrams'"); err != nil {
674+ t.Fatal(err)
675+ }
676+ server.answer(http.StatusBadGateway)
677+ third := signedEvent(t, testOwnerSecret, 1, now+2, nil, "```mermaid\nC\n```")
678+ if err := publishAs(t, tenant, third); err != nil {
679+ t.Fatal(err)
680+ }
681+ runPendingView(t, tenant, "diagrams", third.ID)
682+ state()
683+ if failures != 20 || enabled != 0 || status != "paused after 20 failures: HTTP 502" {
684+ t.Fatalf("after twenty failures: %d %d %q", failures, enabled, status)
685+ }
686+ if queued := pendingViewIntents(t, tenant, "diagrams", third.ID); len(queued) != 0 {
687+ t.Fatalf("paused view queued a retry: %+v", queued)
688+ }
689+ fourth := signedEvent(t, testOwnerSecret, 1, now+3, nil, "```mermaid\nD\n```")
690+ if err := publishAs(t, tenant, fourth); err != nil {
691+ t.Fatal(err)
692+ }
693+ if queued := pendingViewIntents(t, tenant, "diagrams", fourth.ID); len(queued) != 0 {
694+ t.Fatal("paused view queued a transform")
695+ }
696+ // Resuming clears the count; a success resets it after a failure.
697+ if _, err := tenant.Execute(ctx, tenant.Policy().Owner, "resumecustomview", []json.RawMessage{json.RawMessage(`"diagrams"`)}); err != nil {
698+ t.Fatal(err)
699+ }
700+ server.answer(http.StatusOK, svgArtifact(0))
701+ fifth := signedEvent(t, testOwnerSecret, 1, now+4, nil, "```mermaid\nE\n```")
702+ if err := publishAs(t, tenant, fifth); err != nil {
703+ t.Fatal(err)
704+ }
705+ runPendingView(t, tenant, "diagrams", fifth.ID)
706+ state()
707+ if failures != 0 || enabled != 1 || status != "ok" {
708+ t.Fatalf("after success: %d %d %q", failures, enabled, status)
709+ }
710+}
711+
712+func TestCustomViewArtifactValidation(t *testing.T) {
713+ png := base64.StdEncoding.EncodeToString([]byte(testPNGRaw))
714+ for _, tc := range []struct {
715+ name string
716+ artifact viewArtifact
717+ max int
718+ want string
719+ }{
720+ {"svg", viewArtifact{Type: "image/svg+xml", Body: testSVG}, 1024, ""},
721+ {"svg fragment href", viewArtifact{Type: "image/svg+xml", Body: `<svg><use href="#a"/><a xlink:href='#b'/></svg>`}, 1024, ""},
722+ {"png", viewArtifact{Type: "image/png", Body: png}, 1024, ""},
723+ {"png raw base64", viewArtifact{Type: "image/png", Body: strings.TrimRight(png, "=")}, 1024, ""},
724+ {"type", viewArtifact{Type: "text/html", Body: "<p>"}, 1024, "invalid: artifact type"},
725+ {"svg size", viewArtifact{Type: "image/svg+xml", Body: testSVG}, 10, "invalid: artifact exceeds"},
726+ {"png size", viewArtifact{Type: "image/png", Body: png}, 4, "invalid: artifact exceeds"},
727+ {"png base64", viewArtifact{Type: "image/png", Body: "not base64!"}, 1024, "invalid: png body must be base64"},
728+ {"png signature", viewArtifact{Type: "image/png", Body: base64.StdEncoding.EncodeToString([]byte("GIF89a"))}, 1024, "invalid: png body is not a PNG"},
729+ {"svg element", viewArtifact{Type: "image/svg+xml", Body: "<div/>"}, 1024, "invalid: svg body has no svg"},
730+ {"script", viewArtifact{Type: "image/svg+xml", Body: `<svg><SCRIPT>alert(1)</SCRIPT></svg>`}, 1024, "invalid: svg contains script"},
731+ {"handler", viewArtifact{Type: "image/svg+xml", Body: `<svg onload = "x()"/>`}, 1024, "invalid: svg contains an event handler"},
732+ {"foreign object", viewArtifact{Type: "image/svg+xml", Body: `<svg><foreignobject/></svg>`}, 1024, "invalid: svg contains a foreign object"},
733+ {"javascript href", viewArtifact{Type: "image/svg+xml", Body: `<svg><a href="javascript:alert(1)"/></svg>`}, 1024, "invalid: svg contains a javascript"},
734+ {"external href", viewArtifact{Type: "image/svg+xml", Body: `<svg><image href="https://evil.example/x.png"/></svg>`}, 1024, "invalid: svg references an external"},
735+ {"external xlink", viewArtifact{Type: "image/svg+xml", Body: `<svg><use xlink:href='/x.svg#a'/></svg>`}, 1024, "invalid: svg references an external"},
736+ {"unquoted href", viewArtifact{Type: "image/svg+xml", Body: `<svg><a href=data:text/html,x>`}, 1024, "invalid: svg references an external"},
737+ } {
738+ _, err := checkArtifact(tc.artifact, tc.max)
739+ if tc.want == "" && err != nil {
740+ t.Errorf("%s: unexpected %v", tc.name, err)
741+ }
742+ if tc.want != "" && (err == nil || !strings.HasPrefix(err.Error(), tc.want)) {
743+ t.Errorf("%s: err = %v, want prefix %q", tc.name, err, tc.want)
744+ }
745+ }
746+ if body, err := checkArtifact(viewArtifact{Type: "image/png", Body: " " + png + "\n"}, 1024); err != nil || body != png {
747+ t.Fatalf("png normalization: %q %v", body, err)
748+ }
749+}
750+
751+func TestCustomViewMembersAudienceStaysOffTheStore(t *testing.T) {
752+ tenant, server, added := viewTenant(t, []int{1}, map[string]any{"audience": "members"})
753+ ctx := context.Background()
754+ member, _ := event.PublicKey(testMemberSecret)
755+ setRole(t, tenant, member, "member")
756+ now := time.Now().Unix()
757+ server.answer(http.StatusOK, svgArtifact(0))
758+ note := signedEvent(t, testOwnerSecret, 1, now, nil, "```mermaid\nA\n```")
759+ if err := publishAs(t, tenant, note); err != nil {
760+ t.Fatal(err)
761+ }
762+ runPendingView(t, tenant, "diagrams", note.ID)
763+ hash := views.Hash("mermaid", "A")
764+ if _, ok := storedArtifact(t, tenant, "diagrams", hash); ok {
765+ t.Fatal("a members-only artifact became a stored record")
766+ }
767+ var raw string
768+ if err := tenant.store.DB().QueryRowContext(ctx, "SELECT raw FROM custom_view_artifacts WHERE view='diagrams' AND hash=?", hash).Scan(&raw); err != nil {
769+ t.Fatal(err)
770+ }
771+ signed, err := event.Parse([]byte(raw))
772+ if err != nil || signed.PubKey != tenant.records.PublicKey() || event.Tag(signed, "d") != "bind.ws/view/diagrams/"+hash {
773+ t.Fatalf("members artifact record = %+v %v", signed, err)
774+ }
775+ path := "/views/diagrams/" + hash + ".svg"
776+ if res := getArtifact(t, tenant, path, ""); res.Code != http.StatusUnauthorized {
777+ t.Fatalf("guest: %d", res.Code)
778+ }
779+ if res := getArtifact(t, tenant, path, strings.Repeat("7", 64)); res.Code != http.StatusForbidden {
780+ t.Fatalf("outsider: %d", res.Code)
781+ }
782+ for _, secret := range []string{testMemberSecret, testOwnerSecret} {
783+ res := getArtifact(t, tenant, path, secret)
784+ if res.Code != http.StatusOK || res.Body.String() != testSVG || res.Header().Get("Cache-Control") != "private, max-age=31536000, immutable" {
785+ t.Fatalf("member: %d %v", res.Code, res.Header())
786+ }
787+ }
788+ // A public view on a members-only relay follows the read rule.
789+ p := tenant.Policy()
790+ p.Reads = "members"
791+ if err := tenant.applyPolicy(ctx, p); err != nil {
792+ t.Fatal(err)
793+ }
794+ addView(t, tenant, map[string]any{"name": "open", "kinds": []int{1}, "transform": added["transform"], "languages": []string{"dot"}})
795+ other := signedEvent(t, testOwnerSecret, 1, now+1, nil, "```dot\nB\n```")
796+ if err := publishAs(t, tenant, other); err != nil {
797+ t.Fatal(err)
798+ }
799+ runPendingView(t, tenant, "open", other.ID)
800+ openPath := "/views/open/" + views.Hash("dot", "B") + ".svg"
801+ if res := getArtifact(t, tenant, openPath, ""); res.Code != http.StatusUnauthorized && res.Code != http.StatusForbidden {
802+ t.Fatalf("guest on a members relay: %d", res.Code)
803+ }
804+ if res := getArtifact(t, tenant, openPath, testMemberSecret); res.Code != http.StatusOK {
805+ t.Fatalf("member on a members relay: %d %s", res.Code, res.Body.String())
806+ }
807+}
808+
809+func TestCustomViewHourlyTickQueuesRecentSources(t *testing.T) {
810+ tenant, server, _ := viewTenant(t, []int{1}, map[string]any{"trigger": "hourly"})
811+ ctx := context.Background()
812+ now := time.Now().Unix()
813+ server.answer(http.StatusOK, svgArtifact(0))
814+ note := signedEvent(t, testOwnerSecret, 1, now, nil, "```mermaid\nA\n```")
815+ if err := publishAs(t, tenant, note); err != nil {
816+ t.Fatal(err)
817+ }
818+ if queued := pendingViewIntents(t, tenant, "diagrams", note.ID); len(queued) != 0 {
819+ t.Fatal("an hourly view queued at write time")
820+ }
821+ if err := tenant.tickCustomViews(ctx, now); err != nil {
822+ t.Fatal(err)
823+ }
824+ queued := pendingViewIntents(t, tenant, "diagrams", note.ID)
825+ if len(queued) != 1 || !strings.HasPrefix(queued[0].EventID, note.ID+"@") {
826+ t.Fatalf("hourly queue = %+v", queued)
827+ }
828+ runViewIntent(t, tenant, queued[0])
829+ if _, ok := storedArtifact(t, tenant, "diagrams", views.Hash("mermaid", "A")); !ok {
830+ t.Fatal("hourly artifact missing")
831+ }
832+ // Not due again for an hour; a backfill through runcustomview queues now.
833+ if err := tenant.tickCustomViews(ctx, now+60); err != nil {
834+ t.Fatal(err)
835+ }
836+ if queued := pendingViewIntents(t, tenant, "diagrams", note.ID); len(queued) != 0 {
837+ t.Fatal("hourly view queued again within the hour")
838+ }
839+ ran, err := tenant.Execute(ctx, tenant.Policy().Owner, "runcustomview", []json.RawMessage{json.RawMessage(`"diagrams"`)})
840+ if err != nil || ran.(map[string]any)["queued"] != 1 {
841+ t.Fatalf("runcustomview: %v %v", ran, err)
842+ }
843+ queued = pendingViewIntents(t, tenant, "diagrams", note.ID)
844+ if len(queued) != 1 {
845+ t.Fatalf("backfill queue = %+v", queued)
846+ }
847+ runViewIntent(t, tenant, queued[0])
848+ if server.count() != 1 {
849+ t.Fatalf("backfill re-rendered a block with an artifact: %d requests", server.count())
850+ }
851+}
852+
853+func TestCustomViewRendersTheReadmeAtTheStateHead(t *testing.T) {
854+ tenant, server, _ := viewTenant(t, []int{event.KIND_REPO_STATE}, nil)
855+ ctx := context.Background()
856+ p := tenant.Policy()
857+ p.Features.Grasp = true
858+ if err := tenant.applyPolicy(ctx, p); err != nil {
859+ t.Fatal(err)
860+ }
861+ owner := tenant.Policy().Owner
862+ 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"}}, "")
863+ if err := publishAs(t, tenant, announcement); err != nil {
864+ t.Fatal(err)
865+ }
866+ source := filepath.Join(t.TempDir(), "source.git")
867+ gitTest(t, "init", "--bare", source)
868+ work := filepath.Join(t.TempDir(), "work")
869+ gitTest(t, "clone", source, work)
870+ gitTest(t, "-C", work, "config", "user.email", "test@example.com")
871+ gitTest(t, "-C", work, "config", "user.name", "Test")
872+ if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("# Docs\n\n```go\nx\n```\n\n```mermaid\ngraph LR; a-->b;\n```\n"), 0o600); err != nil {
873+ t.Fatal(err)
874+ }
875+ gitTest(t, "-C", work, "add", "README.md")
876+ gitTest(t, "-C", work, "commit", "-m", "readme")
877+ gitTest(t, "-C", work, "branch", "-M", "main")
878+ sha := strings.TrimSpace(gitOutput(t, "-C", work, "rev-parse", "HEAD"))
879+ state := signedEvent(t, testOwnerSecret, event.KIND_REPO_STATE, time.Now().Unix(), [][]string{{"d", "docs"}, {"HEAD", "ref: refs/heads/main"}, {"refs/heads/main", sha}}, "")
880+ if err := publishAs(t, tenant, state); err != nil {
881+ t.Fatal(err)
882+ }
883+ if queued := pendingViewIntents(t, tenant, "diagrams", state.ID); len(queued) != 0 {
884+ t.Fatal("a state was queued before its objects arrived")
885+ }
886+ relay := httptest.NewServer(tenant)
887+ defer relay.Close()
888+ gitTest(t, "-C", work, "push", relay.URL+"/npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d/docs.git", "refs/heads/main:refs/heads/main")
889+ blocks, err := tenant.sourceBlocks(ctx, state)
890+ if err != nil {
891+ t.Fatal(err)
892+ }
893+ if len(blocks) != 2 || blocks[1].Index != 1 || blocks[1].Lang != "mermaid" || blocks[1].Source != "graph LR; a-->b;" {
894+ t.Fatalf("readme blocks = %+v", blocks)
895+ }
896+ queued := pendingViewIntents(t, tenant, "diagrams", state.ID)
897+ if len(queued) != 1 {
898+ t.Fatalf("state intents after push = %d", len(queued))
899+ }
900+ server.answer(http.StatusOK, svgArtifact(1))
901+ runViewIntent(t, tenant, queued[0])
902+ var request map[string]any
903+ if err := json.Unmarshal(server.requests[0], &request); err != nil {
904+ t.Fatal(err)
905+ }
906+ coordinate := "30618:" + owner + ":docs"
907+ if source := request["source"].(map[string]any); source["id"] != coordinate || source["kind"] != float64(30618) {
908+ t.Fatalf("state source = %v", source)
909+ }
910+ if blocks := request["blocks"].([]any); len(blocks) != 1 || blocks[0].(map[string]any)["index"] != float64(1) {
911+ t.Fatalf("state blocks = %v", blocks)
912+ }
913+ artifact, ok := storedArtifact(t, tenant, "diagrams", views.Hash("mermaid", "graph LR; a-->b;"))
914+ if !ok || event.Tag(artifact, "a") != coordinate || event.Tag(artifact, "e") != "" || event.Tag(artifact, "block") != "1" {
915+ t.Fatalf("state artifact = %+v %v", artifact, ok)
916+ }
917+}
918+
919+func TestMCPCustomViewTools(t *testing.T) {
920+ app, tenant := testTenant(t)
921+ member, _ := event.PublicKey(testMemberSecret)
922+ setRole(t, tenant, member, "member")
923+ call := func(name string, arguments map[string]any, secret string) (map[string]any, bool) {
924+ t.Helper()
925+ w, response := mcpCall{method: "tools/call", name: name, arguments: arguments, sign: true, secret: secret}.do(t, app, "/mcp")
926+ if w.Code != http.StatusOK {
927+ t.Fatalf("%s: %d %s", name, w.Code, w.Body.String())
928+ }
929+ return mcpToolResult(t, response)
930+ }
931+ structured := func(result map[string]any) any {
932+ value, _ := result["structuredContent"].(map[string]any)
933+ return value["result"]
934+ }
935+ text := func(result map[string]any) string {
936+ content, _ := result["content"].([]any)
937+ if len(content) == 0 {
938+ return ""
939+ }
940+ return content[0].(map[string]any)["text"].(string)
941+ }
942+ _, response := mcpCall{method: "tools/list", sign: true}.do(t, app, "/mcp")
943+ names := map[string]bool{}
944+ for _, tool := range response.Result.(map[string]any)["tools"].([]any) {
945+ names[tool.(map[string]any)["name"].(string)] = true
946+ }
947+ for _, want := range []string{"list_custom_views", "add_custom_view", "remove_custom_view", "pause_custom_view", "resume_custom_view", "run_custom_view"} {
948+ if !names[want] {
949+ t.Fatalf("missing tool %s", want)
950+ }
951+ }
952+ definition := map[string]any{"name": "diagrams", "kinds": []any{1.0, 30023.0}, "transform": "https://render.example/tiny", "languages": []any{"mermaid"}}
953+ result, isError := call("add_custom_view", definition, testMemberSecret)
954+ if !isError || !strings.Contains(text(result), "restricted") {
955+ t.Fatalf("add_custom_view as member: %v %s", isError, text(result))
956+ }
957+ result, isError = call("add_custom_view", definition, "")
958+ added, _ := structured(result).(map[string]any)
959+ secret, _ := added["secret"].(string)
960+ if isError || len(secret) != 64 || added["name"] != "diagrams" || added["trigger"] != "write" {
961+ t.Fatalf("add_custom_view: %v %s", isError, text(result))
962+ }
963+ definition["name"], definition["secret"], definition["trigger"], definition["audience"], definition["max_bytes"] = "charts", "shared-secret-value-1234", "hourly", "members", 4096.0
964+ result, isError = call("add_custom_view", definition, "")
965+ if pasted, _ := structured(result).(map[string]any); isError || pasted["secret"] != "shared-secret-value-1234" || pasted["trigger"] != "hourly" || pasted["audience"] != "members" || pasted["maxBytes"] != float64(4096) {
966+ t.Fatalf("add_custom_view with a secret: %v %s", isError, text(result))
967+ }
968+ result, isError = call("list_custom_views", nil, "")
969+ rows, _ := structured(result).([]any)
970+ if isError || len(rows) != 2 || strings.Contains(text(result), secret) || strings.Contains(text(result), "shared-secret-value-1234") {
971+ t.Fatalf("list_custom_views: %v %s", isError, text(result))
972+ }
973+ if result, isError = call("list_custom_views", nil, testMemberSecret); !isError {
974+ t.Fatalf("list_custom_views as member: %s", text(result))
975+ }
976+ result, isError = call("pause_custom_view", map[string]any{"name": "diagrams"}, "")
977+ if paused, _ := structured(result).(map[string]any); isError || paused["enabled"] != false {
978+ t.Fatalf("pause_custom_view: %v %s", isError, text(result))
979+ }
980+ result, isError = call("resume_custom_view", map[string]any{"name": "diagrams"}, "")
981+ if resumed, _ := structured(result).(map[string]any); isError || resumed["enabled"] != true {
982+ t.Fatalf("resume_custom_view: %v %s", isError, text(result))
983+ }
984+ result, isError = call("run_custom_view", map[string]any{"name": "diagrams"}, "")
985+ if ran, _ := structured(result).(map[string]any); isError || ran["queued"] != float64(0) {
986+ t.Fatalf("run_custom_view: %v %s", isError, text(result))
987+ }
988+ result, isError = call("remove_custom_view", map[string]any{"name": "charts"}, "")
989+ if removed, _ := structured(result).(map[string]any); isError || removed["removed"] != true {
990+ t.Fatalf("remove_custom_view: %v %s", isError, text(result))
991+ }
992+ result, isError = call("remove_custom_view", map[string]any{"name": "charts"}, "")
993+ if !isError || !strings.Contains(text(result), "not found") {
994+ t.Fatalf("remove_custom_view twice: %v %s", isError, text(result))
995+ }
996+}
997diff --git a/internal/daemon/custom_views_transform.go b/internal/daemon/custom_views_transform.go
998index 9b33471..6767a26 100644
999--- a/internal/daemon/custom_views_transform.go
1000+++ b/internal/daemon/custom_views_transform.go
1001@@ -323,9 +323,11 @@ func (t *Tenant) retryViewTransform(ctx context.Context, intent work.Intent, pay
1002 return err
1003 }
1004 now := time.Now().Unix()
1005- key := sha256.Sum256([]byte(viewTransform + "\x00" + intent.EventID + "\x00" + intent.Target + "\x00" + fmt.Sprint(payload.Attempt)))
1006+ // The intent id names the source and run; earlier attempts add "#n".
1007+ source, _, _ := strings.Cut(intent.EventID, "#")
1008+ key := sha256.Sum256([]byte(viewTransform + "\x00" + source + "\x00" + intent.Target + "\x00" + fmt.Sprint(payload.Attempt)))
1009 return t.store.WithTx(ctx, func(tx *sql.Tx) error {
1010- _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO work_intents(id,kind,event_id,target,payload,next_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)`, hex.EncodeToString(key[:]), viewTransform, intent.EventID+"#"+fmt.Sprint(payload.Attempt), intent.Target, string(encoded), now+int64(delay/time.Second), now, now)
1011+ _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO work_intents(id,kind,event_id,target,payload,next_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)`, hex.EncodeToString(key[:]), viewTransform, source+"#"+fmt.Sprint(payload.Attempt), intent.Target, string(encoded), now+int64(delay/time.Second), now, now)
1012 return err
1013 })
1014 }
1015@@ -686,12 +688,13 @@ func (t *Tenant) dropOrphanArtifacts(ctx context.Context, name string) error {
1016 if err != nil {
1017 return nil
1018 }
1019- rows, err := t.store.DB().QueryContext(ctx, `SELECT a.hash, a.raw FROM custom_view_artifacts a WHERE a.view=?`, name)
1020+ rows, err := t.store.DB().QueryContext(ctx, `SELECT hash, raw FROM custom_view_artifacts WHERE view=?`, name)
1021 if err != nil {
1022 return err
1023 }
1024- type stale struct{ hash string }
1025- var refresh []stale
1026+ // Read the rows out before asking about sources: the store lends one
1027+ // connection at a time.
1028+ tagged := map[string][]string{}
1029 for rows.Next() {
1030 var hash, raw string
1031 if err := rows.Scan(&hash, &raw); err != nil {
1032@@ -702,28 +705,28 @@ func (t *Tenant) dropOrphanArtifacts(ctx context.Context, name string) error {
1033 if json.Unmarshal([]byte(raw), &signed) != nil {
1034 continue
1035 }
1036- var tagged []string
1037+ sources := []string{}
1038 for _, tag := range signed.Tags {
1039 if len(tag) == 2 && (tag[0] == "e" || tag[0] == "a") {
1040- tagged = append(tagged, tag[1])
1041+ sources = append(sources, tag[1])
1042 }
1043 }
1044+ tagged[hash] = sources
1045+ }
1046+ if err := errors.Join(rows.Err(), rows.Close()); err != nil {
1047+ return err
1048+ }
1049+ for hash, sources := range tagged {
1050 current, err := t.artifactSources(ctx, name, hash)
1051 if err != nil {
1052- rows.Close()
1053 return err
1054 }
1055- sort.Strings(tagged)
1056+ sort.Strings(sources)
1057 sort.Strings(current)
1058- if strings.Join(tagged, "\n") != strings.Join(current, "\n") {
1059- refresh = append(refresh, stale{hash})
1060+ if strings.Join(sources, "\n") == strings.Join(current, "\n") {
1061+ continue
1062 }
1063- }
1064- if err := errors.Join(rows.Err(), rows.Close()); err != nil {
1065- return err
1066- }
1067- for _, item := range refresh {
1068- if err := t.signArtifact(ctx, view, item.hash, time.Now().Unix()); err != nil {
1069+ if err := t.signArtifact(ctx, view, hash, time.Now().Unix()); err != nil {
1070 return err
1071 }
1072 }
1073diff --git a/internal/daemon/mcp_http.go b/internal/daemon/mcp_http.go
1074index c19d55e..d71c16c 100644
1075--- a/internal/daemon/mcp_http.go
1076+++ b/internal/daemon/mcp_http.go
1077@@ -91,8 +91,8 @@ func (t *Tenant) llmsHTTP(w http.ResponseWriter, r *http.Request) {
1078 "- Endpoint: "+base+"/mcp accepts POST only. Protocol version "+mcp.Version+", stateless Streamable HTTP, JSON responses.",
1079 "- Headers: `MCP-Protocol-Version: "+mcp.Version+"`, `Mcp-Method` equal to the JSON-RPC method and, for tools/call, `Mcp-Name` equal to params.name. The body's params._meta carries `"+mcp.MetaProtocolVersion+"` and `"+mcp.MetaClientCapabilities+"`.",
1080 "- Methods: server/discover, tools/list and tools/call.",
1081- "- Read tools: list_repositories, read_repository, list_issues, read_issue, list_pull_requests, read_pull_request, list_files, read_file, read_status, read_management, list_rooms, read_room, read_thread, list_wiki, read_wiki_page, read_merge_request, list_agents, list_callbacks, list_join_requests, list_jobs and read_job.",
1082- "- Management tools: run_job, add_job, remove_job, backup_now, dump_now, set_policy, set_connections, send_test_notification, pause_agent, resume_agent, revoke_agent, pause_all_agents, resume_all_agents, approve_join, deny_join, add_callback, remove_callback, pause_callback and resume_callback. approve_join and deny_join answer access requests from people who asked to join without an invite. A callback POSTs each new matching event to an https URL, signed with X-Tiny-Signature.",
1083+ "- Read tools: list_repositories, read_repository, list_issues, read_issue, list_pull_requests, read_pull_request, list_files, read_file, read_status, read_management, list_rooms, read_room, read_thread, list_wiki, read_wiki_page, read_merge_request, list_agents, list_callbacks, list_custom_views, list_join_requests, list_jobs and read_job.",
1084+ "- Management tools: run_job, add_job, remove_job, backup_now, dump_now, set_policy, set_connections, send_test_notification, pause_agent, resume_agent, revoke_agent, pause_all_agents, resume_all_agents, approve_join, deny_join, add_callback, remove_callback, pause_callback, resume_callback, add_custom_view, remove_custom_view, pause_custom_view, resume_custom_view and run_custom_view. approve_join and deny_join answer access requests from people who asked to join without an invite. A callback POSTs each new matching event to an https URL, signed with X-Tiny-Signature. A custom view POSTs the fenced code blocks of matching events to an https transform and keeps the SVG or PNG it returns as signed artifacts served at /views/<name>/<hash>.svg.",
1085 "- Write tools take a signed event, or return the unsigned event to sign when called with plain fields: publish_event, create_issue, create_pull_request, comment, set_status, post_message, start_thread, reply_in_thread, react, publish_wiki_page, propose_wiki_merge, publish_site, create_room, request_decision, request_job, job_feedback and job_result. A decision request is answered by a kind 7 reaction from the person asked; a job request is answered by kind 7000 feedback and a result of the request kind plus 1000. publish_site takes [path, sha256] pairs for files already in the blob store and publishes a NIP-5A manifest; an agent needs a sites grant, and a grant with a ttl requires an expiration tag.",
1086 "",
1087 "## Files",
1088diff --git a/internal/daemon/mcp_tools.go b/internal/daemon/mcp_tools.go
1089index cddea04..19a9244 100644
1090--- a/internal/daemon/mcp_tools.go
1091+++ b/internal/daemon/mcp_tools.go
1092@@ -133,6 +133,9 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) {
1093 add("list_callbacks", "List event callbacks: id, owner, host, filter, paused state, failures and last delivery. Members and agents see their own; the owner and moderators see every callback. The secret is never listed.", mcp.Object(nil), mcpReads, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {
1094 return t.mcpManage(ctx, call, "listcallbacks")
1095 })
1096+ add("list_custom_views", "List the owner's custom views: name, kinds, languages, transform host, trigger, audience, state, failures and last run. The secret is never listed. Owner only.", mcp.Object(nil), mcpReads, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {
1097+ return t.mcpManage(ctx, call, "listcustomviews")
1098+ })
1099 add("list_join_requests", "List access requests from people who asked to join without an invite: pubkey, reason, requested_at, status (pending, approved or denied), decided_by and decided_at. Pending requests come first. Requires an owner or moderator key.", mcp.Object(nil), mcpReads, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {
1100 return t.mcpManage(ctx, call, "listjoinrequests")
1101 })
1102@@ -164,6 +167,21 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) {
1103 add("add_callback", "Register an https URL that receives a POST with each new event matching filter that your key may see. The filter takes kinds (required), authors, #a, #e, #p and #h with at most 8 values each. The answer carries the callback id and, once, the secret used for the X-Tiny-Signature header; keep it. Members and agents may hold up to the relay's callback allowance, 4 by default.", mcp.Object(map[string]any{"url": map[string]any{"type": "string", "description": "An https URL on a public host."}, "filter": map[string]any{"type": "object", "description": "NIP-01 filter subset: kinds, authors, #a, #e, #p, #h.", "minProperties": 1}, "secret": map[string]any{"type": "string", "minLength": callbackSecretMin, "maxLength": callbackSecretMax, "description": "Optional shared secret, 16 to 128 printable ASCII characters. Generated when omitted."}}, "url", "filter"), mcpChanges, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {
1104 return t.mcpManage(ctx, call, "addcallback", call.Arguments)
1105 })
1106+ add("add_custom_view", "Define a custom view: the relay POSTs the fenced code blocks of matching events, in the listed languages, to an https transform and keeps the SVG or PNG artifacts it returns as relay-signed events shown in place of the blocks. The answer carries the secret once, used for the X-Tiny-Signature header; keep it. Owner only.", mcp.Object(map[string]any{"name": map[string]any{"type": "string", "pattern": "^[a-z0-9-]{1,32}$", "description": "View name: 1 to 32 lowercase letters, digits or hyphens."}, "kinds": map[string]any{"type": "array", "items": map[string]any{"type": "integer", "minimum": 0, "maximum": 65535}, "minItems": 1, "maxItems": viewKindMax, "description": "Event kinds the view watches; 30618 sends the repository README at its head."}, "transform": map[string]any{"type": "string", "description": "An https URL on a public host that renders the blocks."}, "trigger": map[string]any{"type": "string", "enum": []string{"write", "hourly"}, "description": "write sends each matching event as it arrives; hourly sends the newest ones every hour."}, "audience": map[string]any{"type": "string", "enum": []string{"public", "members"}, "description": "Who may see the artifacts."}, "languages": map[string]any{"type": "array", "items": map[string]any{"type": "string", "minLength": 1, "maxLength": viewLanguageLength}, "minItems": 1, "maxItems": viewLanguageMax, "description": "Fenced block languages the view renders, lowercase."}, "max_bytes": map[string]any{"type": "integer", "minimum": 1, "maximum": viewMaxBytesCap, "description": "Largest artifact accepted, in bytes. 1 MiB when omitted, 4 MiB at most."}, "secret": map[string]any{"type": "string", "minLength": callbackSecretMin, "maxLength": callbackSecretMax, "description": "Optional shared secret, 16 to 128 printable ASCII characters. Generated when omitted."}}, "name", "kinds", "transform", "languages"), mcpChanges, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {
1107+ return t.mcpManage(ctx, call, "addcustomview", call.Arguments)
1108+ })
1109+ add("remove_custom_view", "Delete a custom view and every artifact it produced. Owner only.", mcp.Object(map[string]any{"name": mcpID}, "name"), mcpSettings, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {
1110+ return t.mcpManage(ctx, call, "removecustomview", call.String("name"))
1111+ })
1112+ add("pause_custom_view", "Stop a custom view: no transform requests until it is resumed. Its artifacts stay. Owner only.", mcp.Object(map[string]any{"name": mcpID}, "name"), mcpControls, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {
1113+ return t.mcpManage(ctx, call, "pausecustomview", call.String("name"))
1114+ })
1115+ add("resume_custom_view", "Resume a paused custom view and clear its failure count. Owner only.", mcp.Object(map[string]any{"name": mcpID}, "name"), mcpControls, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {
1116+ return t.mcpManage(ctx, call, "resumecustomview", call.String("name"))
1117+ })
1118+ add("run_custom_view", "Queue a backfill of a custom view over the newest 500 events of its kinds. Blocks that already have an artifact are reused. Owner only.", mcp.Object(map[string]any{"name": mcpID}, "name"), mcpChanges, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {
1119+ return t.mcpManage(ctx, call, "runcustomview", call.String("name"))
1120+ })
1121 add("remove_callback", "Delete a callback by id. The callback's owner, the relay owner and moderators may do this.", mcp.Object(map[string]any{"id": mcpID}, "id"), mcpSettings, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {
1122 return t.mcpManage(ctx, call, "removecallback", call.String("id"))
1123 })
1124diff --git a/internal/webui/custom_views_test.go b/internal/webui/custom_views_test.go
1125new file mode 100644
1126index 0000000..f6e5d73
1127--- /dev/null
1128+++ b/internal/webui/custom_views_test.go
1129@@ -0,0 +1,172 @@
1130+package webui
1131+
1132+import (
1133+ "context"
1134+ "encoding/json"
1135+ "net/http"
1136+ "net/http/httptest"
1137+ "net/url"
1138+ "strings"
1139+ "testing"
1140+
1141+ "github.com/FelineStateMachine/tinyrelay/internal/policy"
1142+ "github.com/FelineStateMachine/tinyrelay/internal/views"
1143+ "github.com/FelineStateMachine/tinyrelay/internal/wiki"
1144+)
1145+
1146+// viewsBackend reports two custom views and lists them for the owner.
1147+type viewsBackend struct {
1148+ fakeBackend
1149+ views []views.View
1150+ rows []map[string]any
1151+}
1152+
1153+func (b *viewsBackend) CustomViews() []views.View { return b.views }
1154+
1155+func (b *viewsBackend) Query(ctx context.Context, method string, params []json.RawMessage, actor string) (any, error) {
1156+ if method == "listcustomviews" {
1157+ if actor != b.policy.Owner {
1158+ return nil, context.Canceled
1159+ }
1160+ return b.rows, nil
1161+ }
1162+ if method == "browserepo" {
1163+ var request map[string]any
1164+ if len(params) == 1 {
1165+ _ = json.Unmarshal(params[0], &request)
1166+ }
1167+ if request["view"] == "file" {
1168+ return map[string]any{"content": "# Notes\n\n```mermaid\ngraph TD; a-->b;\n```\n\nSee [guide](docs/guide.md).\n"}, nil
1169+ }
1170+ return map[string]any{"identifier": "notes", "entries": []any{}}, nil
1171+ }
1172+ return b.fakeBackend.Query(ctx, method, params, actor)
1173+}
1174+
1175+const mermaidSource = "graph TD;\n A-->B & <C>;"
1176+
1177+func mermaidFigure(view string) string {
1178+ return `<figure data-view="` + view + `"><object data="/views/` + view + `/` + views.Hash("mermaid", mermaidSource) + `.svg" type="image/svg+xml"><pre><code data-lang="mermaid">graph TD;` + "\n" + ` A--&gt;B &amp; &lt;C&gt;;</code></pre></object></figure>`
1179+}
1180+
1181+func TestRenderersShowCustomViewArtifactsWithTheCodeAsFallback(t *testing.T) {
1182+ renderer := views.NewRenderer([]views.View{{Name: "diagrams", Languages: []string{"mermaid"}}})
1183+ source := "Intro\n\n```mermaid\n" + mermaidSource + "\n```\n\n```go\nfunc main() {}\n```\n\nhttps://example.com/x\n"
1184+ got := string(renderMarkdownWith(source, renderer.Block))
1185+ wantAll(t, "markdown", got, mermaidFigure("diagrams"), `<pre><code data-lang="go">func main() {}</code></pre>`)
1186+ if strings.Contains(got, "class=") {
1187+ t.Fatal("markdown carries a class attribute")
1188+ }
1189+ // Without a renderer, every fenced block stays code.
1190+ plain := string(renderMarkdown(source))
1191+ wantAll(t, "plain markdown", plain, `<pre><code data-lang="mermaid">graph TD;`)
1192+ wantNone(t, "plain markdown", plain, "<figure", "<object")
1193+ // Chat keeps the figure and links the bare URL outside it.
1194+ chat := string(renderChatMarkdownWith(source, renderer.Block))
1195+ wantAll(t, "chat", chat, mermaidFigure("diagrams"), `<a href="https://example.com/x" rel="noopener">https://example.com/x</a>`)
1196+ // README rendering maps relative links and keeps the figure.
1197+ query := url.Values{"owner": {"alice"}, "repo": {"notes"}, "ref": {"main"}}
1198+ repo := string(renderRepositoryMarkdownWith(source+"[guide](docs/guide.md)\n", query, renderer.Block))
1199+ wantAll(t, "repository", repo, mermaidFigure("diagrams"), `href="/repo?owner=alice&amp;path=docs%2Fguide.md&amp;ref=main&amp;repo=notes&amp;view=file"`)
1200+ // The wiki renderer takes the same renderer through its options.
1201+ article := string(wiki.RenderHTMLWith("# Title\n\n``` Mermaid\n"+mermaidSource+"\n```\n\n~~~\nplain\n~~~\n", wiki.Options{Block: renderer.Block}))
1202+ wantAll(t, "wiki", article, "<h1>Title</h1>", mermaidFigure("diagrams"), "<pre><code>plain\n</code></pre>")
1203+ if plainWiki := string(wiki.RenderHTML("```mermaid\n" + mermaidSource + "\n```\n")); strings.Contains(plainWiki, "<figure") || !strings.Contains(plainWiki, `<code data-lang="mermaid">`) {
1204+ t.Fatalf("wiki without options: %s", plainWiki)
1205+ }
1206+}
1207+
1208+func TestPagesRenderCustomViewFiguresFromTheBackendSummary(t *testing.T) {
1209+ owner := strings.Repeat("a", 64)
1210+ backend := &viewsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(owner)}, views: []views.View{{Name: "diagrams", Languages: []string{"mermaid"}}}}
1211+ app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return owner, nil }})
1212+ if err != nil {
1213+ t.Fatal(err)
1214+ }
1215+ source := "```mermaid\n" + mermaidSource + "\n```"
1216+ for name, got := range map[string]string{
1217+ "markdown": string(app.markdown(source)),
1218+ "chat": string(app.chatMarkdown(source)),
1219+ "wiki": string(app.wikiHTML(source)),
1220+ } {
1221+ wantAll(t, name, got, mermaidFigure("diagrams"))
1222+ }
1223+ // The repository home renders the README through the same renderer,
1224+ // and the tenant prefix reaches the object address.
1225+ recorder := httptest.NewRecorder()
1226+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/repo?owner="+owner+"&repo=notes&view=home", nil))
1227+ body := recorder.Body.String()
1228+ if recorder.Code != http.StatusOK {
1229+ t.Fatalf("repo home: %d %s", recorder.Code, body)
1230+ }
1231+ hash := views.Hash("mermaid", "graph TD; a-->b;")
1232+ figure := `<figure data-view="diagrams"><object data="/views/diagrams/` + hash + `.svg" type="image/svg+xml"><pre><code data-lang="mermaid">graph TD; a--&gt;b;</code></pre></object></figure>`
1233+ wantAll(t, "repo home", body, figure, `href="/repo?owner=`)
1234+ if prefixed := injectBase(figure, "/r/work"); !strings.Contains(prefixed, `<object data="/r/work/views/diagrams/`+hash+`.svg"`) {
1235+ t.Fatalf("tenant prefix missed the object address: %s", prefixed)
1236+ }
1237+ // A backend without the summary keeps code blocks as code.
1238+ plainApp, err := New(&fakeBackend{policy: policy.Defaults(owner)}, Options{Actor: func(*http.Request) (string, error) { return owner, nil }})
1239+ if err != nil {
1240+ t.Fatal(err)
1241+ }
1242+ if got := string(plainApp.markdown(source)); strings.Contains(got, "<figure") {
1243+ t.Fatalf("figure without a summary: %s", got)
1244+ }
1245+ backend.views = nil
1246+ if got := string(app.markdown(source)); strings.Contains(got, "<figure") {
1247+ t.Fatalf("figure without views: %s", got)
1248+ }
1249+}
1250+
1251+func TestViewsPageListsCustomViewsWithControlsAndTheAddForm(t *testing.T) {
1252+ owner := strings.Repeat("a", 64)
1253+ backend := &viewsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(owner)}, rows: []map[string]any{
1254+ {"name": "diagrams", "kinds": []any{1.0, 30023.0}, "languages": []any{"mermaid", "dot"}, "trigger": "write", "audience": "public", "enabled": true, "state": "active", "lastRun": 1700000000.0, "lastStatus": "ok", "transform": "https://render.example/tiny?token=secret-path"},
1255+ {"name": "charts", "kinds": []any{30618.0}, "languages": []any{"vega"}, "trigger": "hourly", "audience": "members", "enabled": false, "state": "paused", "lastRun": 0.0, "lastStatus": "paused after 20 failures: HTTP 500"},
1256+ }}
1257+ app, err := New(backend, Options{Actor: func(r *http.Request) (string, error) { return r.Header.Get("X-Actor"), nil }})
1258+ if err != nil {
1259+ t.Fatal(err)
1260+ }
1261+ get := func(actor string) string {
1262+ t.Helper()
1263+ request := httptest.NewRequest(http.MethodGet, "/manage/views", nil)
1264+ request.Header.Set("X-Actor", actor)
1265+ recorder := httptest.NewRecorder()
1266+ app.ServeHTTP(recorder, request)
1267+ if recorder.Code != http.StatusOK {
1268+ t.Fatalf("views page: %d %s", recorder.Code, recorder.Body.String())
1269+ }
1270+ body := recorder.Body.String()
1271+ if strings.Contains(body, "class=") {
1272+ t.Fatal("views page carries a class attribute")
1273+ }
1274+ if strings.Contains(body, "ยท") {
1275+ t.Fatal("views page contains a middle dot")
1276+ }
1277+ return body
1278+ }
1279+ body := get(owner)
1280+ wantAll(t, "/manage/views", body,
1281+ `<rpc-form method="listviews">`, `<rpc-form method="publishview">`,
1282+ `<table id="custom-views">`, `<tr id="view-diagrams">`, `<code>diagrams</code>`, `<td>1, 30023</td>`, `<td>mermaid, dot</td>`, `<td>write</td>`, `<td>public</td>`,
1283+ `<td data-state="active">active | ok</td>`, `<td>2023-11-14 22:13 UTC</td>`,
1284+ `<rpc-form method="pausecustomview" refresh><input type="hidden" name="param" value="diagrams"><button>Pause</button></rpc-form>`,
1285+ `<rpc-form method="runcustomview" refresh><input type="hidden" name="param" value="diagrams"><button>Run now</button></rpc-form>`,
1286+ `<rpc-form method="removecustomview" refresh><input type="hidden" name="param" value="diagrams"><button>Remove</button></rpc-form>`,
1287+ `<tr id="view-charts">`, `<td data-state="paused">paused | paused after 20 failures: HTTP 500</td>`, `<td>never</td>`,
1288+ `<rpc-form method="resumecustomview" refresh><input type="hidden" name="param" value="charts"><button>Resume</button></rpc-form>`,
1289+ `<view-form method="addcustomview">`, `<input name="view-name" pattern="[a-z0-9-]{1,32}"`, `<input name="view-kinds"`, `<input name="view-languages"`, `<input name="view-transform" type="url"`,
1290+ `<select name="view-trigger"><option value="write">write</option><option value="hourly">hourly</option></select>`, `<select name="view-audience">`, `<input name="view-max-bytes" type="number" min="1" max="4194304"`, `<input name="view-secret" minlength="16" maxlength="128"`, `<button>Add view</button>`)
1291+ wantNone(t, "/manage/views", body, "secret-path", `<rpc-form method="pausecustomview" refresh><input type="hidden" name="param" value="charts">`)
1292+ // A member keeps the built-in controls and sees no custom view section.
1293+ member := get(strings.Repeat("b", 64))
1294+ wantAll(t, "/manage/views as member", member, `<rpc-form method="listviews">`)
1295+ wantNone(t, "/manage/views as member", member, `id="custom-views"`, "<view-form")
1296+ // The owner without views sees the empty note and the form.
1297+ backend.rows = nil
1298+ empty := get(owner)
1299+ wantAll(t, "/manage/views empty", empty, `<p id="custom-views-empty">No custom views yet.</p>`, `<view-form method="addcustomview">`)
1300+ wantNone(t, "/manage/views empty", empty, `<table id="custom-views">`)
1301+}

About

ownerc01503e8035f
visibilitypublic
headc2bdcb3f3d21

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs