tinyrelaycommit

Commit 8dd79a2655be

browse files at this commit | history

1diff --git a/internal/daemon/custom_views.go b/internal/daemon/custom_views.go
2new file mode 100644
3index 0000000..9f0976f
4--- /dev/null
5+++ b/internal/daemon/custom_views.go
6@@ -0,0 +1,606 @@
7+package daemon
8+
9+// Custom views hand fenced code blocks to an external transform and keep
10+// what comes back as relay-signed artifacts attached to the source event.
11+// This file holds the definition, its storage and the owner's management
12+// methods; custom_views_transform.go holds the block extraction, the POST
13+// and the artifacts, and custom_views_http.go serves them.
14+
15+import (
16+ "context"
17+ "database/sql"
18+ "encoding/json"
19+ "errors"
20+ "fmt"
21+ "sort"
22+ "strconv"
23+ "strings"
24+ "time"
25+
26+ "github.com/FelineStateMachine/tinyrelay/internal/event"
27+ "github.com/FelineStateMachine/tinyrelay/internal/storage"
28+ "github.com/FelineStateMachine/tinyrelay/internal/views"
29+)
30+
31+const (
32+ viewTransform = "view-transform"
33+ viewKindMax = 16
34+ viewLanguageMax = 32
35+ viewLanguageLength = 32
36+ viewDefaultMaxBytes = 1 << 20
37+ viewMaxBytesCap = 4 << 20
38+ viewTimeout = 30 * time.Second
39+ viewMaxAttempts = 3
40+ viewPauseFailures = 20
41+ viewBackfillLimit = 500
42+ viewHourlyPeriod = int64(3600)
43+ viewResponseMaxBytes = int64(viewMaxBytesCap*4 + 1<<20)
44+)
45+
46+// viewBackoff is the wait before the second and third attempt.
47+var viewBackoff = []time.Duration{time.Minute, 5 * time.Minute}
48+
49+const customViewSchema = `CREATE TABLE IF NOT EXISTS custom_views(name TEXT PRIMARY KEY, kinds TEXT NOT NULL, transform TEXT NOT NULL, trigger TEXT NOT NULL, audience TEXT NOT NULL, languages TEXT NOT NULL, max_bytes INTEGER NOT NULL, secret TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL, last_run_at INTEGER NOT NULL DEFAULT 0, last_status TEXT NOT NULL DEFAULT '', failures INTEGER NOT NULL DEFAULT 0);
50+CREATE TABLE IF NOT EXISTS custom_view_artifacts(view TEXT NOT NULL, hash TEXT NOT NULL, event_id TEXT NOT NULL, type TEXT NOT NULL, engine TEXT NOT NULL DEFAULT '', audience TEXT NOT NULL, content TEXT NOT NULL, raw TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY(view,hash));
51+CREATE TABLE IF NOT EXISTS custom_view_sources(view TEXT NOT NULL, hash TEXT NOT NULL, source TEXT NOT NULL, block INTEGER NOT NULL, expires INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(view,hash,source));
52+CREATE INDEX IF NOT EXISTS custom_view_sources_source ON custom_view_sources(view,source);`
53+
54+// customView is one stored definition. The secret never leaves the tenant
55+// except once, in the addcustomview answer.
56+type customView struct {
57+ Name string `json:"name"`
58+ Kinds []int `json:"kinds"`
59+ Transform string `json:"transform"`
60+ Trigger string `json:"trigger"`
61+ Audience string `json:"audience"`
62+ Languages []string `json:"languages"`
63+ MaxBytes int `json:"maxBytes"`
64+ Enabled bool `json:"enabled"`
65+ CreatedAt int64 `json:"created"`
66+ LastRunAt int64 `json:"lastRun"`
67+ LastStatus string `json:"lastStatus"`
68+ Failures int `json:"failures"`
69+ secret string
70+}
71+
72+func (v customView) summary() map[string]any {
73+ state := "active"
74+ if !v.Enabled {
75+ state = "paused"
76+ }
77+ return map[string]any{"name": v.Name, "kinds": v.Kinds, "transform": v.Transform, "host": callbackHost(v.Transform), "trigger": v.Trigger, "audience": v.Audience, "languages": v.Languages, "maxBytes": v.MaxBytes, "enabled": v.Enabled, "state": state, "created": v.CreatedAt, "lastRun": v.LastRunAt, "lastStatus": v.LastStatus, "failures": v.Failures}
78+}
79+
80+func (v customView) watches(kind int) bool {
81+ for _, k := range v.Kinds {
82+ if k == kind {
83+ return true
84+ }
85+ }
86+ return false
87+}
88+
89+func (t *Tenant) initCustomViews(ctx context.Context) error {
90+ _, err := t.store.DB().ExecContext(ctx, customViewSchema)
91+ return err
92+}
93+
94+func customViewMethod(method string) bool {
95+ return containsString([]string{"listcustomviews", "addcustomview", "removecustomview", "runcustomview", "pausecustomview", "resumecustomview"}, method)
96+}
97+
98+// customViewExecute serves the owner's custom view methods under the view
99+// operation. Every change is recorded in the audit log.
100+func (t *Tenant) customViewExecute(ctx context.Context, actor, method string, params []json.RawMessage) (result any, err error) {
101+ ctx, finish := t.app.telemetry.Start(ctx, "view")
102+ outcome := "error"
103+ defer func() {
104+ if err != nil {
105+ outcome = callbackOutcome(err)
106+ }
107+ finish(outcome)
108+ }()
109+ role, err := t.community.Role(ctx, actor)
110+ if err != nil {
111+ return nil, err
112+ }
113+ if role != "owner" {
114+ return nil, fmt.Errorf("restricted: only the owner may %s", method)
115+ }
116+ switch method {
117+ case "listcustomviews":
118+ rows, err := t.customViewRows(ctx)
119+ if err != nil {
120+ return nil, err
121+ }
122+ out := make([]map[string]any, 0, len(rows))
123+ for _, row := range rows {
124+ out = append(out, row.summary())
125+ }
126+ outcome = "ok"
127+ return out, nil
128+ case "addcustomview":
129+ result, err = t.addCustomView(ctx, actor, params)
130+ default:
131+ result, err = t.changeCustomView(ctx, actor, method, params)
132+ }
133+ if err == nil {
134+ outcome = "ok"
135+ }
136+ return result, err
137+}
138+
139+// customViewOptions is the addcustomview parameter. Kinds and languages may
140+// arrive as lists or as comma-separated strings from a plain form.
141+type customViewOptions struct {
142+ Name string `json:"name"`
143+ Kinds json.RawMessage `json:"kinds"`
144+ Transform string `json:"transform"`
145+ Trigger string `json:"trigger"`
146+ Audience string `json:"audience"`
147+ Languages json.RawMessage `json:"languages"`
148+ MaxBytes json.RawMessage `json:"max_bytes"`
149+ Secret string `json:"secret"`
150+}
151+
152+func (t *Tenant) addCustomView(ctx context.Context, actor string, params []json.RawMessage) (any, error) {
153+ if len(params) != 1 {
154+ return nil, errors.New("invalid: addcustomview expects one object with name, kinds, transform, trigger, audience, languages and optional max_bytes and secret")
155+ }
156+ var options customViewOptions
157+ if err := json.Unmarshal(params[0], &options); err != nil {
158+ return nil, errors.New("invalid: addcustomview expects one object with name, kinds, transform, trigger, audience, languages and optional max_bytes and secret")
159+ }
160+ view, err := t.parseCustomView(options)
161+ if err != nil {
162+ return nil, err
163+ }
164+ generated := false
165+ secret := strings.TrimSpace(options.Secret)
166+ if secret == "" {
167+ secret, err = randomHex(32)
168+ if err != nil {
169+ return nil, err
170+ }
171+ generated = true
172+ } else if len(secret) < callbackSecretMin || len(secret) > callbackSecretMax || strings.Trim(secret, "!\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~") != "" {
173+ return nil, fmt.Errorf("invalid: secret must be %d to %d printable ASCII characters", callbackSecretMin, callbackSecretMax)
174+ }
175+ view.secret = secret
176+ view.Enabled = true
177+ view.CreatedAt = time.Now().Unix()
178+ kinds, _ := json.Marshal(view.Kinds)
179+ languages, _ := json.Marshal(view.Languages)
180+ _, err = t.store.DB().ExecContext(ctx, `INSERT INTO custom_views(name,kinds,transform,trigger,audience,languages,max_bytes,secret,enabled,created_at) VALUES(?,?,?,?,?,?,?,?,1,?)`, view.Name, string(kinds), view.Transform, view.Trigger, view.Audience, string(languages), view.MaxBytes, secret, view.CreatedAt)
181+ if err != nil {
182+ if strings.Contains(err.Error(), "UNIQUE") {
183+ return nil, errors.New("invalid: a view with that name exists")
184+ }
185+ return nil, err
186+ }
187+ t.invalidateCustomViews()
188+ if err := t.community.Record(ctx, actor, "addcustomview", view.Name, ""); err != nil {
189+ return nil, err
190+ }
191+ // Counts only: the transform host, path and secret stay out of the log.
192+ t.app.telemetry.Logger().Info("custom view added", "tenant", t.meta.Name, "kinds", len(view.Kinds), "languages", len(view.Languages), "generated", generated)
193+ out := view.summary()
194+ out["secret"] = secret
195+ return out, nil
196+}
197+
198+// parseCustomView validates a definition: the name, the kinds it watches,
199+// the https transform, the trigger, the audience, the languages and the
200+// artifact size limit.
201+func (t *Tenant) parseCustomView(options customViewOptions) (customView, error) {
202+ view := customView{Name: strings.TrimSpace(options.Name), Transform: strings.TrimSpace(options.Transform), Trigger: strings.TrimSpace(options.Trigger), Audience: strings.TrimSpace(options.Audience), MaxBytes: viewDefaultMaxBytes}
203+ if !views.NamePattern.MatchString(view.Name) {
204+ return customView{}, errors.New("invalid: name must be 1 to 32 lowercase letters, digits or hyphens")
205+ }
206+ kinds, err := intList(options.Kinds)
207+ if err != nil || len(kinds) == 0 || len(kinds) > viewKindMax {
208+ return customView{}, fmt.Errorf("invalid: kinds must list 1 to %d event kinds", viewKindMax)
209+ }
210+ for _, kind := range kinds {
211+ if kind < 0 || kind > 65535 {
212+ return customView{}, errors.New("invalid: kinds must be between 0 and 65535")
213+ }
214+ }
215+ sort.Ints(kinds)
216+ view.Kinds = dedupeInts(kinds)
217+ if err := t.checkCallbackURL(view.Transform); err != nil {
218+ return customView{}, fmt.Errorf("invalid: transform %s", strings.TrimPrefix(err.Error(), "invalid: "))
219+ }
220+ if view.Trigger == "" {
221+ view.Trigger = "write"
222+ }
223+ if view.Trigger != "write" && view.Trigger != "hourly" {
224+ return customView{}, errors.New("invalid: trigger must be write or hourly")
225+ }
226+ if view.Audience == "" {
227+ view.Audience = "public"
228+ }
229+ if view.Audience != "public" && view.Audience != "members" {
230+ return customView{}, errors.New("invalid: audience must be public or members")
231+ }
232+ languages, err := stringList(options.Languages)
233+ if err != nil || len(languages) == 0 || len(languages) > viewLanguageMax {
234+ return customView{}, fmt.Errorf("invalid: languages must list 1 to %d fenced block languages", viewLanguageMax)
235+ }
236+ seen := map[string]bool{}
237+ view.Languages = nil
238+ for _, lang := range languages {
239+ lang = strings.ToLower(strings.TrimSpace(lang))
240+ if lang == "" || len(lang) > viewLanguageLength || strings.Trim(lang, "abcdefghijklmnopqrstuvwxyz0123456789+-_.#") != "" {
241+ return customView{}, errors.New("invalid: languages must be lowercase words of letters, digits, +, -, _, . or #")
242+ }
243+ if !seen[lang] {
244+ seen[lang] = true
245+ view.Languages = append(view.Languages, lang)
246+ }
247+ }
248+ if len(options.MaxBytes) > 0 && string(options.MaxBytes) != "null" && string(options.MaxBytes) != `""` {
249+ var size int
250+ var text string
251+ if json.Unmarshal(options.MaxBytes, &size) != nil {
252+ if json.Unmarshal(options.MaxBytes, &text) != nil {
253+ return customView{}, errors.New("invalid: max_bytes must be a number")
254+ }
255+ if size, err = strconv.Atoi(strings.TrimSpace(text)); err != nil {
256+ return customView{}, errors.New("invalid: max_bytes must be a number")
257+ }
258+ }
259+ if size < 1 || size > viewMaxBytesCap {
260+ return customView{}, fmt.Errorf("invalid: max_bytes must be between 1 and %d", viewMaxBytesCap)
261+ }
262+ view.MaxBytes = size
263+ }
264+ return view, nil
265+}
266+
267+// intList reads a JSON list of integers, a list of numeric strings or a
268+// comma-separated string.
269+func intList(raw json.RawMessage) ([]int, error) {
270+ if len(raw) == 0 {
271+ return nil, nil
272+ }
273+ var out []int
274+ if json.Unmarshal(raw, &out) == nil {
275+ return out, nil
276+ }
277+ items, err := stringList(raw)
278+ if err != nil {
279+ return nil, err
280+ }
281+ for _, item := range items {
282+ value, err := strconv.Atoi(strings.TrimSpace(item))
283+ if err != nil {
284+ return nil, err
285+ }
286+ out = append(out, value)
287+ }
288+ return out, nil
289+}
290+
291+// stringList reads a JSON list of strings or a comma-separated string.
292+func stringList(raw json.RawMessage) ([]string, error) {
293+ if len(raw) == 0 {
294+ return nil, nil
295+ }
296+ var out []string
297+ if json.Unmarshal(raw, &out) == nil {
298+ return out, nil
299+ }
300+ var text string
301+ if err := json.Unmarshal(raw, &text); err != nil {
302+ return nil, err
303+ }
304+ for _, item := range strings.FieldsFunc(text, func(r rune) bool { return r == ',' || r == ' ' || r == '\n' }) {
305+ if item = strings.TrimSpace(item); item != "" {
306+ out = append(out, item)
307+ }
308+ }
309+ return out, nil
310+}
311+
312+func dedupeInts(values []int) []int {
313+ out := values[:0]
314+ for i, value := range values {
315+ if i == 0 || value != values[i-1] {
316+ out = append(out, value)
317+ }
318+ }
319+ return out
320+}
321+
322+func (t *Tenant) changeCustomView(ctx context.Context, actor, method string, params []json.RawMessage) (any, error) {
323+ name := customViewNameParam(params)
324+ if name == "" {
325+ return nil, errors.New("invalid: view name required")
326+ }
327+ view, err := t.customViewByName(ctx, name)
328+ if err != nil {
329+ return nil, err
330+ }
331+ switch method {
332+ case "removecustomview":
333+ if err := t.removeCustomView(ctx, view); err != nil {
334+ return nil, err
335+ }
336+ case "pausecustomview":
337+ _, err = t.store.DB().ExecContext(ctx, `UPDATE custom_views SET enabled=0, last_status='paused' WHERE name=?`, name)
338+ view.Enabled, view.LastStatus = false, "paused"
339+ case "resumecustomview":
340+ _, err = t.store.DB().ExecContext(ctx, `UPDATE custom_views SET enabled=1, failures=0, last_status='' WHERE name=?`, name)
341+ view.Enabled, view.Failures, view.LastStatus = true, 0, ""
342+ case "runcustomview":
343+ if !view.Enabled {
344+ return nil, errors.New("invalid: resume the view before running it")
345+ }
346+ queued, err := t.queueCustomViewBackfill(ctx, view, 0, strconv.FormatInt(time.Now().UnixNano(), 10))
347+ if err != nil {
348+ return nil, err
349+ }
350+ if err := t.community.Record(ctx, actor, method, name, strconv.Itoa(queued)); err != nil {
351+ return nil, err
352+ }
353+ t.app.telemetry.Logger().Info("custom view run queued", "tenant", t.meta.Name, "queued", queued)
354+ out := view.summary()
355+ out["queued"] = queued
356+ return out, nil
357+ default:
358+ return nil, fmt.Errorf("unsupported: management method %q", method)
359+ }
360+ if err != nil {
361+ return nil, err
362+ }
363+ t.invalidateCustomViews()
364+ if err := t.community.Record(ctx, actor, method, name, ""); err != nil {
365+ return nil, err
366+ }
367+ if method == "removecustomview" {
368+ return map[string]any{"name": name, "removed": true}, nil
369+ }
370+ return view.summary(), nil
371+}
372+
373+// removeCustomView deletes the definition and every artifact it produced,
374+// including the stored public records.
375+func (t *Tenant) removeCustomView(ctx context.Context, view customView) error {
376+ ids, err := t.customViewArtifactIDs(ctx, `SELECT event_id FROM custom_view_artifacts WHERE view=?`, view.Name)
377+ if err != nil {
378+ return err
379+ }
380+ if err := t.store.WithTx(ctx, func(tx *sql.Tx) error {
381+ for _, table := range []string{"custom_view_sources", "custom_view_artifacts", "custom_views"} {
382+ column := "view"
383+ if table == "custom_views" {
384+ column = "name"
385+ }
386+ if _, err := tx.ExecContext(ctx, "DELETE FROM "+table+" WHERE "+column+"=?", view.Name); err != nil {
387+ return err
388+ }
389+ }
390+ return nil
391+ }); err != nil {
392+ return err
393+ }
394+ return t.deleteStoredArtifacts(ctx, ids)
395+}
396+
397+// customViewNameParam reads the name from a bare string or from {"name": ...}.
398+func customViewNameParam(params []json.RawMessage) string {
399+ if len(params) == 0 {
400+ return ""
401+ }
402+ if name := stringParam(params, 0); name != "" {
403+ return name
404+ }
405+ var options struct {
406+ Name string `json:"name"`
407+ }
408+ _ = json.Unmarshal(params[0], &options)
409+ return strings.TrimSpace(options.Name)
410+}
411+
412+func (t *Tenant) customViewByName(ctx context.Context, name string) (customView, error) {
413+ rows, err := t.customViewRows(ctx, name)
414+ if err != nil {
415+ return customView{}, err
416+ }
417+ if len(rows) == 0 {
418+ return customView{}, errors.New("not found: view")
419+ }
420+ return rows[0], nil
421+}
422+
423+// customViewRows lists definitions, narrowed to one name.
424+func (t *Tenant) customViewRows(ctx context.Context, names ...string) ([]customView, error) {
425+ query := `SELECT name,kinds,transform,trigger,audience,languages,max_bytes,secret,enabled,created_at,last_run_at,last_status,failures FROM custom_views`
426+ args := []any{}
427+ if len(names) > 0 {
428+ query += ` WHERE name=?`
429+ args = append(args, names[0])
430+ }
431+ query += ` ORDER BY created_at,name`
432+ rows, err := t.store.DB().QueryContext(ctx, query, args...)
433+ if err != nil {
434+ return nil, fmt.Errorf("list custom views: %w", err)
435+ }
436+ defer rows.Close()
437+ var out []customView
438+ for rows.Next() {
439+ var item customView
440+ var kinds, languages string
441+ var enabled int
442+ if err := rows.Scan(&item.Name, &kinds, &item.Transform, &item.Trigger, &item.Audience, &languages, &item.MaxBytes, &item.secret, &enabled, &item.CreatedAt, &item.LastRunAt, &item.LastStatus, &item.Failures); err != nil {
443+ return nil, err
444+ }
445+ item.Enabled = enabled != 0
446+ if err := json.Unmarshal([]byte(kinds), &item.Kinds); err != nil {
447+ return nil, fmt.Errorf("custom view kinds: %w", err)
448+ }
449+ if err := json.Unmarshal([]byte(languages), &item.Languages); err != nil {
450+ return nil, fmt.Errorf("custom view languages: %w", err)
451+ }
452+ out = append(out, item)
453+ }
454+ return out, rows.Err()
455+}
456+
457+// customViewIndex returns the enabled views indexed by kind, built on first
458+// use and dropped whenever a view changes.
459+func (t *Tenant) customViewIndex(ctx context.Context) map[int][]customView {
460+ t.viewMu.Lock()
461+ defer t.viewMu.Unlock()
462+ if t.viewIndex != nil {
463+ return t.viewIndex
464+ }
465+ index := map[int][]customView{}
466+ rows, err := t.customViewRows(ctx)
467+ if err != nil {
468+ t.app.telemetry.Logger().Error("load custom views", "tenant", t.meta.Name, "error", err)
469+ return index
470+ }
471+ for _, row := range rows {
472+ if !row.Enabled {
473+ continue
474+ }
475+ for _, kind := range row.Kinds {
476+ index[kind] = append(index[kind], row)
477+ }
478+ }
479+ t.viewIndex = index
480+ return index
481+}
482+
483+func (t *Tenant) invalidateCustomViews() {
484+ t.viewMu.Lock()
485+ t.viewIndex = nil
486+ t.viewMu.Unlock()
487+}
488+
489+// CustomViews is the summary the web UI renders with: the enabled views'
490+// names and languages, nothing else.
491+func (t *Tenant) CustomViews() []views.View {
492+ seen := map[string]bool{}
493+ var out []views.View
494+ for _, list := range t.customViewIndex(context.Background()) {
495+ for _, view := range list {
496+ if !seen[view.Name] {
497+ seen[view.Name] = true
498+ out = append(out, views.View{Name: view.Name, Languages: append([]string(nil), view.Languages...)})
499+ }
500+ }
501+ }
502+ sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
503+ return out
504+}
505+
506+// queueCustomViews runs after an event is stored or released. Each enabled
507+// write-triggered view that watches the kind and finds a block it renders
508+// gets one transform intent. It never fails the publish.
509+func (t *Tenant) queueCustomViews(ctx context.Context, e event.Event) {
510+ candidates := t.customViewIndex(ctx)[e.Kind]
511+ if len(candidates) == 0 {
512+ return
513+ }
514+ if e.Kind == event.KIND_REPO_STATE && (t.git == nil || t.git.IsPending(e.ID)) {
515+ // The README is read from the objects, which have not arrived yet;
516+ // releaseGit queues the state once they have.
517+ return
518+ }
519+ var intents []storage.Intent
520+ for _, view := range candidates {
521+ if view.Trigger != "write" {
522+ continue
523+ }
524+ if e.Kind != event.KIND_REPO_STATE && len(views.Matching(views.Blocks(e.Content), view.Languages)) == 0 {
525+ continue
526+ }
527+ intents = append(intents, t.viewIntent(view, e, ""))
528+ }
529+ if len(intents) == 0 {
530+ return
531+ }
532+ err := t.store.WithTx(ctx, func(tx *sql.Tx) error {
533+ return storage.AddIntents(ctx, tx, intents, time.Now().Unix())
534+ })
535+ if err != nil {
536+ t.app.telemetry.Logger().Error("view transforms not queued", "tenant", t.meta.Name, "error", err)
537+ return
538+ }
539+ t.app.telemetry.Logger().Debug("view transforms queued", "tenant", t.meta.Name, "queued", len(intents))
540+}
541+
542+// viewIntent is the transform intent for one source. The event travels in
543+// the payload; run makes a backfill or an hourly pass distinct from the
544+// write-time intent for the same event.
545+func (t *Tenant) viewIntent(view customView, e event.Event, run string) storage.Intent {
546+ payload, _ := json.Marshal(viewPayload{Event: e, Attempt: 1, Run: run})
547+ id := e.ID
548+ if run != "" {
549+ id += "@" + run
550+ }
551+ return storage.Intent{Kind: viewTransform, EventID: id, Target: view.Name, Payload: string(payload)}
552+}
553+
554+// queueCustomViewBackfill queues the newest sources of a view's kinds, at
555+// most 500, written since the given time. Repository states are queued
556+// whether or not their README has blocks; the handler reads it.
557+func (t *Tenant) queueCustomViewBackfill(ctx context.Context, view customView, since int64, run string) (int, error) {
558+ rows, err := t.store.Query(ctx, event.Filter{Kinds: view.Kinds, Tags: map[string][]string{}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: viewBackfillLimit})
559+ if err != nil {
560+ return 0, err
561+ }
562+ var intents []storage.Intent
563+ for _, e := range rows.Events {
564+ if e.CreatedAt < since {
565+ continue
566+ }
567+ if e.Kind == event.KIND_REPO_STATE {
568+ if t.git == nil || t.git.IsPending(e.ID) {
569+ continue
570+ }
571+ } else if len(views.Matching(views.Blocks(e.Content), view.Languages)) == 0 {
572+ continue
573+ }
574+ intents = append(intents, t.viewIntent(view, e, run))
575+ }
576+ if len(intents) == 0 {
577+ return 0, nil
578+ }
579+ err = t.store.WithTx(ctx, func(tx *sql.Tx) error {
580+ return storage.AddIntents(ctx, tx, intents, time.Now().Unix())
581+ })
582+ return len(intents), err
583+}
584+
585+// tickCustomViews queues the hourly views that are due. It is called from
586+// the tenant scheduler on its own timer.
587+func (t *Tenant) tickCustomViews(ctx context.Context, now int64) error {
588+ rows, err := t.customViewRows(ctx)
589+ if err != nil {
590+ return err
591+ }
592+ for _, view := range rows {
593+ if !view.Enabled || view.Trigger != "hourly" || now-view.LastRunAt < viewHourlyPeriod {
594+ continue
595+ }
596+ since := view.LastRunAt - 300
597+ if view.LastRunAt == 0 {
598+ since = 0
599+ }
600+ queued, err := t.queueCustomViewBackfill(ctx, view, since, strconv.FormatInt(now/viewHourlyPeriod, 10))
601+ if err != nil {
602+ return err
603+ }
604+ if _, err := t.store.DB().ExecContext(ctx, `UPDATE custom_views SET last_run_at=? WHERE name=?`, now, view.Name); err != nil {
605+ return err
606+ }
607+ if queued > 0 {
608+ t.app.telemetry.Logger().Info("custom view hourly run queued", "tenant", t.meta.Name, "queued", queued)
609+ }
610+ }
611+ return nil
612+}
613diff --git a/internal/daemon/custom_views_http.go b/internal/daemon/custom_views_http.go
614new file mode 100644
615index 0000000..4ba3ebc
616--- /dev/null
617+++ b/internal/daemon/custom_views_http.go
618@@ -0,0 +1,105 @@
619+package daemon
620+
621+// GET /views/<name>/<hash>.<svg|png> serves a custom view artifact under a
622+// sandbox policy. A members-only view answers only members; a public one
623+// follows the relay's read rule.
624+
625+import (
626+ "net/http"
627+ "strconv"
628+ "strings"
629+
630+ "github.com/FelineStateMachine/tinyrelay/internal/views"
631+)
632+
633+func isViewArtifactPath(path string) bool {
634+ return strings.HasPrefix(path, "/views/")
635+}
636+
637+func (t *Tenant) viewArtifactHTTP(w http.ResponseWriter, r *http.Request) {
638+ ctx, finish := t.app.telemetry.Start(r.Context(), "view")
639+ r = r.WithContext(ctx)
640+ outcome := "error"
641+ defer func() { finish(outcome) }()
642+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
643+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
644+ outcome = "invalid"
645+ return
646+ }
647+ parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/views/"), "/")
648+ if len(parts) != 2 {
649+ http.NotFound(w, r)
650+ outcome = "invalid"
651+ return
652+ }
653+ name := parts[0]
654+ hash, extension, _ := strings.Cut(parts[1], ".")
655+ if !views.NamePattern.MatchString(name) || !views.HashPattern.MatchString(hash) || (extension != "svg" && extension != "png") {
656+ http.NotFound(w, r)
657+ outcome = "invalid"
658+ return
659+ }
660+ actor, err := t.resolveUIActor(r)
661+ if err != nil {
662+ http.Error(w, err.Error(), http.StatusUnauthorized)
663+ outcome = "unauthorized"
664+ return
665+ }
666+ record, err := t.customViewArtifact(ctx, name, hash)
667+ if err != nil {
668+ if strings.HasPrefix(err.Error(), "not found:") {
669+ http.NotFound(w, r)
670+ outcome = "invalid"
671+ return
672+ }
673+ http.Error(w, err.Error(), http.StatusInternalServerError)
674+ return
675+ }
676+ wantType := map[string]string{"svg": "image/svg+xml", "png": "image/png"}[extension]
677+ if record.Type != wantType {
678+ http.NotFound(w, r)
679+ outcome = "invalid"
680+ return
681+ }
682+ if record.Audience == "members" {
683+ role, err := t.community.Role(ctx, actor)
684+ if err != nil {
685+ http.Error(w, err.Error(), http.StatusInternalServerError)
686+ return
687+ }
688+ if actor == "" {
689+ http.Error(w, "auth-required: this view is for members", http.StatusUnauthorized)
690+ outcome = "unauthorized"
691+ return
692+ }
693+ if role != "member" && role != "moderator" && role != "owner" {
694+ http.Error(w, "restricted: this view is for members", http.StatusForbidden)
695+ outcome = "unauthorized"
696+ return
697+ }
698+ } else if err := t.browseRead(ctx, actor); err != nil {
699+ browseHTTPError(w, err)
700+ outcome = "unauthorized"
701+ return
702+ }
703+ body, err := record.body()
704+ if err != nil {
705+ http.Error(w, "artifact is unreadable", http.StatusInternalServerError)
706+ return
707+ }
708+ w.Header().Set("Content-Type", record.Type)
709+ w.Header().Set("Content-Length", strconv.Itoa(len(body)))
710+ w.Header().Set("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'")
711+ w.Header().Set("X-Content-Type-Options", "nosniff")
712+ if record.Audience == "members" {
713+ w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
714+ } else {
715+ w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
716+ }
717+ outcome = "ok"
718+ if r.Method == http.MethodHead {
719+ w.WriteHeader(http.StatusOK)
720+ return
721+ }
722+ _, _ = w.Write(body)
723+}
724diff --git a/internal/daemon/custom_views_transform.go b/internal/daemon/custom_views_transform.go
725new file mode 100644
726index 0000000..9b33471
727--- /dev/null
728+++ b/internal/daemon/custom_views_transform.go
729@@ -0,0 +1,819 @@
730+package daemon
731+
732+// Custom view transforms. Each source event is one POST of its fenced
733+// blocks to the view's transform, signed with the view's secret. What comes
734+// back is checked, signed by the relay and attached to the source. A failed
735+// POST is tried again after one minute and then after five; after 20
736+// failures in a row the view is paused until the owner resumes it.
737+
738+import (
739+ "bytes"
740+ "context"
741+ "crypto/sha256"
742+ "crypto/tls"
743+ "database/sql"
744+ "encoding/base64"
745+ "encoding/hex"
746+ "encoding/json"
747+ "errors"
748+ "fmt"
749+ "io"
750+ "net/http"
751+ "regexp"
752+ "sort"
753+ "strconv"
754+ "strings"
755+ "sync"
756+ "time"
757+
758+ "github.com/FelineStateMachine/tinyrelay/internal/event"
759+ "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
760+ "github.com/FelineStateMachine/tinyrelay/internal/replication"
761+ "github.com/FelineStateMachine/tinyrelay/internal/views"
762+ "github.com/FelineStateMachine/tinyrelay/internal/work"
763+)
764+
765+// viewPayload is the work intent body: the source event, so the run does
766+// not depend on the event still being stored, the attempt number and the
767+// run that queued it.
768+type viewPayload struct {
769+ Event event.Event `json:"event"`
770+ Attempt int `json:"attempt"`
771+ Run string `json:"run,omitempty"`
772+}
773+
774+// viewRequest is the body posted to a transform: only the blocks, with the
775+// source named so the transform can correlate. No event content, tags,
776+// author or time leaves the relay.
777+type viewRequest struct {
778+ Relay string `json:"relay"`
779+ View string `json:"view"`
780+ Source viewSource `json:"source"`
781+ Blocks []views.Block `json:"blocks"`
782+}
783+
784+type viewSource struct {
785+ ID string `json:"id"`
786+ Kind int `json:"kind"`
787+}
788+
789+// viewResponse is what a transform answers with.
790+type viewResponse struct {
791+ Artifacts []viewArtifact `json:"artifacts"`
792+ Errors []viewError `json:"errors"`
793+}
794+
795+type viewArtifact struct {
796+ Block int `json:"block"`
797+ Type string `json:"type"`
798+ Body string `json:"body"`
799+ Engine string `json:"engine"`
800+}
801+
802+type viewError struct {
803+ Block int `json:"block"`
804+ Error string `json:"error"`
805+}
806+
807+// viewSourceKey names a source in the artifact tables: the event id, or the
808+// coordinate of a repository state so a new state replaces its README.
809+func viewSourceKey(e event.Event) string {
810+ if e.Kind == event.KIND_REPO_STATE {
811+ return strconv.Itoa(e.Kind) + ":" + e.PubKey + ":" + event.Tag(e, "d")
812+ }
813+ return e.ID
814+}
815+
816+// viewSourceTag is the tag an artifact carries for a source.
817+func viewSourceTag(source string) []string {
818+ if strings.HasPrefix(source, strconv.Itoa(event.KIND_REPO_STATE)+":") {
819+ return []string{"a", source}
820+ }
821+ return []string{"e", source}
822+}
823+
824+// viewHTTPClient refuses private addresses and redirects unless the relay
825+// itself runs on a loopback address.
826+func (t *Tenant) viewHTTPClient() *http.Client {
827+ if t.viewClient != nil {
828+ return t.viewClient
829+ }
830+ if t.loopbackRelay() {
831+ return &http.Client{Timeout: viewTimeout, Transport: &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}}, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
832+ }
833+ return replication.NewPinnedClient(viewTimeout)
834+}
835+
836+// viewLock bounds work to one transform run per view at a time.
837+func (t *Tenant) viewLock(name string) *sync.Mutex {
838+ t.viewMu.Lock()
839+ defer t.viewMu.Unlock()
840+ if t.viewLocks == nil {
841+ t.viewLocks = map[string]*sync.Mutex{}
842+ }
843+ lock := t.viewLocks[name]
844+ if lock == nil {
845+ lock = &sync.Mutex{}
846+ t.viewLocks[name] = lock
847+ }
848+ return lock
849+}
850+
851+// sourceBlocks finds the blocks of a source: the fenced blocks of its
852+// content, or of the README at the head of a repository state.
853+func (t *Tenant) sourceBlocks(ctx context.Context, e event.Event) ([]views.Block, error) {
854+ if e.Kind != event.KIND_REPO_STATE {
855+ return views.Blocks(e.Content), nil
856+ }
857+ readme, err := t.stateReadme(ctx, e)
858+ if err != nil {
859+ return nil, err
860+ }
861+ return views.Blocks(readme), nil
862+}
863+
864+// stateReadme reads README.md at the head named by a repository state. A
865+// missing README yields no content.
866+func (t *Tenant) stateReadme(ctx context.Context, e event.Event) (string, error) {
867+ if t.git == nil {
868+ return "", nil
869+ }
870+ head := strings.TrimPrefix(event.Tag(e, "HEAD"), "ref: ")
871+ commit := ""
872+ for _, tag := range e.Tags {
873+ if len(tag) >= 2 && tag[0] == head {
874+ commit = tag[1]
875+ }
876+ }
877+ if head == "" || commit == "" {
878+ return "", nil
879+ }
880+ for _, name := range []string{"README.md", "readme.md", "README"} {
881+ page, err := t.git.Browse(ctx, gitrelay.BrowseRequest{Owner: e.PubKey, Repo: event.Tag(e, "d"), Ref: commit, Path: name, View: "file", Limit: 1})
882+ if err != nil {
883+ if strings.HasPrefix(err.Error(), "not found:") || strings.HasPrefix(err.Error(), "invalid:") {
884+ continue
885+ }
886+ return "", err
887+ }
888+ if page.Binary || page.Content == "" {
889+ continue
890+ }
891+ return page.Content, nil
892+ }
893+ return "", nil
894+}
895+
896+// sourceCurrent reports whether the source is still the stored event: an
897+// event that was deleted, or a state that a newer state replaced, renders
898+// nothing.
899+func (t *Tenant) sourceCurrent(ctx context.Context, e event.Event) (bool, error) {
900+ var id string
901+ var err error
902+ if e.Kind == event.KIND_REPO_STATE {
903+ 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)
904+ } else {
905+ err = t.store.DB().QueryRowContext(ctx, `SELECT id FROM events WHERE id=?`, e.ID).Scan(&id)
906+ }
907+ if errors.Is(err, sql.ErrNoRows) {
908+ return false, nil
909+ }
910+ return id == e.ID, err
911+}
912+
913+// handleViewTransform serves one view-transform intent. It rechecks the
914+// view and the source right before the POST, attaches artifacts that
915+// already exist for a block's hash and posts only the blocks that need
916+// rendering.
917+func (t *Tenant) handleViewTransform(ctx context.Context, intent work.Intent) (err error) {
918+ ctx, finish := t.app.telemetry.Start(ctx, "view")
919+ outcome := "error"
920+ defer func() { finish(outcome) }()
921+ var payload viewPayload
922+ if err := json.Unmarshal([]byte(intent.Payload), &payload); err != nil || payload.Event.ID == "" {
923+ outcome = "invalid"
924+ return errors.New("view-transform: invalid payload")
925+ }
926+ if payload.Attempt < 1 {
927+ payload.Attempt = 1
928+ }
929+ lock := t.viewLock(intent.Target)
930+ lock.Lock()
931+ defer lock.Unlock()
932+ view, err := t.customViewByName(ctx, intent.Target)
933+ if err != nil {
934+ if strings.HasPrefix(err.Error(), "not found:") {
935+ outcome = "paused"
936+ return nil
937+ }
938+ return err
939+ }
940+ if !view.Enabled {
941+ outcome = "paused"
942+ return nil
943+ }
944+ current, err := t.sourceCurrent(ctx, payload.Event)
945+ if err != nil {
946+ return err
947+ }
948+ if !current {
949+ outcome = "invalid"
950+ return nil
951+ }
952+ blocks, err := t.sourceBlocks(ctx, payload.Event)
953+ if err != nil {
954+ return err
955+ }
956+ blocks = views.Matching(blocks, view.Languages)
957+ source := viewSourceKey(payload.Event)
958+ expires := event.Expiration(payload.Event)
959+ // Attach the blocks that already have an artifact and keep the rest.
960+ var pending []views.Block
961+ hashes := make([]string, 0, len(blocks))
962+ for _, block := range blocks {
963+ hash := views.Hash(block.Lang, block.Source)
964+ hashes = append(hashes, hash)
965+ exists, err := t.attachArtifact(ctx, view, hash, source, block.Index, expires)
966+ if err != nil {
967+ return err
968+ }
969+ if !exists {
970+ pending = append(pending, block)
971+ }
972+ }
973+ if err := t.detachStaleArtifacts(ctx, view.Name, source, hashes); err != nil {
974+ return err
975+ }
976+ if len(pending) == 0 {
977+ outcome = "ok"
978+ return t.pruneViewArtifacts(ctx, view.Name, intent.ID)
979+ }
980+ status, response, postErr := t.postViewTransform(ctx, view, payload.Event, pending)
981+ now := time.Now().Unix()
982+ if postErr == nil {
983+ stored, refused := 0, 0
984+ sent := map[int]views.Block{}
985+ for _, block := range pending {
986+ sent[block.Index] = block
987+ }
988+ for _, artifact := range response.Artifacts {
989+ block, ok := sent[artifact.Block]
990+ if !ok {
991+ refused++
992+ continue
993+ }
994+ if err := t.storeArtifact(ctx, view, block, artifact, source, expires, now); err != nil {
995+ if strings.HasPrefix(err.Error(), "invalid:") {
996+ refused++
997+ continue
998+ }
999+ return err
1000+ }
1001+ stored++
1002+ }
1003+ outcome = "ok"
1004+ if refused > 0 {
1005+ outcome = "invalid"
1006+ }
1007+ if _, err := t.store.DB().ExecContext(ctx, `UPDATE custom_views SET last_run_at=?, last_status='ok', failures=0 WHERE name=?`, now, view.Name); err != nil {
1008+ return err
1009+ }
1010+ // Counts only: no source, block or body leaves the relay in a log.
1011+ t.app.telemetry.Logger().Info("view transform delivered", "tenant", t.meta.Name, "attempt", payload.Attempt, "status", status, "blocks", len(pending), "artifacts", stored, "refused", refused, "errors", len(response.Errors))
1012+ return t.pruneViewArtifacts(ctx, view.Name, intent.ID)
1013+ }
1014+ reason := postErr.Error()
1015+ failures := view.Failures + 1
1016+ paused := failures >= viewPauseFailures
1017+ if paused {
1018+ outcome = "paused"
1019+ if err := t.pauseCustomView(ctx, view.Name, failures, fmt.Sprintf("paused after %d failures: %s", failures, reason)); err != nil {
1020+ return err
1021+ }
1022+ } else if _, err := t.store.DB().ExecContext(ctx, `UPDATE custom_views SET last_run_at=?, failures=?, last_status=? WHERE name=?`, now, failures, reason, view.Name); err != nil {
1023+ return err
1024+ }
1025+ t.app.telemetry.Logger().Info("view transform failed", "tenant", t.meta.Name, "attempt", payload.Attempt, "status", status, "failures", failures, "paused", paused)
1026+ if paused || payload.Attempt >= viewMaxAttempts {
1027+ return nil
1028+ }
1029+ return t.retryViewTransform(ctx, intent, payload)
1030+}
1031+
1032+// pauseCustomView stops the view and records the count and reason, then
1033+// drops the index so no new intents are queued for it.
1034+func (t *Tenant) pauseCustomView(ctx context.Context, name string, failures int, status string) error {
1035+ if _, err := t.store.DB().ExecContext(ctx, `UPDATE custom_views SET enabled=0, failures=?, last_status=? WHERE name=?`, failures, status, name); err != nil {
1036+ return err
1037+ }
1038+ t.invalidateCustomViews()
1039+ return nil
1040+}
1041+
1042+// retryViewTransform queues the next attempt after its backoff as its own
1043+// intent so the durable queue's ordering and fencing still apply.
1044+func (t *Tenant) retryViewTransform(ctx context.Context, intent work.Intent, payload viewPayload) error {
1045+ delay := viewBackoff[len(viewBackoff)-1]
1046+ if payload.Attempt-1 < len(viewBackoff) {
1047+ delay = viewBackoff[payload.Attempt-1]
1048+ }
1049+ payload.Attempt++
1050+ encoded, err := json.Marshal(payload)
1051+ if err != nil {
1052+ return err
1053+ }
1054+ now := time.Now().Unix()
1055+ key := sha256.Sum256([]byte(viewTransform + "\x00" + intent.EventID + "\x00" + intent.Target + "\x00" + fmt.Sprint(payload.Attempt)))
1056+ return t.store.WithTx(ctx, func(tx *sql.Tx) error {
1057+ _, 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)
1058+ return err
1059+ })
1060+}
1061+
1062+// postViewTransform sends the blocks and reads the answer. It returns the
1063+// HTTP status with a bounded reason on failure; a response that is not
1064+// JSON counts as a failure.
1065+func (t *Tenant) postViewTransform(ctx context.Context, view customView, e event.Event, blocks []views.Block) (int, viewResponse, error) {
1066+ body, err := json.Marshal(viewRequest{Relay: t.publicURL, View: view.Name, Source: viewSource{ID: viewSourceKey(e), Kind: e.Kind}, Blocks: blocks})
1067+ if err != nil {
1068+ return 0, viewResponse{}, errors.New("encode request")
1069+ }
1070+ ctx, cancel := context.WithTimeout(ctx, viewTimeout)
1071+ defer cancel()
1072+ request, err := http.NewRequestWithContext(ctx, http.MethodPost, view.Transform, bytes.NewReader(body))
1073+ if err != nil {
1074+ return 0, viewResponse{}, errors.New("bad url")
1075+ }
1076+ request.Header.Set("Content-Type", "application/json")
1077+ request.Header.Set("Accept", "application/json")
1078+ request.Header.Set("X-Tiny-View", view.Name)
1079+ request.Header.Set("X-Tiny-Relay", t.publicURL)
1080+ request.Header.Set("X-Tiny-Signature", callbackSignature(view.secret, body))
1081+ request.Header.Set("User-Agent", "tinyrelay")
1082+ response, err := t.viewHTTPClient().Do(request)
1083+ if err != nil {
1084+ if errors.Is(err, context.DeadlineExceeded) || ctx.Err() != nil {
1085+ return 0, viewResponse{}, errors.New("timeout")
1086+ }
1087+ return 0, viewResponse{}, errors.New("connection failed")
1088+ }
1089+ defer response.Body.Close()
1090+ raw, err := io.ReadAll(io.LimitReader(response.Body, viewResponseMaxBytes))
1091+ if err != nil {
1092+ return response.StatusCode, viewResponse{}, errors.New("read failed")
1093+ }
1094+ if response.StatusCode < 200 || response.StatusCode >= 300 {
1095+ return response.StatusCode, viewResponse{}, fmt.Errorf("HTTP %d", response.StatusCode)
1096+ }
1097+ var decoded viewResponse
1098+ if err := json.Unmarshal(raw, &decoded); err != nil {
1099+ return response.StatusCode, viewResponse{}, errors.New("invalid response")
1100+ }
1101+ return response.StatusCode, decoded, nil
1102+}
1103+
1104+var (
1105+ svgScript = regexp.MustCompile(`(?i)<script`)
1106+ svgHandler = regexp.MustCompile(`(?i)\bon[a-z]+\s*=`)
1107+ svgForeignObject = regexp.MustCompile(`(?i)<foreignObject`)
1108+ svgReference = regexp.MustCompile(`(?i)(?:xlink:)?href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))`)
1109+ pngSignature = []byte("\x89PNG\r\n\x1a\n")
1110+)
1111+
1112+// checkArtifact validates a transform's artifact: an accepted type, a body
1113+// within the view's size limit, a PNG that decodes from base64 and an SVG
1114+// without script, handlers, foreign objects or references outside itself.
1115+// It returns the body as stored: the SVG text or the PNG in base64.
1116+func checkArtifact(artifact viewArtifact, maxBytes int) (string, error) {
1117+ switch artifact.Type {
1118+ case "image/svg+xml":
1119+ if len(artifact.Body) > maxBytes {
1120+ return "", errors.New("invalid: artifact exceeds the size limit")
1121+ }
1122+ if err := checkSVG(artifact.Body); err != nil {
1123+ return "", err
1124+ }
1125+ return artifact.Body, nil
1126+ case "image/png":
1127+ body := strings.TrimSpace(artifact.Body)
1128+ decoded, err := base64.StdEncoding.DecodeString(body)
1129+ if err != nil {
1130+ if decoded, err = base64.RawStdEncoding.DecodeString(body); err != nil {
1131+ return "", errors.New("invalid: png body must be base64")
1132+ }
1133+ }
1134+ if len(decoded) > maxBytes {
1135+ return "", errors.New("invalid: artifact exceeds the size limit")
1136+ }
1137+ if !bytes.HasPrefix(decoded, pngSignature) {
1138+ return "", errors.New("invalid: png body is not a PNG")
1139+ }
1140+ return base64.StdEncoding.EncodeToString(decoded), nil
1141+ default:
1142+ return "", errors.New("invalid: artifact type must be image/svg+xml or image/png")
1143+ }
1144+}
1145+
1146+func checkSVG(body string) error {
1147+ if !strings.Contains(strings.ToLower(body), "<svg") {
1148+ return errors.New("invalid: svg body has no svg element")
1149+ }
1150+ if svgScript.MatchString(body) {
1151+ return errors.New("invalid: svg contains script")
1152+ }
1153+ if svgHandler.MatchString(body) {
1154+ return errors.New("invalid: svg contains an event handler")
1155+ }
1156+ if svgForeignObject.MatchString(body) {
1157+ return errors.New("invalid: svg contains a foreign object")
1158+ }
1159+ for _, match := range svgReference.FindAllStringSubmatch(body, -1) {
1160+ target := strings.TrimSpace(match[1] + match[2] + match[3])
1161+ if target == "" || strings.HasPrefix(target, "#") {
1162+ continue
1163+ }
1164+ if strings.HasPrefix(strings.ToLower(target), "javascript:") {
1165+ return errors.New("invalid: svg contains a javascript reference")
1166+ }
1167+ return errors.New("invalid: svg references an external target")
1168+ }
1169+ return nil
1170+}
1171+
1172+// storeArtifact checks one artifact and keeps it as a signed record
1173+// attached to its source.
1174+func (t *Tenant) storeArtifact(ctx context.Context, view customView, block views.Block, artifact viewArtifact, source string, expires int64, now int64) error {
1175+ content, err := checkArtifact(artifact, view.MaxBytes)
1176+ if err != nil {
1177+ return err
1178+ }
1179+ engine := strings.TrimSpace(artifact.Engine)
1180+ if len(engine) > 64 {
1181+ engine = engine[:64]
1182+ }
1183+ hash := views.Hash(block.Lang, block.Source)
1184+ err = t.store.WithTx(ctx, func(tx *sql.Tx) error {
1185+ if _, err := tx.ExecContext(ctx, `INSERT INTO custom_view_artifacts(view,hash,event_id,type,engine,audience,content,raw,created_at) VALUES(?,?,'',?,?,?,?,'',?) ON CONFLICT(view,hash) DO UPDATE SET type=excluded.type, engine=excluded.engine, audience=excluded.audience, content=excluded.content, created_at=excluded.created_at`, view.Name, hash, artifact.Type, engine, view.Audience, content, now); err != nil {
1186+ return err
1187+ }
1188+ _, err := tx.ExecContext(ctx, `INSERT INTO custom_view_sources(view,hash,source,block,expires) VALUES(?,?,?,?,?) ON CONFLICT(view,hash,source) DO UPDATE SET block=excluded.block, expires=excluded.expires`, view.Name, hash, source, block.Index, expires)
1189+ return err
1190+ })
1191+ if err != nil {
1192+ return err
1193+ }
1194+ return t.signArtifact(ctx, view, hash, now)
1195+}
1196+
1197+// attachArtifact reports whether an artifact exists for the hash and, when
1198+// it does, attaches the source to it through a fresh signed record.
1199+func (t *Tenant) attachArtifact(ctx context.Context, view customView, hash, source string, block int, expires int64) (bool, error) {
1200+ var exists int
1201+ err := t.store.DB().QueryRowContext(ctx, `SELECT 1 FROM custom_view_artifacts WHERE view=? AND hash=?`, view.Name, hash).Scan(&exists)
1202+ if errors.Is(err, sql.ErrNoRows) {
1203+ return false, nil
1204+ }
1205+ if err != nil {
1206+ return false, err
1207+ }
1208+ var current int
1209+ var currentBlock int
1210+ var currentExpires int64
1211+ err = t.store.DB().QueryRowContext(ctx, `SELECT 1,block,expires FROM custom_view_sources WHERE view=? AND hash=? AND source=?`, view.Name, hash, source).Scan(&current, &currentBlock, &currentExpires)
1212+ if err == nil && currentBlock == block && currentExpires == expires {
1213+ return true, nil
1214+ }
1215+ if err != nil && !errors.Is(err, sql.ErrNoRows) {
1216+ return false, err
1217+ }
1218+ if _, err := t.store.DB().ExecContext(ctx, `INSERT INTO custom_view_sources(view,hash,source,block,expires) VALUES(?,?,?,?,?) ON CONFLICT(view,hash,source) DO UPDATE SET block=excluded.block, expires=excluded.expires`, view.Name, hash, source, block, expires); err != nil {
1219+ return false, err
1220+ }
1221+ return true, t.signArtifact(ctx, view, hash, time.Now().Unix())
1222+}
1223+
1224+// signArtifact signs the artifact with its current sources as a kind 30078
1225+// record and stores the record when the view is public.
1226+func (t *Tenant) signArtifact(ctx context.Context, view customView, hash string, now int64) error {
1227+ var typ, engine, content string
1228+ if err := t.store.DB().QueryRowContext(ctx, `SELECT type,engine,content FROM custom_view_artifacts WHERE view=? AND hash=?`, view.Name, hash).Scan(&typ, &engine, &content); err != nil {
1229+ return err
1230+ }
1231+ rows, err := t.store.DB().QueryContext(ctx, `SELECT source,block,expires FROM custom_view_sources WHERE view=? AND hash=? ORDER BY rowid`, view.Name, hash)
1232+ if err != nil {
1233+ return err
1234+ }
1235+ tags := [][]string{{"-"}, {"d", "bind.ws/view/" + view.Name + "/" + hash}, {"view", view.Name}}
1236+ var blocks [][]string
1237+ expires := int64(0)
1238+ unlimited := false
1239+ for rows.Next() {
1240+ var source string
1241+ var block int
1242+ var sourceExpires int64
1243+ if err := rows.Scan(&source, &block, &sourceExpires); err != nil {
1244+ rows.Close()
1245+ return err
1246+ }
1247+ tags = append(tags, viewSourceTag(source))
1248+ blocks = append(blocks, []string{"block", strconv.Itoa(block)})
1249+ if sourceExpires == 0 {
1250+ unlimited = true
1251+ } else if sourceExpires > expires {
1252+ expires = sourceExpires
1253+ }
1254+ }
1255+ if err := errors.Join(rows.Err(), rows.Close()); err != nil {
1256+ return err
1257+ }
1258+ tags = append(tags, blocks...)
1259+ tags = append(tags, []string{"type", typ})
1260+ if engine != "" {
1261+ tags = append(tags, []string{"engine", engine})
1262+ }
1263+ if expires > 0 && !unlimited {
1264+ tags = append(tags, []string{"expiration", strconv.FormatInt(expires, 10)})
1265+ }
1266+ var signed event.Event
1267+ if view.Audience == "public" {
1268+ signed, err = t.records.Generate(ctx, event.KIND_VIEW, tags, content, now)
1269+ } else {
1270+ signed, err = t.records.Sign(ctx, event.KIND_VIEW, tags, content, now)
1271+ }
1272+ if err != nil {
1273+ return err
1274+ }
1275+ raw, err := json.Marshal(signed)
1276+ if err != nil {
1277+ return err
1278+ }
1279+ _, err = t.store.DB().ExecContext(ctx, `UPDATE custom_view_artifacts SET event_id=?, raw=? WHERE view=? AND hash=?`, signed.ID, string(raw), view.Name, hash)
1280+ return err
1281+}
1282+
1283+// detachStaleArtifacts drops the source from artifacts whose block it no
1284+// longer carries, then removes artifacts left without a source.
1285+func (t *Tenant) detachStaleArtifacts(ctx context.Context, name, source string, hashes []string) error {
1286+ rows, err := t.store.DB().QueryContext(ctx, `SELECT hash FROM custom_view_sources WHERE view=? AND source=?`, name, source)
1287+ if err != nil {
1288+ return err
1289+ }
1290+ keep := map[string]bool{}
1291+ for _, hash := range hashes {
1292+ keep[hash] = true
1293+ }
1294+ var stale []string
1295+ for rows.Next() {
1296+ var hash string
1297+ if err := rows.Scan(&hash); err != nil {
1298+ rows.Close()
1299+ return err
1300+ }
1301+ if !keep[hash] {
1302+ stale = append(stale, hash)
1303+ }
1304+ }
1305+ if err := errors.Join(rows.Err(), rows.Close()); err != nil {
1306+ return err
1307+ }
1308+ for _, hash := range stale {
1309+ if _, err := t.store.DB().ExecContext(ctx, `DELETE FROM custom_view_sources WHERE view=? AND hash=? AND source=?`, name, hash, source); err != nil {
1310+ return err
1311+ }
1312+ }
1313+ return t.dropOrphanArtifacts(ctx, name)
1314+}
1315+
1316+// pruneViewArtifacts detaches sources that no longer exist and removes the
1317+// artifacts that lose their last source. A view with transform work still
1318+// queued is left alone so a replaced source can reattach its blocks first.
1319+func (t *Tenant) pruneViewArtifacts(ctx context.Context, name, except string) error {
1320+ var pending int
1321+ if err := t.store.DB().QueryRowContext(ctx, `SELECT count(*) FROM work_intents WHERE kind=? AND target=? AND state IN ('pending','running') AND id<>?`, viewTransform, name, except).Scan(&pending); err != nil {
1322+ return err
1323+ }
1324+ if pending > 0 {
1325+ return nil
1326+ }
1327+ rows, err := t.store.DB().QueryContext(ctx, `SELECT DISTINCT source FROM custom_view_sources WHERE view=?`, name)
1328+ if err != nil {
1329+ return err
1330+ }
1331+ var sources []string
1332+ for rows.Next() {
1333+ var source string
1334+ if err := rows.Scan(&source); err != nil {
1335+ rows.Close()
1336+ return err
1337+ }
1338+ sources = append(sources, source)
1339+ }
1340+ if err := errors.Join(rows.Err(), rows.Close()); err != nil {
1341+ return err
1342+ }
1343+ removed := 0
1344+ for _, source := range sources {
1345+ present, err := t.sourcePresent(ctx, source)
1346+ if err != nil {
1347+ return err
1348+ }
1349+ if present {
1350+ continue
1351+ }
1352+ if _, err := t.store.DB().ExecContext(ctx, `DELETE FROM custom_view_sources WHERE view=? AND source=?`, name, source); err != nil {
1353+ return err
1354+ }
1355+ removed++
1356+ }
1357+ if removed == 0 {
1358+ return nil
1359+ }
1360+ return t.dropOrphanArtifacts(ctx, name)
1361+}
1362+
1363+// pruneAllViewArtifacts runs the prune for every view; the maintenance
1364+// sweep calls it after expired events are removed.
1365+func (t *Tenant) pruneAllViewArtifacts(ctx context.Context) error {
1366+ rows, err := t.customViewRows(ctx)
1367+ if err != nil {
1368+ return err
1369+ }
1370+ for _, view := range rows {
1371+ if err := t.pruneViewArtifacts(ctx, view.Name, ""); err != nil {
1372+ return err
1373+ }
1374+ }
1375+ return nil
1376+}
1377+
1378+func (t *Tenant) sourcePresent(ctx context.Context, source string) (bool, error) {
1379+ var one int
1380+ var err error
1381+ if tag := viewSourceTag(source); tag[0] == "a" {
1382+ parts := strings.SplitN(source, ":", 3)
1383+ if len(parts) != 3 {
1384+ return false, nil
1385+ }
1386+ 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)
1387+ } else {
1388+ 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)
1389+ }
1390+ if errors.Is(err, sql.ErrNoRows) {
1391+ return false, nil
1392+ }
1393+ return err == nil, err
1394+}
1395+
1396+// dropOrphanArtifacts removes artifacts without a source, and their stored
1397+// records, then re-signs the artifacts whose source list shrank.
1398+func (t *Tenant) dropOrphanArtifacts(ctx context.Context, name string) error {
1399+ ids, err := t.customViewArtifactIDs(ctx, `SELECT event_id FROM custom_view_artifacts WHERE view=? AND NOT EXISTS(SELECT 1 FROM custom_view_sources WHERE custom_view_sources.view=custom_view_artifacts.view AND custom_view_sources.hash=custom_view_artifacts.hash)`, name)
1400+ if err != nil {
1401+ return err
1402+ }
1403+ if _, err := t.store.DB().ExecContext(ctx, `DELETE FROM custom_view_artifacts WHERE view=? AND NOT EXISTS(SELECT 1 FROM custom_view_sources WHERE custom_view_sources.view=custom_view_artifacts.view AND custom_view_sources.hash=custom_view_artifacts.hash)`, name); err != nil {
1404+ return err
1405+ }
1406+ if err := t.deleteStoredArtifacts(ctx, ids); err != nil {
1407+ return err
1408+ }
1409+ if len(ids) > 0 {
1410+ t.app.telemetry.Logger().Info("view artifacts removed", "tenant", t.meta.Name, "removed", len(ids))
1411+ }
1412+ // Artifacts that kept a source but lost one carry a tag for it; a fresh
1413+ // record drops it.
1414+ view, err := t.customViewByName(ctx, name)
1415+ if err != nil {
1416+ return nil
1417+ }
1418+ rows, err := t.store.DB().QueryContext(ctx, `SELECT a.hash, a.raw FROM custom_view_artifacts a WHERE a.view=?`, name)
1419+ if err != nil {
1420+ return err
1421+ }
1422+ type stale struct{ hash string }
1423+ var refresh []stale
1424+ for rows.Next() {
1425+ var hash, raw string
1426+ if err := rows.Scan(&hash, &raw); err != nil {
1427+ rows.Close()
1428+ return err
1429+ }
1430+ var signed event.Event
1431+ if json.Unmarshal([]byte(raw), &signed) != nil {
1432+ continue
1433+ }
1434+ var tagged []string
1435+ for _, tag := range signed.Tags {
1436+ if len(tag) == 2 && (tag[0] == "e" || tag[0] == "a") {
1437+ tagged = append(tagged, tag[1])
1438+ }
1439+ }
1440+ current, err := t.artifactSources(ctx, name, hash)
1441+ if err != nil {
1442+ rows.Close()
1443+ return err
1444+ }
1445+ sort.Strings(tagged)
1446+ sort.Strings(current)
1447+ if strings.Join(tagged, "\n") != strings.Join(current, "\n") {
1448+ refresh = append(refresh, stale{hash})
1449+ }
1450+ }
1451+ if err := errors.Join(rows.Err(), rows.Close()); err != nil {
1452+ return err
1453+ }
1454+ for _, item := range refresh {
1455+ if err := t.signArtifact(ctx, view, item.hash, time.Now().Unix()); err != nil {
1456+ return err
1457+ }
1458+ }
1459+ return nil
1460+}
1461+
1462+func (t *Tenant) artifactSources(ctx context.Context, name, hash string) ([]string, error) {
1463+ rows, err := t.store.DB().QueryContext(ctx, `SELECT source FROM custom_view_sources WHERE view=? AND hash=?`, name, hash)
1464+ if err != nil {
1465+ return nil, err
1466+ }
1467+ defer rows.Close()
1468+ var out []string
1469+ for rows.Next() {
1470+ var source string
1471+ if err := rows.Scan(&source); err != nil {
1472+ return nil, err
1473+ }
1474+ out = append(out, source)
1475+ }
1476+ return out, rows.Err()
1477+}
1478+
1479+func (t *Tenant) customViewArtifactIDs(ctx context.Context, query string, args ...any) ([]string, error) {
1480+ rows, err := t.store.DB().QueryContext(ctx, query, args...)
1481+ if err != nil {
1482+ return nil, err
1483+ }
1484+ defer rows.Close()
1485+ var ids []string
1486+ for rows.Next() {
1487+ var id string
1488+ if err := rows.Scan(&id); err != nil {
1489+ return nil, err
1490+ }
1491+ if id != "" {
1492+ ids = append(ids, id)
1493+ }
1494+ }
1495+ return ids, rows.Err()
1496+}
1497+
1498+// deleteStoredArtifacts removes the public records of artifacts by id.
1499+func (t *Tenant) deleteStoredArtifacts(ctx context.Context, ids []string) error {
1500+ for _, id := range ids {
1501+ if _, err := t.store.DeleteEvent(ctx, id); err != nil {
1502+ return err
1503+ }
1504+ }
1505+ return nil
1506+}
1507+
1508+// cascadeViewDeletion runs after a kind 5 deletion is stored: artifacts
1509+// attached to the deleted events or addresses lose that source at once.
1510+func (t *Tenant) cascadeViewDeletion(ctx context.Context, e event.Event) {
1511+ if e.Kind != event.KIND_DELETION {
1512+ return
1513+ }
1514+ rows, err := t.customViewRows(ctx)
1515+ if err != nil || len(rows) == 0 {
1516+ return
1517+ }
1518+ for _, view := range rows {
1519+ if err := t.pruneViewArtifacts(ctx, view.Name, ""); err != nil {
1520+ t.app.telemetry.Logger().Error("view artifacts not pruned", "tenant", t.meta.Name, "error", err)
1521+ }
1522+ }
1523+}
1524+
1525+// artifactRecord is one stored artifact as the route serves it.
1526+type artifactRecord struct {
1527+ Type string
1528+ Audience string
1529+ Content string
1530+ EventID string
1531+}
1532+
1533+func (t *Tenant) customViewArtifact(ctx context.Context, name, hash string) (artifactRecord, error) {
1534+ var record artifactRecord
1535+ err := t.store.DB().QueryRowContext(ctx, `SELECT type,audience,content,event_id FROM custom_view_artifacts WHERE view=? AND hash=?`, name, hash).Scan(&record.Type, &record.Audience, &record.Content, &record.EventID)
1536+ if errors.Is(err, sql.ErrNoRows) {
1537+ return artifactRecord{}, errors.New("not found: artifact")
1538+ }
1539+ return record, err
1540+}
1541+
1542+// body returns the bytes served for the artifact.
1543+func (r artifactRecord) body() ([]byte, error) {
1544+ if r.Type == "image/png" {
1545+ return base64.StdEncoding.DecodeString(r.Content)
1546+ }
1547+ return []byte(r.Content), nil
1548+}
1549diff --git a/internal/daemon/http.go b/internal/daemon/http.go
1550index f5ab888..9c47d81 100644
1551--- a/internal/daemon/http.go
1552+++ b/internal/daemon/http.go
1553@@ -83,6 +83,10 @@ func (t *Tenant) ServeHTTP(w http.ResponseWriter, r *http.Request) {
1554 if t.tryBrowseHTTP(w, r) {
1555 return
1556 }
1557+ if isViewArtifactPath(r.URL.Path) {
1558+ t.viewArtifactHTTP(w, r)
1559+ return
1560+ }
1561 if t.sites != nil && t.sites.MatchesHost(r.Host) {
1562 t.sites.Handler().ServeHTTP(w, r)
1563 return
1564diff --git a/internal/daemon/management.go b/internal/daemon/management.go
1565index 4262486..81d2e7d 100644
1566--- a/internal/daemon/management.go
1567+++ b/internal/daemon/management.go
1568@@ -50,6 +50,10 @@ func (t *Tenant) executeManagement(ctx context.Context, actor, method string, pa
1569 result, err = t.callbackExecute(ctx, actor, method, params)
1570 return result, true, err
1571 }
1572+ if customViewMethod(method) {
1573+ result, err = t.customViewExecute(ctx, actor, method, params)
1574+ return result, true, err
1575+ }
1576 if !managementAdapterMethod(method) {
1577 return nil, false, nil
1578 }
1579@@ -138,7 +142,7 @@ func publicManagementMethod(method string) bool {
1580 // ManagementMethods is the source-compatible NIP-86 registry. The hosted
1581 // lease, trial, fuel, and entitlement controls are intentionally absent.
1582 func ManagementMethods() []string {
1583- return []string{"supportedmethods", "listaudit", "stats", "getpolicy", "setpolicy", "listviews", "banpubkey", "setblockedwords", "allowpubkey", "setmember", "unrulepubkey", "removemember", "listbannedpubkeys", "listallowedpubkeys", "listmembers", "listpeople", "listjoinrequests", "approvejoin", "denyjoin", "createinvite", "listinvites", "revokeinvite", "listclaims", "listlisthistory", "restorelist", "createclaim", "deleteclaim", "removesubtree", "banevent", "allowevent", "listeventsneedingmoderation", "blockip", "unblockip", "listblockedips", "listreports", "resolvereport", "exportconfig", "importconfig", "deleterelay", "listblobs", "listsites", "deleteblob", "deleteevent", "listrecentevents", "searchevents", "pinevent", "unpinevent", "listpins", "allowkind", "disallowkind", "unrulekind", "storagestats", "gitstorage", "setretention", "listretention", "purgekind", "listallowedkinds", "listblockedkinds", "notifytest", "resetrules", "listpresets", "listconnectiontemplates", "listconnections", "setconnections", "applypreset", "forkrelay", "pullfrom", "pullstatus", "listjobs", "deliverystatus", "addjob", "removejob", "runjob", "backfill", "transferowner", "listdumps", "deletedump", "dumpnow", "backupnow", "listbackups", "deletebackup", "setsuccession", "clearsuccession", "successionstatus", "changerelayname", "changerelaydescription", "changerelayicon", "adddomain", "setdomainsite", "checkdomain", "removedomain", "listdomains", "listagents", "pauseagent", "resumeagent", "revokeagent", "pauseallagents", "resumeallagents", "listcallbacks", "addcallback", "removecallback", "pausecallback", "resumecallback"}
1584+ return []string{"supportedmethods", "listaudit", "stats", "getpolicy", "setpolicy", "listviews", "banpubkey", "setblockedwords", "allowpubkey", "setmember", "unrulepubkey", "removemember", "listbannedpubkeys", "listallowedpubkeys", "listmembers", "listpeople", "listjoinrequests", "approvejoin", "denyjoin", "createinvite", "listinvites", "revokeinvite", "listclaims", "listlisthistory", "restorelist", "createclaim", "deleteclaim", "removesubtree", "banevent", "allowevent", "listeventsneedingmoderation", "blockip", "unblockip", "listblockedips", "listreports", "resolvereport", "exportconfig", "importconfig", "deleterelay", "listblobs", "listsites", "deleteblob", "deleteevent", "listrecentevents", "searchevents", "pinevent", "unpinevent", "listpins", "allowkind", "disallowkind", "unrulekind", "storagestats", "gitstorage", "setretention", "listretention", "purgekind", "listallowedkinds", "listblockedkinds", "notifytest", "resetrules", "listpresets", "listconnectiontemplates", "listconnections", "setconnections", "applypreset", "forkrelay", "pullfrom", "pullstatus", "listjobs", "deliverystatus", "addjob", "removejob", "runjob", "backfill", "transferowner", "listdumps", "deletedump", "dumpnow", "backupnow", "listbackups", "deletebackup", "setsuccession", "clearsuccession", "successionstatus", "changerelayname", "changerelaydescription", "changerelayicon", "adddomain", "setdomainsite", "checkdomain", "removedomain", "listdomains", "listagents", "pauseagent", "resumeagent", "revokeagent", "pauseallagents", "resumeallagents", "listcallbacks", "addcallback", "removecallback", "pausecallback", "resumecallback", "listcustomviews", "addcustomview", "removecustomview", "runcustomview", "pausecustomview", "resumecustomview"}
1585 }
1586
1587 func managementRoleAllows(method, role string) bool {
1588diff --git a/internal/daemon/scheduler.go b/internal/daemon/scheduler.go
1589index 596ab32..5514377 100644
1590--- a/internal/daemon/scheduler.go
1591+++ b/internal/daemon/scheduler.go
1592@@ -44,6 +44,7 @@ func (t *Tenant) runScheduler() {
1593 }
1594 if !wake {
1595 report("records", t.records.Tick(ctx, now.Unix()))
1596+ report("custom views", t.tickCustomViews(ctx, now.Unix()))
1597 if !now.Before(nextMaintenance) {
1598 if t.blobs != nil {
1599 report("partial uploads", t.blobs.CleanupMultipart(ctx))
1600diff --git a/internal/daemon/services.go b/internal/daemon/services.go
1601index 0e07ec5..d760074 100644
1602--- a/internal/daemon/services.go
1603+++ b/internal/daemon/services.go
1604@@ -28,6 +28,7 @@ import (
1605 "github.com/FelineStateMachine/tinyrelay/internal/replication"
1606 "github.com/FelineStateMachine/tinyrelay/internal/sites"
1607 "github.com/FelineStateMachine/tinyrelay/internal/storage"
1608+ "github.com/FelineStateMachine/tinyrelay/internal/views"
1609 "github.com/FelineStateMachine/tinyrelay/internal/webui"
1610 "github.com/FelineStateMachine/tinyrelay/internal/work"
1611 )
1612@@ -46,6 +47,9 @@ func (t *Tenant) initServices(ctx context.Context) error {
1613 if err := t.initCallbacks(ctx); err != nil {
1614 return err
1615 }
1616+ if err := t.initCustomViews(ctx); err != nil {
1617+ return err
1618+ }
1619 t.blobs, err = blob.New(ctx, blob.Config{
1620 Root: t.meta.Paths.Root, PublicURL: t.publicURL, Store: t.store, Authorize: t.authorizeBlob, UploadTerms: t.blobUploadTerms,
1621 Limits: func() blob.Limits {
1622@@ -192,6 +196,10 @@ func (b backend) URL() string { return b.tenant.publicURL }
1623 func (b backend) Slug() string { return b.tenant.meta.Name }
1624 func (b backend) Identity() string { return b.tenant.records.PublicKey() }
1625
1626+// CustomViews gives the page renderers the enabled custom views' names and
1627+// languages, never a transform or a secret.
1628+func (b backend) CustomViews() []views.View { return b.tenant.CustomViews() }
1629+
1630 // ReadAllowed is consumed by the web UI private boundary. It deliberately
1631 // delegates to the same uncached membership check used by private Git and
1632 // NIP-42, so a revoked browser session cannot retain page access.
1633@@ -581,6 +589,7 @@ func (t *Tenant) workHandlers() map[string]work.Handler {
1634 }
1635 handlers[notificationPush] = t.handleNotificationPush
1636 handlers[callbackDelivery] = t.handleCallbackDelivery
1637+ handlers[viewTransform] = t.handleViewTransform
1638 return handlers
1639 }
1640
1641@@ -616,6 +625,7 @@ func (t *Tenant) releaseGit(ctx context.Context, id string) error {
1642 // A repository state is hidden until its objects arrive, so agents
1643 // that wait for pushes are woken here rather than at publish time.
1644 t.notifyCallbacks(ctx, released)
1645+ t.queueCustomViews(ctx, released)
1646 }
1647 return err
1648 }
1649@@ -657,8 +667,10 @@ func (t *Tenant) sweep(ctx context.Context, now int64) error {
1650 if err != nil {
1651 return err
1652 }
1653- _, err = t.store.SweepRetention(ctx, opts)
1654- return err
1655+ if _, err := t.store.SweepRetention(ctx, opts); err != nil {
1656+ return err
1657+ }
1658+ return t.pruneAllViewArtifacts(ctx)
1659 }
1660 func (t *Tenant) replacePolicy(p policy.Policy) {
1661 t.mu.Lock()
1662@@ -755,6 +767,8 @@ func (t *Tenant) commitImported(ctx context.Context, e event.Event, origin repli
1663 if err == nil {
1664 t.notifyDevices(ctx, e)
1665 t.notifyCallbacks(ctx, e)
1666+ t.queueCustomViews(ctx, e)
1667+ t.cascadeViewDeletion(ctx, e)
1668 t.notifyWikiMerge(ctx, e)
1669 t.notifyWikiProposal(ctx, e)
1670 }
1671diff --git a/internal/daemon/tenant.go b/internal/daemon/tenant.go
1672index 52203c2..3aa6931 100644
1673--- a/internal/daemon/tenant.go
1674+++ b/internal/daemon/tenant.go
1675@@ -76,6 +76,10 @@ type Tenant struct {
1676 callbackIndex map[int][]callbackRecord
1677 callbackLocks map[string]*sync.Mutex
1678 callbackClient *http.Client
1679+ viewMu sync.Mutex
1680+ viewIndex map[int][]customView
1681+ viewLocks map[string]*sync.Mutex
1682+ viewClient *http.Client
1683 }
1684
1685 func newTenant(ctx context.Context, cfg tenantConfig) (*Tenant, error) {
1686@@ -396,6 +400,8 @@ func (t *Tenant) Publish(ctx context.Context, e event.Event, s relay.Session) (s
1687 }
1688 t.notifyDevices(ctx, e)
1689 t.notifyCallbacks(ctx, e)
1690+ t.queueCustomViews(ctx, e)
1691+ t.cascadeViewDeletion(ctx, e)
1692 t.notifyWikiMerge(ctx, e)
1693 t.notifyWikiProposal(ctx, e)
1694 // Stage Git metadata before acknowledging the event. This closes the
1695diff --git a/internal/records/records.go b/internal/records/records.go
1696index 444d99e..197408b 100644
1697--- a/internal/records/records.go
1698+++ b/internal/records/records.go
1699@@ -197,6 +197,12 @@ func (s *Service) Generate(ctx context.Context, kind int, tags [][]string, conte
1700 return s.signed(ctx, kind, tags, content, now)
1701 }
1702
1703+// Sign signs a record without storing it, for records whose audience is
1704+// decided when they are served, such as members-only view artifacts.
1705+func (s *Service) Sign(ctx context.Context, kind int, tags [][]string, content string, now int64) (event.Event, error) {
1706+ return s.signedOnly(ctx, kind, tags, content, now)
1707+}
1708+
1709 func (s *Service) profile(ctx context.Context, now int64) (event.Event, error) {
1710 p := s.policy()
1711 b, _ := json.Marshal(map[string]string{"name": p.Name, "about": p.Description, "picture": p.Icon})
1712diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go
1713index c21e2cf..26c7a8f 100644
1714--- a/internal/telemetry/telemetry.go
1715+++ b/internal/telemetry/telemetry.go
1716@@ -236,10 +236,10 @@ func latencyBuckets() []float64 {
1717 return []float64{.0005, .001, .005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10}
1718 }
1719
1720-var operationNames = map[string]struct{}{"http": {}, "websocket": {}, "publish": {}, "subscribe": {}, "query": {}, "count": {}, "fanout": {}, "delivery": {}, "commit": {}, "save": {}, "parse": {}, "authorize": {}, "upgrade": {}, "callback": {}, "delivery-discovery": {}, "notification-broadcast": {}, "browserepos": {}, "browserepo": {}, "browsefiles": {}, "browsefile": {}, "browsestatus": {}, "browseissues": {}, "browsepulls": {}, "browseissue": {}, "browsepull": {}, "browse-download": {}, "push": {}, "mcp": {}, "rooms": {}, "blob-expiry": {}}
1721+var operationNames = map[string]struct{}{"http": {}, "websocket": {}, "publish": {}, "subscribe": {}, "query": {}, "count": {}, "fanout": {}, "delivery": {}, "commit": {}, "save": {}, "parse": {}, "authorize": {}, "upgrade": {}, "callback": {}, "delivery-discovery": {}, "notification-broadcast": {}, "browserepos": {}, "browserepo": {}, "browsefiles": {}, "browsefile": {}, "browsestatus": {}, "browseissues": {}, "browsepulls": {}, "browseissue": {}, "browsepull": {}, "browse-download": {}, "push": {}, "mcp": {}, "rooms": {}, "blob-expiry": {}, "view": {}}
1722 var outcomeNames = map[string]struct{}{"success": {}, "ok": {}, "invalid": {}, "unauthorized": {}, "timeout": {}, "busy": {}, "closed": {}, "error": {}, "paused": {}}
1723 var queueNames = map[string]struct{}{"inbox": {}, "outbox": {}, "write": {}, "delivery": {}}
1724-var workNames = map[string]struct{}{"delivery": {}, "push": {}, "replication": {}, "webhook": {}, "job": {}, "records-projection": {}, "site-mirror": {}, "git-metadata": {}, "catalog-owner": {}, "callback": {}, "delivery-discovery": {}, "notification-delivery": {}, "notification-broadcast": {}, "notification-push": {}, "notification": {}, "callback-delivery": {}}
1725+var workNames = map[string]struct{}{"delivery": {}, "push": {}, "replication": {}, "webhook": {}, "job": {}, "records-projection": {}, "site-mirror": {}, "git-metadata": {}, "catalog-owner": {}, "callback": {}, "delivery-discovery": {}, "notification-delivery": {}, "notification-broadcast": {}, "notification-push": {}, "notification": {}, "callback-delivery": {}, "view-transform": {}}
1726
1727 func bounded(value string, allowed map[string]struct{}) string {
1728 if _, ok := allowed[value]; ok {
1729diff --git a/internal/views/views.go b/internal/views/views.go
1730new file mode 100644
1731index 0000000..273a26d
1732--- /dev/null
1733+++ b/internal/views/views.go
1734@@ -0,0 +1,155 @@
1735+// Package views holds the parts of custom views that the renderers and the
1736+// relay share: finding fenced code blocks in text, naming a block by its
1737+// hash and the markup that shows an artifact in place of a block. It knows
1738+// nothing about any particular transform.
1739+package views
1740+
1741+import (
1742+ "crypto/sha256"
1743+ "encoding/hex"
1744+ "html"
1745+ "regexp"
1746+ "strings"
1747+)
1748+
1749+// Block is one fenced code block. Index counts every fenced block in the
1750+// text from zero, whether or not it carries a language.
1751+type Block struct {
1752+ Index int `json:"index"`
1753+ Lang string `json:"lang"`
1754+ Source string `json:"source"`
1755+}
1756+
1757+// NamePattern is the shape of a custom view name.
1758+var NamePattern = regexp.MustCompile(`^[a-z0-9-]{1,32}$`)
1759+
1760+// OpenFence reports whether a line opens a fenced code block: three or more
1761+// backticks or tildes, with the language as the first word after them. The
1762+// marker returned is the fence run, so the closing line must use the same
1763+// character.
1764+func OpenFence(line string) (marker, lang string, ok bool) {
1765+ trimmed := strings.TrimSpace(line)
1766+ if len(trimmed) < 3 || (trimmed[0] != '`' && trimmed[0] != '~') {
1767+ return "", "", false
1768+ }
1769+ run := len(trimmed) - len(strings.TrimLeft(trimmed, trimmed[:1]))
1770+ if run < 3 {
1771+ return "", "", false
1772+ }
1773+ info := strings.Fields(trimmed[run:])
1774+ if len(info) > 0 {
1775+ lang = strings.ToLower(info[0])
1776+ }
1777+ return trimmed[:run], lang, true
1778+}
1779+
1780+// ClosesFence reports whether a line closes the fence opened with marker: a
1781+// run of at least as many of the same character and nothing else.
1782+func ClosesFence(line, marker string) bool {
1783+ trimmed := strings.TrimSpace(line)
1784+ return len(trimmed) >= len(marker) && strings.Trim(trimmed, marker[:1]) == ""
1785+}
1786+
1787+// Blocks lists the fenced code blocks of a text in order. The source is the
1788+// lines between the fences joined by newlines, without a trailing newline.
1789+func Blocks(content string) []Block {
1790+ lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
1791+ var out []Block
1792+ for i := 0; i < len(lines); i++ {
1793+ marker, lang, ok := OpenFence(lines[i])
1794+ if !ok {
1795+ continue
1796+ }
1797+ var body []string
1798+ for i++; i < len(lines) && !ClosesFence(lines[i], marker); i++ {
1799+ body = append(body, lines[i])
1800+ }
1801+ out = append(out, Block{Index: len(out), Lang: lang, Source: strings.Join(body, "\n")})
1802+ }
1803+ return out
1804+}
1805+
1806+// Matching keeps the blocks whose language is one of the given languages.
1807+func Matching(blocks []Block, languages []string) []Block {
1808+ var out []Block
1809+ for _, block := range blocks {
1810+ if block.Lang != "" && contains(languages, block.Lang) {
1811+ out = append(out, block)
1812+ }
1813+ }
1814+ return out
1815+}
1816+
1817+// Hash names a block: the SHA-256 of the language, a newline and the
1818+// source, in hex. The same block in two texts has the same hash.
1819+func Hash(lang, source string) string {
1820+ sum := sha256.Sum256([]byte(lang + "\n" + source))
1821+ return hex.EncodeToString(sum[:])
1822+}
1823+
1824+// HashPattern is the shape of a block hash in a path.
1825+var HashPattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
1826+
1827+// Path is the address an artifact is served at.
1828+func Path(view, hash, extension string) string {
1829+ return "/views/" + view + "/" + hash + "." + extension
1830+}
1831+
1832+// Figure is the markup for a block a custom view renders: the artifact as
1833+// an object with the escaped code block inside it, so a missing or failed
1834+// artifact shows the code without any script.
1835+func Figure(view, lang, source string) string {
1836+ var b strings.Builder
1837+ b.WriteString(`<figure data-view="` + html.EscapeString(view) + `"><object data="` + Path(view, Hash(lang, source), "svg") + `" type="image/svg+xml"><pre><code`)
1838+ if lang != "" {
1839+ b.WriteString(` data-lang="` + html.EscapeString(lang) + `"`)
1840+ }
1841+ b.WriteString(">" + html.EscapeString(source) + "</code></pre></object></figure>\n")
1842+ return b.String()
1843+}
1844+
1845+// Renderer decides how a fenced block is shown: the view that renders the
1846+// language, if any. Languages maps a lowercase language to a view name.
1847+type Renderer struct {
1848+ Languages map[string]string
1849+}
1850+
1851+// NewRenderer indexes views by the languages they render. The first view
1852+// that names a language renders it.
1853+func NewRenderer(views []View) Renderer {
1854+ index := map[string]string{}
1855+ for _, view := range views {
1856+ for _, lang := range view.Languages {
1857+ lang = strings.ToLower(lang)
1858+ if _, taken := index[lang]; !taken {
1859+ index[lang] = view.Name
1860+ }
1861+ }
1862+ }
1863+ return Renderer{Languages: index}
1864+}
1865+
1866+// View is the summary a renderer needs: the name and the languages.
1867+type View struct {
1868+ Name string `json:"name"`
1869+ Languages []string `json:"languages"`
1870+}
1871+
1872+// Block returns the figure markup for a block when a view renders its
1873+// language.
1874+func (r Renderer) Block(lang, source string) (string, bool) {
1875+ view, ok := r.Languages[strings.ToLower(lang)]
1876+ if !ok || view == "" {
1877+ return "", false
1878+ }
1879+ return Figure(view, strings.ToLower(lang), source), true
1880+}
1881+
1882+func contains(values []string, value string) bool {
1883+ for _, item := range values {
1884+ if item == value {
1885+ return true
1886+ }
1887+ }
1888+ return false
1889+}
1890diff --git a/internal/views/views_test.go b/internal/views/views_test.go
1891new file mode 100644
1892index 0000000..3b730f3
1893--- /dev/null
1894+++ b/internal/views/views_test.go
1895@@ -0,0 +1,90 @@
1896+package views
1897+
1898+import (
1899+ "crypto/sha256"
1900+ "encoding/hex"
1901+ "strings"
1902+ "testing"
1903+)
1904+
1905+func TestBlocksFindFencesInOrderWithLanguages(t *testing.T) {
1906+ content := "Intro\n\n```mermaid\ngraph TD;\n A-->B;\n```\n\nText\n\n~~~\nplain\n~~~\n\n ```` Python extra\nprint(1)\n```\nstill python\n````\n\n```dot\n"
1907+ blocks := Blocks(content)
1908+ if len(blocks) != 4 {
1909+ t.Fatalf("blocks = %+v", blocks)
1910+ }
1911+ for i, want := range []Block{
1912+ {0, "mermaid", "graph TD;\n A-->B;"},
1913+ {1, "", "plain"},
1914+ {2, "python", "print(1)\n```\nstill python"},
1915+ {3, "dot", ""},
1916+ } {
1917+ if blocks[i] != want {
1918+ t.Errorf("block %d = %+v, want %+v", i, blocks[i], want)
1919+ }
1920+ }
1921+ matched := Matching(blocks, []string{"mermaid", "dot"})
1922+ if len(matched) != 2 || matched[0].Index != 0 || matched[1].Index != 3 {
1923+ t.Fatalf("matching = %+v", matched)
1924+ }
1925+ if got := Matching(blocks, []string{"go"}); got != nil {
1926+ t.Fatalf("unexpected match %+v", got)
1927+ }
1928+}
1929+
1930+func TestFenceHelpersRejectShortAndMixedFences(t *testing.T) {
1931+ if _, _, ok := OpenFence("``"); ok {
1932+ t.Fatal("two backticks opened a fence")
1933+ }
1934+ if _, _, ok := OpenFence("text ```"); ok {
1935+ t.Fatal("fence in the middle of a line opened a fence")
1936+ }
1937+ marker, lang, ok := OpenFence("~~~~ Mermaid title")
1938+ if !ok || marker != "~~~~" || lang != "mermaid" {
1939+ t.Fatalf("open = %q %q %v", marker, lang, ok)
1940+ }
1941+ if ClosesFence("~~~", marker) || ClosesFence("````", marker) || !ClosesFence(" ~~~~~ ", marker) {
1942+ t.Fatal("closing fence rules")
1943+ }
1944+}
1945+
1946+func TestHashCoversLanguageAndSource(t *testing.T) {
1947+ sum := sha256.Sum256([]byte("mermaid\ngraph TD"))
1948+ if Hash("mermaid", "graph TD") != hex.EncodeToString(sum[:]) {
1949+ t.Fatal("hash differs from sha256(lang + newline + source)")
1950+ }
1951+ if Hash("mermaid", "graph TD") == Hash("dot", "graph TD") {
1952+ t.Fatal("language is not part of the hash")
1953+ }
1954+ if Blocks("```mermaid\na\n```")[0].Source != Blocks("```mermaid\r\na\r\n```")[0].Source {
1955+ t.Fatal("line endings change the source")
1956+ }
1957+}
1958+
1959+func TestFigureEmbedsTheArtifactWithTheCodeAsFallback(t *testing.T) {
1960+ got := Figure("diagrams", "mermaid", "graph TD;\n A-->B & <C>;")
1961+ hash := Hash("mermaid", "graph TD;\n A-->B & <C>;")
1962+ want := `<figure data-view="diagrams"><object data="/views/diagrams/` + hash + `.svg" type="image/svg+xml"><pre><code data-lang="mermaid">graph TD;` + "\n" + ` A--&gt;B &amp; &lt;C&gt;;</code></pre></object></figure>` + "\n"
1963+ if got != want {
1964+ t.Fatalf("figure =\n%s\nwant\n%s", got, want)
1965+ }
1966+ if strings.Contains(got, "class=") {
1967+ t.Fatal("figure carries a class attribute")
1968+ }
1969+}
1970+
1971+func TestRendererPicksTheFirstViewForALanguage(t *testing.T) {
1972+ r := NewRenderer([]View{{Name: "first", Languages: []string{"mermaid"}}, {Name: "second", Languages: []string{"Mermaid", "dot"}}})
1973+ if html, ok := r.Block("MERMAID", "x"); !ok || !strings.Contains(html, `data-view="first"`) {
1974+ t.Fatalf("mermaid = %q %v", html, ok)
1975+ }
1976+ if html, ok := r.Block("dot", "x"); !ok || !strings.Contains(html, `data-view="second"`) {
1977+ t.Fatalf("dot = %q %v", html, ok)
1978+ }
1979+ if _, ok := r.Block("go", "x"); ok {
1980+ t.Fatal("go rendered")
1981+ }
1982+ if _, ok := (Renderer{}).Block("mermaid", "x"); ok {
1983+ t.Fatal("empty renderer rendered")
1984+ }
1985+}

About

ownerc01503e8035f
visibilitypublic
head8dd79a2655be

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs