| 1 | diff --git a/docs/view-definitions.md b/docs/view-definitions.md |
| 2 | new file mode 100644 |
| 3 | index 0000000..fba695a |
| 4 | --- /dev/null |
| 5 | +++ b/docs/view-definitions.md |
| 6 | @@ -0,0 +1,150 @@ |
| 7 | +# Reusable view definitions |
| 8 | + |
| 9 | +Status: Proposal. These definitions are not accepted by the current management API. [Custom views](views.md) describes the block transforms available today. |
| 10 | + |
| 11 | +A view selects relay data and presents a result. It can list events, summarize a set of events or render one event differently. A diagram renderer is one application of the third mode. |
| 12 | + |
| 13 | +## Three modes |
| 14 | + |
| 15 | +| Mode | Input and result | Examples | |
| 16 | +| --- | --- | --- | |
| 17 | +| `list` | A bounded event query becomes an ordered collection. | Articles by an author, open repository issues, a room feed. | |
| 18 | +| `aggregate` | A bounded set of events becomes one derived result. | Counts by kind, a calendar with RSVP totals, a weekly activity report. | |
| 19 | +| `render` | One event or an extracted part becomes a replacement or companion result. | Diagram blocks, a formatted event card, a rendered README. | |
| 20 | + |
| 21 | +The modes share selection, access, refresh and presentation concepts. They have different result identities and update rules. A scheduled render remains a set of independent renders; running it hourly does not make it an aggregate. |
| 22 | + |
| 23 | +## Shared fields |
| 24 | + |
| 25 | +| Field | Meaning | |
| 26 | +| --- | --- | |
| 27 | +| `version` | Definition format version. | |
| 28 | +| `name` | Stable owner-selected name. | |
| 29 | +| `mode` | `list`, `aggregate` or `render`. | |
| 30 | +| `source` | A Nostr filter and a bounded selection policy. | |
| 31 | +| `refresh` | Refresh on matching writes or on an interval. Manual refresh is always available. | |
| 32 | +| `audience` | Who can read the view: `public` or `members`. | |
| 33 | +| `presentation` | How the result appears: events, a table, structured data or an inline rendering. | |
| 34 | + |
| 35 | +`source.filter` uses the relay's existing Nostr filter semantics, including kinds, authors, IDs and tag filters, through its access-controlled query path. A source limit and deterministic ordering are explicit; equal timestamps use the relay's event ID ordering. A time window is separate from the refresh interval. `refresh.on` and `refresh.every_seconds` are mutually exclusive. |
| 36 | + |
| 37 | +Mode-specific fields are validated for that mode. A list does not require a transform URL. An aggregate does not require a language. A render does not have to produce an image. |
| 38 | + |
| 39 | +## Event list |
| 40 | + |
| 41 | +An article view needs a query and a presentation: |
| 42 | + |
| 43 | +```json |
| 44 | +{ |
| 45 | + "version": 1, |
| 46 | + "name": "articles", |
| 47 | + "mode": "list", |
| 48 | + "source": { |
| 49 | + "filter": { "kinds": [30023] }, |
| 50 | + "limit": 100, |
| 51 | + "order": "newest" |
| 52 | + }, |
| 53 | + "refresh": { "on": "write" }, |
| 54 | + "audience": "public", |
| 55 | + "presentation": { "type": "events" } |
| 56 | +} |
| 57 | +``` |
| 58 | + |
| 59 | +The result retains event references. Existing event components can render them without an external service or generated HTML. Event replacement, deletion and expiration update membership in the list. |
| 60 | + |
| 61 | +## Aggregate |
| 62 | + |
| 63 | +A report counts notes and articles written in the last seven days: |
| 64 | + |
| 65 | +```json |
| 66 | +{ |
| 67 | + "version": 1, |
| 68 | + "name": "weekly-activity", |
| 69 | + "mode": "aggregate", |
| 70 | + "source": { |
| 71 | + "filter": { "kinds": [1, 30023] }, |
| 72 | + "window_seconds": 604800, |
| 73 | + "limit": 10000, |
| 74 | + "order": "newest" |
| 75 | + }, |
| 76 | + "transform": { |
| 77 | + "type": "builtin", |
| 78 | + "name": "count", |
| 79 | + "group_by": ["kind"] |
| 80 | + }, |
| 81 | + "refresh": { "every_seconds": 3600 }, |
| 82 | + "audience": "members", |
| 83 | + "presentation": { "type": "table" } |
| 84 | +} |
| 85 | +``` |
| 86 | + |
| 87 | +This produces one result for the selected set. Its response records the time range, source count and whether the selection was truncated. A partial selection must not be presented as a complete total. A table and a chart can present the same structured result without rerunning the aggregation. |
| 88 | + |
| 89 | +Built-in reducers cover common reports. An HTTP reducer can use the same mode only with an explicit, bounded event projection defining the fields sent to the service. It must not inherit the block endpoint's request contract. |
| 90 | + |
| 91 | +## Event rendering |
| 92 | + |
| 93 | +A diagram view selects events, extracts their fenced blocks and renders those blocks: |
| 94 | + |
| 95 | +```json |
| 96 | +{ |
| 97 | + "version": 1, |
| 98 | + "name": "diagrams", |
| 99 | + "mode": "render", |
| 100 | + "source": { |
| 101 | + "filter": { "kinds": [1, 30023, 30818] }, |
| 102 | + "limit": 500, |
| 103 | + "order": "newest" |
| 104 | + }, |
| 105 | + "input": { |
| 106 | + "type": "fenced-blocks", |
| 107 | + "from": "content", |
| 108 | + "languages": ["mermaid", "dot", "d2"] |
| 109 | + }, |
| 110 | + "transform": { |
| 111 | + "type": "http", |
| 112 | + "url": "https://diagram.zip/transform/blocks", |
| 113 | + "connection": "diagram-renderer", |
| 114 | + "options": { "appearance": "auto-transparent" } |
| 115 | + }, |
| 116 | + "output": { |
| 117 | + "types": ["image/svg+xml", "image/png"], |
| 118 | + "max_bytes": 1048576 |
| 119 | + }, |
| 120 | + "refresh": { "on": "write" }, |
| 121 | + "audience": "public", |
| 122 | + "presentation": { |
| 123 | + "type": "inline", |
| 124 | + "target": "input", |
| 125 | + "fallback": "source" |
| 126 | + } |
| 127 | +} |
| 128 | +``` |
| 129 | + |
| 130 | +Languages belong to the fenced-block extractor. Image types belong to this renderer's output contract. Neither is a requirement for every view. |
| 131 | + |
| 132 | +Whole-event rendering uses an explicit field projection as its input and can return structured data or text for a registered presenter. Repository rendering uses an explicit `repo-readme` source adapter. Kind 30618 alone should not silently change event content into a Git file. |
| 133 | + |
| 134 | +Transform options belong to the chosen adapter. Diagram appearance and frame settings do not become relay-wide view fields. Credentials live in the named connection and are excluded from exported definitions. |
| 135 | + |
| 136 | +## Results and access |
| 137 | + |
| 138 | +Each mode has a distinct result identity: |
| 139 | + |
| 140 | +- A list identifies a query result and the referenced events. |
| 141 | +- An aggregate identifies the definition revision and the selected source snapshot or window. |
| 142 | +- A render identifies the definition revision, the input and the renderer revision. Reuse across events is allowed only when the renderer depends solely on the extracted input. |
| 143 | + |
| 144 | +Changing a filter, transform option, output type or renderer revision invalidates the affected results. Refresh replaces a result atomically only after the new result passes validation. A completed run cannot overwrite a newer definition or source snapshot. Readers can see when a result was produced and whether it is stale or failed. |
| 145 | + |
| 146 | +The view's audience cannot grant access to source data a reader could not otherwise read. Selection, presentation and HTTP serving enforce that rule. Public summaries of restricted data need a separate, explicit policy; choosing an aggregate mode must not publish private inputs. The source fields exported to an external transform are declared independently from the result's audience. |
| 147 | + |
| 148 | +Each presentation has a server-rendered baseline. Lists use event components, tables use defined columns, structured results use the JSON viewer, and inline renders retain source access. Arbitrary HTML, scripts and executable templates are outside this proposal. |
| 149 | + |
| 150 | +## Migration |
| 151 | + |
| 152 | +The existing flat custom-view definition maps to `mode: "render"`: `kinds` becomes the source filter, `languages` moves into the fenced-block input, and the URL becomes an HTTP transform. Existing API calls and artifact URLs remain valid during migration. An old definition that includes repository state needs an explicit README adapter when exported to the new format. |
| 153 | + |
| 154 | +The built-in article view is a candidate for `list`; calendar, zap and moderation summaries are candidates for `aggregate`. Their existing audience rules must remain intact. Presence is live state and needs a live source adapter rather than being forced into a stored event query. |
| 155 | + |
| 156 | +Use one catalog and one definition validator for the three modes. Keep execution and result handling specific to each mode. Validate the shared design with an event list, an aggregate report and a block render before replacing the current APIs. |
| 157 | diff --git a/docs/views.md b/docs/views.md |
| 158 | index 48b5e7e..1bf0e24 100644 |
| 159 | --- a/docs/views.md |
| 160 | +++ b/docs/views.md |
| 161 | @@ -1,22 +1,24 @@ |
| 162 | # Custom views |
| 163 | |
| 164 | -A custom view renders the fenced code blocks a relay already holds. The owner names a set of event kinds and a set of fenced block languages, and points the view at an https transform they run. When a matching event arrives the relay posts the blocks in those languages to the transform, and keeps the SVG or PNG that comes back as a relay-signed artifact attached to the source event. The relay's pages then show the artifact in place of the code block. |
| 165 | +A custom view currently provides a block transform: it renders fenced code blocks the relay already holds. The owner names a set of event kinds and fenced block languages, and points the view at an HTTPS transform they run. When a matching event arrives, the relay posts the matching blocks to the transform and keeps the SVG or PNG it returns as a relay-signed artifact attached to the source event. The relay's pages show the artifact in place of the code block. |
| 166 | |
| 167 | -Nothing about a view is built into the relay. It renders what its owner asked for, through a service the owner chose. A relay with no views behaves exactly as before, and a block whose artifact is missing, still queued or refused stays visible as code. |
| 168 | +Block transforms can support diagrams, charts or other code-based images. They do not define event lists, aggregate reports or alternate presentations of whole events. The built-in views listed by `listviews` produce signed summaries separately. See the [reusable view proposal](view-definitions.md) for a shared definition covering these uses. |
| 169 | + |
| 170 | +A relay with no block transforms keeps showing code. The source remains available beneath each image. If an image fails to load, the page opens its source when JavaScript is available; without JavaScript, open **Source** to read it. |
| 171 | |
| 172 | ## Define a view |
| 173 | |
| 174 | Call the `addcustomview` management method, or the `add_custom_view` MCP tool, as the owner: |
| 175 | |
| 176 | ```json |
| 177 | -{"method": "addcustomview", "params": [{"name": "diagrams", "kinds": [30818, 30023, 30618], "transform": "https://render.example/tiny", "languages": ["mermaid", "dot", "d2"], "trigger": "write", "audience": "public"}]} |
| 178 | +{"method": "addcustomview", "params": [{"name": "diagrams", "kinds": [30818, 30023, 30618], "transform": "https://render.example/transform/blocks", "languages": ["mermaid", "dot", "d2"], "trigger": "write", "audience": "public"}]} |
| 179 | ``` |
| 180 | |
| 181 | | Field | Meaning | |
| 182 | | --- | --- | |
| 183 | | `name` | 1 to 32 lowercase letters, digits or hyphens. Names the view and its artifact paths. | |
| 184 | | `kinds` | 1 to 16 event kinds to watch. Kind 30618 sends the repository README at its head. | |
| 185 | -| `transform` | An https URL on a public host. | |
| 186 | +| `transform` | An HTTPS URL on a public host. | |
| 187 | | `languages` | 1 to 32 fenced block languages, lowercase. Only these blocks are sent. | |
| 188 | | `trigger` | `write` sends each matching event as it arrives, `hourly` sends the newest ones every hour. Defaults to `write`. | |
| 189 | | `audience` | `public` publishes the artifacts, `members` keeps them to members. Defaults to `public`. | |
| 190 | @@ -37,19 +39,23 @@ A view posts one request for each source event, carrying only the blocks: |
| 191 | |
| 192 | | Header | Value | |
| 193 | | --- | --- | |
| 194 | -| `X-Tiny-View` | The view name. | |
| 195 | -| `X-Tiny-Relay` | The relay's public URL. | |
| 196 | -| `X-Tiny-Signature` | `sha256=` followed by the hex HMAC-SHA256 of the body under the view's secret. | |
| 197 | +| `X-Transform-View` | The view name. | |
| 198 | +| `X-Transform-Relay` | The relay's public URL. | |
| 199 | +| `X-Transform-Signature` | `sha256=` followed by the hex HMAC-SHA256 of the body under the view's secret. | |
| 200 | |
| 201 | Verify the signature before rendering anything. The relay waits 30 seconds for an answer. |
| 202 | |
| 203 | +The relay also sends the equivalent `X-Tiny-View`, `X-Tiny-Relay` and `X-Tiny-Signature` headers for existing transforms. New integrations should use the `X-Transform-*` headers. |
| 204 | + |
| 205 | ## What the transform answers |
| 206 | |
| 207 | ```json |
| 208 | -{"artifacts": [{"block": 2, "type": "svg", "body": "<svg ...>", "engine": "mermaid"}], "errors": [{"block": 5, "error": "unknown shape on line 3"}]} |
| 209 | +{"artifacts": [{"block": 2, "type": "image/svg+xml", "body": "<svg ...>", "engine": "mermaid"}], "errors": [{"block": 5, "error": "unknown shape on line 3"}]} |
| 210 | ``` |
| 211 | |
| 212 | -Each artifact names the block it rendered, its `type`, either `svg` or `png`, and its `body`, the SVG source or a base64 PNG. `engine` is optional and is recorded on the artifact. An artifact larger than the view's `max_bytes` is refused. SVG bodies are checked before they are stored: script elements, event handlers and external references are rejected rather than stripped, and the whole artifact is refused. A block listed under `errors`, or left out of the answer entirely, keeps showing its code. |
| 213 | +Each artifact names the block it rendered, its `type`, either `image/svg+xml` or `image/png`, and its `body`, the SVG source or a base64 PNG. `engine` is optional and is recorded on the artifact. An artifact larger than the view's `max_bytes` is refused. SVG bodies are checked before they are stored: script elements, event handlers, foreign objects and external references are rejected rather than stripped, and the whole artifact is refused. A block listed under `errors`, or left out of the answer entirely, keeps showing its code. |
| 214 | + |
| 215 | +SVG artifacts must include their own colors and background rules. Page styles do not reach into the SVG document. Diagramzip's `/transform/blocks` endpoint defaults to `auto-transparent`, which omits the canvas and includes light and dark palettes. The diagram follows the page's color scheme. After changing a transform's styling, rebuild the stored artifacts to apply the change. |
| 216 | |
| 217 | ## Artifacts |
| 218 | |
| 219 | @@ -57,7 +63,7 @@ An artifact is a relay-signed kind 30078 record addressed `bind.ws/view/<name>/< |
| 220 | |
| 221 | Artifacts follow their sources. An artifact expires when its last source expires, and one that outlives every source that referred to it is deleted. Editing an event detaches the blocks it no longer carries, and the artifact is dropped once nothing points at it. Removing a view deletes every artifact it produced. |
| 222 | |
| 223 | -Each artifact is served at `/views/<name>/<hash>.svg` or `.png` under a sandbox policy that blocks scripts and outside loads. A members-only view answers only members; a public one follows the relay's read rule. The relay's pages embed the artifact with the code block as its fallback, so a reader whose browser refuses the artifact still sees the source. |
| 224 | +Each artifact is served at `/views/<name>/<hash>` with its stored media type, under a sandbox policy that blocks scripts and outside loads. The `.svg` and `.png` URLs remain available. A members-only view answers only members; a public one follows the relay's read rule. The relay's pages embed the artifact as an image with a native **Source** disclosure. Browsers revalidate images so a rebuild can replace them. |
| 225 | |
| 226 | ## Failures |
| 227 | |
| 228 | @@ -73,5 +79,6 @@ A response outside 2xx, a timeout or a connection failure counts as a failure. T |
| 229 | | `pausecustomview <name>` | Stops transform requests. The artifacts stay. | |
| 230 | | `resumecustomview <name>` | Resumes the view and clears the failure count. | |
| 231 | | `runcustomview <name>` | Queues a backfill over the newest 500 events of the view's kinds. Blocks that already have an artifact are reused. | |
| 232 | +| `runcustomview {"name": "diagrams", "refresh": true}` | Rebuilds artifacts for the newest 500 matching events. Existing artifacts stay available until a valid replacement arrives. | |
| 233 | |
| 234 | -The **Manage > Views** page lists each view with its state, last run and failure count, and buttons to pause, resume, run or remove it. The same controls are available over MCP; see [MCP](mcp.md). |
| 235 | +The **Manage > Views** page lists each view with its state, last run and failure count, and buttons to pause, resume, run, rebuild or remove it. **Run now** fills missing artifacts; **Rebuild** also refreshes existing ones. The same controls are available over MCP; see [MCP](mcp.md). |
| 236 | diff --git a/internal/daemon/custom_views.go b/internal/daemon/custom_views.go |
| 237 | index 6ecf63b..2d3e248 100644 |
| 238 | --- a/internal/daemon/custom_views.go |
| 239 | +++ b/internal/daemon/custom_views.go |
| 240 | @@ -337,7 +337,8 @@ func (t *Tenant) changeCustomView(ctx context.Context, actor, method string, par |
| 241 | if !view.Enabled { |
| 242 | return nil, errors.New("invalid: resume the view before running it") |
| 243 | } |
| 244 | - queued, err := t.queueCustomViewBackfill(ctx, view, 0, strconv.FormatInt(time.Now().UnixNano(), 10)) |
| 245 | + refresh := customViewRefreshParam(params) |
| 246 | + queued, err := t.queueCustomViewBackfill(ctx, view, 0, strconv.FormatInt(time.Now().UnixNano(), 10), refresh) |
| 247 | if err != nil { |
| 248 | return nil, err |
| 249 | } |
| 250 | @@ -403,6 +404,19 @@ func customViewNameParam(params []json.RawMessage) string { |
| 251 | return strings.TrimSpace(options.Name) |
| 252 | } |
| 253 | |
| 254 | +func customViewRefreshParam(params []json.RawMessage) bool { |
| 255 | + if len(params) == 0 { |
| 256 | + return false |
| 257 | + } |
| 258 | + var options struct { |
| 259 | + Refresh bool `json:"refresh"` |
| 260 | + } |
| 261 | + if err := json.Unmarshal(params[0], &options); err != nil { |
| 262 | + return false |
| 263 | + } |
| 264 | + return options.Refresh |
| 265 | +} |
| 266 | + |
| 267 | func (t *Tenant) customViewByName(ctx context.Context, name string) (customView, error) { |
| 268 | rows, err := t.customViewRows(ctx, name) |
| 269 | if err != nil { |
| 270 | @@ -518,7 +532,7 @@ func (t *Tenant) queueCustomViews(ctx context.Context, e event.Event) { |
| 271 | if e.Kind != event.KIND_REPO_STATE && len(views.Matching(views.Blocks(e.Content), view.Languages)) == 0 { |
| 272 | continue |
| 273 | } |
| 274 | - intents = append(intents, t.viewIntent(view, e, "")) |
| 275 | + intents = append(intents, t.viewIntent(view, e, "", false)) |
| 276 | } |
| 277 | if len(intents) == 0 { |
| 278 | return |
| 279 | @@ -536,8 +550,9 @@ func (t *Tenant) queueCustomViews(ctx context.Context, e event.Event) { |
| 280 | // viewIntent is the transform intent for one source. The event travels in |
| 281 | // the payload; run makes a backfill or an hourly pass distinct from the |
| 282 | // write-time intent for the same event. |
| 283 | -func (t *Tenant) viewIntent(view customView, e event.Event, run string) storage.Intent { |
| 284 | - payload, _ := json.Marshal(viewPayload{Event: e, Attempt: 1, Run: run}) |
| 285 | +func (t *Tenant) viewIntent(view customView, e event.Event, run string, refresh ...bool) storage.Intent { |
| 286 | + force := len(refresh) > 0 && refresh[0] |
| 287 | + payload, _ := json.Marshal(viewPayload{Event: e, Attempt: 1, Run: run, Refresh: force}) |
| 288 | id := e.ID |
| 289 | if run != "" { |
| 290 | id += "@" + run |
| 291 | @@ -556,7 +571,8 @@ func (t *Tenant) statePending(ctx context.Context, id string) bool { |
| 292 | // queueCustomViewBackfill queues the newest sources of a view's kinds, at |
| 293 | // most 500, written since the given time. Repository states are queued |
| 294 | // whether or not their README has blocks; the handler reads it. |
| 295 | -func (t *Tenant) queueCustomViewBackfill(ctx context.Context, view customView, since int64, run string) (int, error) { |
| 296 | +func (t *Tenant) queueCustomViewBackfill(ctx context.Context, view customView, since int64, run string, refresh ...bool) (int, error) { |
| 297 | + force := len(refresh) > 0 && refresh[0] |
| 298 | 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}) |
| 299 | if err != nil { |
| 300 | return 0, err |
| 301 | @@ -573,7 +589,7 @@ func (t *Tenant) queueCustomViewBackfill(ctx context.Context, view customView, s |
| 302 | } else if len(views.Matching(views.Blocks(e.Content), view.Languages)) == 0 { |
| 303 | continue |
| 304 | } |
| 305 | - intents = append(intents, t.viewIntent(view, e, run)) |
| 306 | + intents = append(intents, t.viewIntent(view, e, run, force)) |
| 307 | } |
| 308 | if len(intents) == 0 { |
| 309 | return 0, nil |
| 310 | @@ -599,7 +615,7 @@ func (t *Tenant) tickCustomViews(ctx context.Context, now int64) error { |
| 311 | if view.LastRunAt == 0 { |
| 312 | since = 0 |
| 313 | } |
| 314 | - queued, err := t.queueCustomViewBackfill(ctx, view, since, strconv.FormatInt(now/viewHourlyPeriod, 10)) |
| 315 | + queued, err := t.queueCustomViewBackfill(ctx, view, since, strconv.FormatInt(now/viewHourlyPeriod, 10), false) |
| 316 | if err != nil { |
| 317 | return err |
| 318 | } |
| 319 | diff --git a/internal/daemon/custom_views_http.go b/internal/daemon/custom_views_http.go |
| 320 | index 4ba3ebc..a0ae190 100644 |
| 321 | --- a/internal/daemon/custom_views_http.go |
| 322 | +++ b/internal/daemon/custom_views_http.go |
| 323 | @@ -1,10 +1,12 @@ |
| 324 | package daemon |
| 325 | |
| 326 | -// GET /views/<name>/<hash>.<svg|png> serves a custom view artifact under a |
| 327 | +// GET /views/<name>/<hash>[.<svg|png>] serves a custom view artifact under a |
| 328 | // sandbox policy. A members-only view answers only members; a public one |
| 329 | // follows the relay's read rule. |
| 330 | |
| 331 | import ( |
| 332 | + "crypto/sha256" |
| 333 | + "encoding/hex" |
| 334 | "net/http" |
| 335 | "strconv" |
| 336 | "strings" |
| 337 | @@ -33,8 +35,8 @@ func (t *Tenant) viewArtifactHTTP(w http.ResponseWriter, r *http.Request) { |
| 338 | return |
| 339 | } |
| 340 | name := parts[0] |
| 341 | - hash, extension, _ := strings.Cut(parts[1], ".") |
| 342 | - if !views.NamePattern.MatchString(name) || !views.HashPattern.MatchString(hash) || (extension != "svg" && extension != "png") { |
| 343 | + hash, extension, hasExtension := strings.Cut(parts[1], ".") |
| 344 | + if !views.NamePattern.MatchString(name) || !views.HashPattern.MatchString(hash) || (hasExtension && extension != "svg" && extension != "png") { |
| 345 | http.NotFound(w, r) |
| 346 | outcome = "invalid" |
| 347 | return |
| 348 | @@ -56,7 +58,7 @@ func (t *Tenant) viewArtifactHTTP(w http.ResponseWriter, r *http.Request) { |
| 349 | return |
| 350 | } |
| 351 | wantType := map[string]string{"svg": "image/svg+xml", "png": "image/png"}[extension] |
| 352 | - if record.Type != wantType { |
| 353 | + if extension != "" && record.Type != wantType { |
| 354 | http.NotFound(w, r) |
| 355 | outcome = "invalid" |
| 356 | return |
| 357 | @@ -91,12 +93,19 @@ func (t *Tenant) viewArtifactHTTP(w http.ResponseWriter, r *http.Request) { |
| 358 | w.Header().Set("Content-Length", strconv.Itoa(len(body))) |
| 359 | w.Header().Set("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'") |
| 360 | w.Header().Set("X-Content-Type-Options", "nosniff") |
| 361 | + etagBytes := sha256.Sum256(body) |
| 362 | + etag := `"` + hex.EncodeToString(etagBytes[:]) + `"` |
| 363 | + w.Header().Set("ETag", etag) |
| 364 | if record.Audience == "members" { |
| 365 | - w.Header().Set("Cache-Control", "private, max-age=31536000, immutable") |
| 366 | + w.Header().Set("Cache-Control", "private, no-cache") |
| 367 | } else { |
| 368 | - w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") |
| 369 | + w.Header().Set("Cache-Control", "public, no-cache") |
| 370 | } |
| 371 | outcome = "ok" |
| 372 | + if r.Header.Get("If-None-Match") == etag { |
| 373 | + w.WriteHeader(http.StatusNotModified) |
| 374 | + return |
| 375 | + } |
| 376 | if r.Method == http.MethodHead { |
| 377 | w.WriteHeader(http.StatusOK) |
| 378 | return |
| 379 | diff --git a/internal/daemon/custom_views_media_test.go b/internal/daemon/custom_views_media_test.go |
| 380 | new file mode 100644 |
| 381 | index 0000000..69a2677 |
| 382 | --- /dev/null |
| 383 | +++ b/internal/daemon/custom_views_media_test.go |
| 384 | @@ -0,0 +1,45 @@ |
| 385 | +package daemon |
| 386 | + |
| 387 | +import ( |
| 388 | + "net/http" |
| 389 | + "net/http/httptest" |
| 390 | + "strings" |
| 391 | + "testing" |
| 392 | + "time" |
| 393 | + |
| 394 | + "github.com/FelineStateMachine/tinyrelay/internal/views" |
| 395 | +) |
| 396 | + |
| 397 | +func TestCustomViewMediaPathServesStoredType(t *testing.T) { |
| 398 | + for _, mediaType := range []string{"image/svg+xml", "image/png"} { |
| 399 | + t.Run(mediaType, func(t *testing.T) { |
| 400 | + tenant, server, _ := viewTenant(t, []int{1}, nil) |
| 401 | + artifact := svgArtifact(0) |
| 402 | + artifact["type"] = mediaType |
| 403 | + if mediaType == "image/png" { |
| 404 | + artifact["body"] = "iVBORw0KGgoAAAANSUhEUg==" |
| 405 | + } |
| 406 | + server.answer(http.StatusOK, artifact) |
| 407 | + e := signedEvent(t, testOwnerSecret, 1, time.Now().Unix(), nil, "```mermaid\ngraph TD; a-->b;\n```") |
| 408 | + if err := publishAs(t, tenant, e); err != nil { |
| 409 | + t.Fatal(err) |
| 410 | + } |
| 411 | + runPendingView(t, tenant, "diagrams", e.ID) |
| 412 | + _, image, ok := strings.Cut(views.Figure("diagrams", "mermaid", "graph TD; a-->b;"), `<img src="`) |
| 413 | + if !ok { |
| 414 | + t.Fatal("custom view has no image") |
| 415 | + } |
| 416 | + path, _, _ := strings.Cut(image, `"`) |
| 417 | + res := getArtifact(t, tenant, path, "") |
| 418 | + if res.Code != http.StatusOK || res.Header().Get("Content-Type") != mediaType { |
| 419 | + t.Fatalf("media response: %d %s", res.Code, res.Body.String()) |
| 420 | + } |
| 421 | + request := httptest.NewRequest(http.MethodHead, "http://127.0.0.1:8080"+path, nil) |
| 422 | + head := httptest.NewRecorder() |
| 423 | + tenant.ServeHTTP(head, request) |
| 424 | + if head.Code != http.StatusOK || head.Body.Len() != 0 || head.Header().Get("ETag") != res.Header().Get("ETag") { |
| 425 | + t.Fatalf("media HEAD: %d %s", head.Code, head.Body.String()) |
| 426 | + } |
| 427 | + }) |
| 428 | + } |
| 429 | +} |
| 430 | diff --git a/internal/daemon/custom_views_test.go b/internal/daemon/custom_views_test.go |
| 431 | index 57d155e..32fa8ed 100644 |
| 432 | --- a/internal/daemon/custom_views_test.go |
| 433 | +++ b/internal/daemon/custom_views_test.go |
| 434 | @@ -367,6 +367,11 @@ func TestCustomViewTransformSendsBlocksAndKeepsSignedArtifacts(t *testing.T) { |
| 435 | 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)) { |
| 436 | t.Fatalf("headers = %v", headers) |
| 437 | } |
| 438 | + for _, field := range []string{"View", "Relay", "Signature"} { |
| 439 | + if headers.Get("X-Transform-"+field) != headers.Get("X-Tiny-"+field) { |
| 440 | + t.Fatalf("canonical transform header %s differs from its legacy value", field) |
| 441 | + } |
| 442 | + } |
| 443 | var request map[string]any |
| 444 | if err := json.Unmarshal(body, &request); err != nil { |
| 445 | t.Fatal(err) |
| 446 | @@ -414,14 +419,21 @@ func TestCustomViewTransformSendsBlocksAndKeepsSignedArtifacts(t *testing.T) { |
| 447 | |
| 448 | // The route serves each artifact under a sandbox, by its own type. |
| 449 | res := getArtifact(t, tenant, "/views/diagrams/"+svgHash+".svg", "") |
| 450 | - 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" { |
| 451 | + if res.Code != http.StatusOK || res.Body.String() != testSVG || res.Header().Get("Content-Type") != "image/svg+xml" || res.Header().Get("Content-Security-Policy") != "sandbox; default-src 'none'; style-src 'unsafe-inline'" || res.Header().Get("X-Content-Type-Options") != "nosniff" || res.Header().Get("Cache-Control") != "public, no-cache" || res.Header().Get("ETag") == "" { |
| 452 | t.Fatalf("svg route: %d %v %s", res.Code, res.Header(), res.Body.String()) |
| 453 | } |
| 454 | + conditional := httptest.NewRequest(http.MethodGet, "http://127.0.0.1:8080/views/diagrams/"+svgHash+".svg", nil) |
| 455 | + conditional.Header.Set("If-None-Match", res.Header().Get("ETag")) |
| 456 | + conditionalResponse := httptest.NewRecorder() |
| 457 | + tenant.ServeHTTP(conditionalResponse, conditional) |
| 458 | + if conditionalResponse.Code != http.StatusNotModified { |
| 459 | + t.Fatalf("conditional artifact: %d %v", conditionalResponse.Code, conditionalResponse.Header()) |
| 460 | + } |
| 461 | res = getArtifact(t, tenant, "/views/diagrams/"+pngHash+".png", "") |
| 462 | if res.Code != http.StatusOK || res.Body.String() != testPNGRaw || res.Header().Get("Content-Type") != "image/png" { |
| 463 | t.Fatalf("png route: %d %v", res.Code, res.Header()) |
| 464 | } |
| 465 | - 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"} { |
| 466 | + 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"} { |
| 467 | if res := getArtifact(t, tenant, path, ""); res.Code != http.StatusNotFound { |
| 468 | t.Fatalf("%s: %d", path, res.Code) |
| 469 | } |
| 470 | @@ -562,6 +574,60 @@ func TestCustomViewReplacementReusesAndDropsBlocks(t *testing.T) { |
| 471 | } |
| 472 | } |
| 473 | |
| 474 | +func queueViewRebuild(t *testing.T, tenant *Tenant, source string) work.Intent { |
| 475 | + t.Helper() |
| 476 | + params := viewParams(map[string]any{"name": "diagrams", "refresh": true}) |
| 477 | + if _, err := tenant.Execute(context.Background(), tenant.Policy().Owner, "runcustomview", params); err != nil { |
| 478 | + t.Fatal(err) |
| 479 | + } |
| 480 | + for _, intent := range pendingViewIntents(t, tenant, "diagrams", source) { |
| 481 | + var payload viewPayload |
| 482 | + if err := json.Unmarshal([]byte(intent.Payload), &payload); err != nil { |
| 483 | + t.Fatal(err) |
| 484 | + } |
| 485 | + if payload.Attempt == 1 { |
| 486 | + if !payload.Refresh { |
| 487 | + t.Fatal("queued rebuild lost refresh flag") |
| 488 | + } |
| 489 | + return intent |
| 490 | + } |
| 491 | + } |
| 492 | + t.Fatal("no rebuild was queued") |
| 493 | + return work.Intent{} |
| 494 | +} |
| 495 | + |
| 496 | +func TestCustomViewRebuildReplacesOnlyAfterSuccess(t *testing.T) { |
| 497 | + tenant, server, _ := viewTenant(t, []int{1}, nil) |
| 498 | + note := signedEvent(t, testOwnerSecret, 1, time.Now().Unix(), nil, "```mermaid\nA\n```") |
| 499 | + if err := publishAs(t, tenant, note); err != nil { |
| 500 | + t.Fatal(err) |
| 501 | + } |
| 502 | + server.answer(http.StatusOK, svgArtifact(0)) |
| 503 | + runPendingView(t, tenant, "diagrams", note.ID) |
| 504 | + path := views.Path("diagrams", views.Hash("mermaid", "A"), "") |
| 505 | + before := getArtifact(t, tenant, path, "") |
| 506 | + if before.Code != http.StatusOK { |
| 507 | + t.Fatalf("initial artifact: %d", before.Code) |
| 508 | + } |
| 509 | + server.answer(http.StatusInternalServerError) |
| 510 | + runViewIntent(t, tenant, queueViewRebuild(t, tenant, note.ID)) |
| 511 | + afterFailure := getArtifact(t, tenant, path, "") |
| 512 | + if afterFailure.Body.String() != before.Body.String() || afterFailure.Header().Get("ETag") != before.Header().Get("ETag") { |
| 513 | + t.Fatal("failed rebuild changed the stored artifact") |
| 514 | + } |
| 515 | + replacement := svgArtifact(0) |
| 516 | + replacement["body"] = strings.Replace(testSVG, "<rect", `<rect fill="#334155"`, 1) |
| 517 | + server.answer(http.StatusOK, replacement) |
| 518 | + runViewIntent(t, tenant, queueViewRebuild(t, tenant, note.ID)) |
| 519 | + after := getArtifact(t, tenant, path, "") |
| 520 | + if after.Body.String() != replacement["body"] || after.Header().Get("ETag") == before.Header().Get("ETag") { |
| 521 | + t.Fatal("successful rebuild did not replace and revalidate the artifact") |
| 522 | + } |
| 523 | + if server.count() != 3 { |
| 524 | + t.Fatalf("transform requests = %d", server.count()) |
| 525 | + } |
| 526 | +} |
| 527 | + |
| 528 | func TestCustomViewTransformRetriesThenPauses(t *testing.T) { |
| 529 | tenant, server, _ := viewTenant(t, []int{1}, nil) |
| 530 | ctx := context.Background() |
| 531 | @@ -729,7 +795,7 @@ func TestCustomViewMembersAudienceStaysOffTheStore(t *testing.T) { |
| 532 | } |
| 533 | for _, secret := range []string{testMemberSecret, testOwnerSecret} { |
| 534 | res := getArtifact(t, tenant, path, secret) |
| 535 | - if res.Code != http.StatusOK || res.Body.String() != testSVG || res.Header().Get("Cache-Control") != "private, max-age=31536000, immutable" { |
| 536 | + if res.Code != http.StatusOK || res.Body.String() != testSVG || res.Header().Get("Cache-Control") != "private, no-cache" || res.Header().Get("ETag") == "" { |
| 537 | t.Fatalf("member: %d %v", res.Code, res.Header()) |
| 538 | } |
| 539 | } |
| 540 | diff --git a/internal/daemon/custom_views_transform.go b/internal/daemon/custom_views_transform.go |
| 541 | index 6767a26..2047f05 100644 |
| 542 | --- a/internal/daemon/custom_views_transform.go |
| 543 | +++ b/internal/daemon/custom_views_transform.go |
| 544 | @@ -40,6 +40,7 @@ type viewPayload struct { |
| 545 | Event event.Event `json:"event"` |
| 546 | Attempt int `json:"attempt"` |
| 547 | Run string `json:"run,omitempty"` |
| 548 | + Refresh bool `json:"refresh,omitempty"` |
| 549 | } |
| 550 | |
| 551 | // viewRequest is the body posted to a transform: only the blocks, with the |
| 552 | @@ -227,12 +228,18 @@ func (t *Tenant) handleViewTransform(ctx context.Context, intent work.Intent) (e |
| 553 | blocks = views.Matching(blocks, view.Languages) |
| 554 | source := viewSourceKey(payload.Event) |
| 555 | expires := event.Expiration(payload.Event) |
| 556 | - // Attach the blocks that already have an artifact and keep the rest. |
| 557 | + // A rebuild deliberately bypasses existing artifacts. The replacement is |
| 558 | + // written only after the transform succeeds, so a failed rebuild preserves |
| 559 | + // the previous artifact. |
| 560 | var pending []views.Block |
| 561 | hashes := make([]string, 0, len(blocks)) |
| 562 | for _, block := range blocks { |
| 563 | hash := views.Hash(block.Lang, block.Source) |
| 564 | hashes = append(hashes, hash) |
| 565 | + if payload.Refresh { |
| 566 | + pending = append(pending, block) |
| 567 | + continue |
| 568 | + } |
| 569 | exists, err := t.attachArtifact(ctx, view, hash, source, block.Index, expires) |
| 570 | if err != nil { |
| 571 | return err |
| 572 | @@ -350,7 +357,11 @@ func (t *Tenant) postViewTransform(ctx context.Context, view customView, e event |
| 573 | request.Header.Set("Accept", "application/json") |
| 574 | request.Header.Set("X-Tiny-View", view.Name) |
| 575 | request.Header.Set("X-Tiny-Relay", t.publicURL) |
| 576 | - request.Header.Set("X-Tiny-Signature", callbackSignature(view.secret, body)) |
| 577 | + signature := callbackSignature(view.secret, body) |
| 578 | + request.Header.Set("X-Tiny-Signature", signature) |
| 579 | + request.Header.Set("X-Transform-View", view.Name) |
| 580 | + request.Header.Set("X-Transform-Relay", t.publicURL) |
| 581 | + request.Header.Set("X-Transform-Signature", signature) |
| 582 | request.Header.Set("User-Agent", "tinyrelay") |
| 583 | response, err := t.viewHTTPClient().Do(request) |
| 584 | if err != nil { |
| 585 | diff --git a/internal/daemon/mcp_tools.go b/internal/daemon/mcp_tools.go |
| 586 | index 19a9244..1ee0130 100644 |
| 587 | --- a/internal/daemon/mcp_tools.go |
| 588 | +++ b/internal/daemon/mcp_tools.go |
| 589 | @@ -167,7 +167,7 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) { |
| 590 | 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) { |
| 591 | return t.mcpManage(ctx, call, "addcallback", call.Arguments) |
| 592 | }) |
| 593 | - 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) { |
| 594 | + 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-Transform-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) { |
| 595 | return t.mcpManage(ctx, call, "addcustomview", call.Arguments) |
| 596 | }) |
| 597 | 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) { |
| 598 | @@ -179,8 +179,8 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) { |
| 599 | 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) { |
| 600 | return t.mcpManage(ctx, call, "resumecustomview", call.String("name")) |
| 601 | }) |
| 602 | - 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) { |
| 603 | - return t.mcpManage(ctx, call, "runcustomview", call.String("name")) |
| 604 | + 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 unless refresh is true, which rebuilds them from the transform. Owner only.", mcp.Object(map[string]any{"name": mcpID, "refresh": map[string]any{"type": "boolean", "description": "Rebuild existing artifacts from the transform."}}, "name"), mcpChanges, func(ctx context.Context, call mcp.Call) (mcp.Result, error) { |
| 605 | + return t.mcpManage(ctx, call, "runcustomview", call.Arguments) |
| 606 | }) |
| 607 | 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) { |
| 608 | return t.mcpManage(ctx, call, "removecallback", call.String("id")) |
| 609 | diff --git a/internal/views/views.go b/internal/views/views.go |
| 610 | index 273a26d..f77ca8e 100644 |
| 611 | --- a/internal/views/views.go |
| 612 | +++ b/internal/views/views.go |
| 613 | @@ -92,19 +92,27 @@ var HashPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) |
| 614 | |
| 615 | // Path is the address an artifact is served at. |
| 616 | func Path(view, hash, extension string) string { |
| 617 | - return "/views/" + view + "/" + hash + "." + extension |
| 618 | + path := "/views/" + view + "/" + hash |
| 619 | + if extension != "" { |
| 620 | + path += "." + extension |
| 621 | + } |
| 622 | + return path |
| 623 | } |
| 624 | |
| 625 | -// Figure is the markup for a block a custom view renders: the artifact as |
| 626 | -// an object with the escaped code block inside it, so a missing or failed |
| 627 | -// artifact shows the code without any script. |
| 628 | +// Figure is the markup for a block a custom view renders. The image remains |
| 629 | +// transparent and follows the page color scheme, while the native details |
| 630 | +// disclosure keeps the source available without requiring script. |
| 631 | func Figure(view, lang, source string) string { |
| 632 | var b strings.Builder |
| 633 | - b.WriteString(`<figure data-view="` + html.EscapeString(view) + `"><object data="` + Path(view, Hash(lang, source), "svg") + `" type="image/svg+xml"><pre><code`) |
| 634 | + alt := "Rendered view" |
| 635 | + if lang != "" { |
| 636 | + alt = "Rendered " + lang + " block" |
| 637 | + } |
| 638 | + b.WriteString(`<figure data-view="` + html.EscapeString(view) + `"><view-artifact><img src="` + Path(view, Hash(lang, source), "") + `" alt="` + html.EscapeString(alt) + `"><details><summary>Source</summary><pre><code`) |
| 639 | if lang != "" { |
| 640 | b.WriteString(` data-lang="` + html.EscapeString(lang) + `"`) |
| 641 | } |
| 642 | - b.WriteString(">" + html.EscapeString(source) + "</code></pre></object></figure>\n") |
| 643 | + b.WriteString(">" + html.EscapeString(source) + "</code></pre></details></view-artifact></figure>\n") |
| 644 | return b.String() |
| 645 | } |
| 646 | |
| 647 | diff --git a/internal/views/views_test.go b/internal/views/views_test.go |
| 648 | index 3b730f3..d06b152 100644 |
| 649 | --- a/internal/views/views_test.go |
| 650 | +++ b/internal/views/views_test.go |
| 651 | @@ -64,7 +64,7 @@ func TestHashCoversLanguageAndSource(t *testing.T) { |
| 652 | func TestFigureEmbedsTheArtifactWithTheCodeAsFallback(t *testing.T) { |
| 653 | got := Figure("diagrams", "mermaid", "graph TD;\n A-->B & <C>;") |
| 654 | hash := Hash("mermaid", "graph TD;\n A-->B & <C>;") |
| 655 | - want := `<figure data-view="diagrams"><object data="/views/diagrams/` + hash + `.svg" type="image/svg+xml"><pre><code data-lang="mermaid">graph TD;` + "\n" + ` A-->B & <C>;</code></pre></object></figure>` + "\n" |
| 656 | + want := `<figure data-view="diagrams"><view-artifact><img src="/views/diagrams/` + hash + `" alt="Rendered mermaid block"><details><summary>Source</summary><pre><code data-lang="mermaid">graph TD;` + "\n" + ` A-->B & <C>;</code></pre></details></view-artifact></figure>` + "\n" |
| 657 | if got != want { |
| 658 | t.Fatalf("figure =\n%s\nwant\n%s", got, want) |
| 659 | } |
| 660 | diff --git a/internal/webui/components.js b/internal/webui/components.js |
| 661 | index f829c66..1c50bb5 100644 |
| 662 | --- a/internal/webui/components.js |
| 663 | +++ b/internal/webui/components.js |
| 664 | @@ -20,6 +20,9 @@ |
| 665 | // Renders JSON as tables, lists and definition lists, keeping the raw |
| 666 | // text in a <details>. Server pages seed it with a <pre>; scripts set .value. |
| 667 | // Tables sit inside a <scroll-box> so wide rows scroll sideways. |
| 668 | +// <view-artifact> |
| 669 | +// Shows a custom view image and keeps its fenced source in a native |
| 670 | +// disclosure when the image cannot be loaded. |
| 671 | // <nostr-key hex="…"> |
| 672 | // Shortened key with the full hex as title; click copies it. |
| 673 | // <file-mirror action="/mirror"> |
| 674 | @@ -181,6 +184,16 @@ |
| 675 | } |
| 676 | } |
| 677 | |
| 678 | + class ViewArtifact extends HTMLElement { |
| 679 | + connectedCallback() { |
| 680 | + const image = this.querySelector("img"), source = this.querySelector("details"); |
| 681 | + if (!image || !source) return; |
| 682 | + const failed = () => { image.hidden = true; source.open = true; }; |
| 683 | + image.addEventListener("error", failed, {once: true}); |
| 684 | + if (image.complete && image.naturalWidth === 0) failed(); |
| 685 | + } |
| 686 | + } |
| 687 | + |
| 688 | // ViewForm adds a custom view: it composes the addcustomview object from |
| 689 | // its fields and shows the returned secret once, since the relay never |
| 690 | // shows it again. |
| 691 | @@ -1715,6 +1728,7 @@ |
| 692 | ensureModules(); |
| 693 | |
| 694 | customElements.define("rpc-form", RpcForm); |
| 695 | + customElements.define("view-artifact", ViewArtifact); |
| 696 | customElements.define("view-form", ViewForm); |
| 697 | customElements.define("connect-card", ConnectCard); |
| 698 | customElements.define("connect-list", ConnectList); |
| 699 | diff --git a/internal/webui/custom_views_test.go b/internal/webui/custom_views_test.go |
| 700 | index f6e5d73..d1de239 100644 |
| 701 | --- a/internal/webui/custom_views_test.go |
| 702 | +++ b/internal/webui/custom_views_test.go |
| 703 | @@ -46,7 +46,7 @@ func (b *viewsBackend) Query(ctx context.Context, method string, params []json.R |
| 704 | const mermaidSource = "graph TD;\n A-->B & <C>;" |
| 705 | |
| 706 | func mermaidFigure(view string) string { |
| 707 | - 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-->B & <C>;</code></pre></object></figure>` |
| 708 | + return `<figure data-view="` + view + `"><view-artifact><img src="/views/` + view + `/` + views.Hash("mermaid", mermaidSource) + `" alt="Rendered mermaid block"><details><summary>Source</summary><pre><code data-lang="mermaid">graph TD;` + "\n" + ` A-->B & <C>;</code></pre></details></view-artifact></figure>` |
| 709 | } |
| 710 | |
| 711 | func TestRenderersShowCustomViewArtifactsWithTheCodeAsFallback(t *testing.T) { |
| 712 | @@ -100,9 +100,9 @@ func TestPagesRenderCustomViewFiguresFromTheBackendSummary(t *testing.T) { |
| 713 | t.Fatalf("repo home: %d %s", recorder.Code, body) |
| 714 | } |
| 715 | hash := views.Hash("mermaid", "graph TD; a-->b;") |
| 716 | - figure := `<figure data-view="diagrams"><object data="/views/diagrams/` + hash + `.svg" type="image/svg+xml"><pre><code data-lang="mermaid">graph TD; a-->b;</code></pre></object></figure>` |
| 717 | + figure := `<figure data-view="diagrams"><view-artifact><img src="/views/diagrams/` + hash + `" alt="Rendered mermaid block"><details><summary>Source</summary><pre><code data-lang="mermaid">graph TD; a-->b;</code></pre></details></view-artifact></figure>` |
| 718 | wantAll(t, "repo home", body, figure, `href="/repo?owner=`) |
| 719 | - if prefixed := injectBase(figure, "/r/work"); !strings.Contains(prefixed, `<object data="/r/work/views/diagrams/`+hash+`.svg"`) { |
| 720 | + if prefixed := injectBase(figure, "/r/work"); !strings.Contains(prefixed, `<img src="/r/work/views/diagrams/`+hash+`"`) { |
| 721 | t.Fatalf("tenant prefix missed the object address: %s", prefixed) |
| 722 | } |
| 723 | // A backend without the summary keeps code blocks as code. |
| 724 | diff --git a/internal/webui/manage.html b/internal/webui/manage.html |
| 725 | index a1f8b10..c2dd68c 100644 |
| 726 | --- a/internal/webui/manage.html |
| 727 | +++ b/internal/webui/manage.html |
| 728 | @@ -246,8 +246,8 @@ |
| 729 | <h1>Views</h1> |
| 730 | <rpc-form method="listviews"><button>List views</button></rpc-form> |
| 731 | <rpc-form method="publishview"><label>View name <input name="param" required></label><button>Publish view</button></rpc-form> |
| 732 | -{{if .Owner}}<h3>Custom views</h3> |
| 733 | -<p>A custom view sends fenced code blocks to a transform you run and keeps what comes back as relay-signed artifacts shown in place of the code.</p> |
| 734 | +{{if .Owner}}<h3>Block transforms</h3> |
| 735 | +<p>Block transforms render fenced code as images. Choose the event kinds and languages to send to your rendering service.</p> |
| 736 | {{if .CustomViews}}<table id="custom-views"> |
| 737 | <thead><tr><th>name</th><th>kinds</th><th>languages</th><th>trigger</th><th>audience</th><th>state</th><th>last run</th><th></th></tr></thead> |
| 738 | <tbody>{{range .CustomViews}}{{$v := asMap .}}<tr id="view-{{str (index $v "name")}}"> |
| 739 | @@ -258,14 +258,14 @@ |
| 740 | <td>{{str (index $v "audience")}}</td> |
| 741 | <td data-state="{{viewState $v}}">{{viewState $v}}{{with str (index $v "lastStatus")}} | {{.}}{{end}}</td> |
| 742 | <td>{{with index $v "lastRun"}}{{when .}}{{else}}never{{end}}</td> |
| 743 | -<td>{{if eq (viewState $v) "paused"}}<rpc-form method="resumecustomview" refresh><input type="hidden" name="param" value="{{str (index $v "name")}}"><button>Resume</button></rpc-form>{{else}}<rpc-form method="pausecustomview" refresh><input type="hidden" name="param" value="{{str (index $v "name")}}"><button>Pause</button></rpc-form><rpc-form method="runcustomview" refresh><input type="hidden" name="param" value="{{str (index $v "name")}}"><button>Run now</button></rpc-form>{{end}}<rpc-form method="removecustomview" refresh><input type="hidden" name="param" value="{{str (index $v "name")}}"><button>Remove</button></rpc-form></td> |
| 744 | +<td>{{if eq (viewState $v) "paused"}}<rpc-form method="resumecustomview" refresh><input type="hidden" name="param" value="{{str (index $v "name")}}"><button>Resume</button></rpc-form>{{else}}<rpc-form method="pausecustomview" refresh><input type="hidden" name="param" value="{{str (index $v "name")}}"><button>Pause</button></rpc-form><rpc-form method="runcustomview" refresh><input type="hidden" name="param" value="{{str (index $v "name")}}"><button>Run now</button></rpc-form><rpc-form method="runcustomview" refresh><input type="hidden" name="param" value='{"name":"{{str (index $v "name")}}","refresh":true}'><button>Rebuild</button></rpc-form>{{end}}<rpc-form method="removecustomview" refresh><input type="hidden" name="param" value="{{str (index $v "name")}}"><button>Remove</button></rpc-form></td> |
| 745 | </tr>{{end}}</tbody> |
| 746 | </table>{{else}}<p id="custom-views-empty">No custom views yet.</p>{{end}} |
| 747 | <view-form method="addcustomview"> |
| 748 | <label>Name <input name="view-name" pattern="[a-z0-9-]{1,32}" placeholder="diagrams" required></label> |
| 749 | <label>Kinds <input name="view-kinds" placeholder="1, 30023, 30618" required></label> |
| 750 | <label>Languages <input name="view-languages" placeholder="mermaid, dot" required></label> |
| 751 | -<label>Transform URL <input name="view-transform" type="url" placeholder="https://render.example/tiny" required></label> |
| 752 | +<label>Transform URL <input name="view-transform" type="url" placeholder="https://render.example/transform/blocks" required></label> |
| 753 | <label>Trigger <select name="view-trigger"><option value="write">write</option><option value="hourly">hourly</option></select></label> |
| 754 | <label>Audience <select name="view-audience"><option value="public">public</option><option value="members">members</option></select></label> |
| 755 | <label>Max artifact bytes <input name="view-max-bytes" type="number" min="1" max="4194304" placeholder="1048576"></label> |
| 756 | diff --git a/internal/webui/style.css b/internal/webui/style.css |
| 757 | index cb23ced..da26a8d 100644 |
| 758 | --- a/internal/webui/style.css |
| 759 | +++ b/internal/webui/style.css |
| 760 | @@ -4,6 +4,7 @@ |
| 761 | html[data-theme=dark]{--paper:#171614;--ink:#d9d6cf;--muted:#8d887c;--rail:#1e1d1a;--line:#2c2a26;--accent:#8fc27a;--soft:#22301f;--hover:#242320;--add:#223521;--del:#3a2a24;--alert:#e08a7a;--g0:#26251f;--g1:#2a3d24;--g2:#3b5a2f;--g3:#4f7d3d;--g4:#8fc27a;--bark:#d3a15c;--w1:#d3a15c;--w2:#9cc48a;--w3:#6ea55a;--w4:#d98a5c} |
| 762 | @media(prefers-color-scheme:dark){html:not([data-theme=light]){--paper:#171614;--ink:#d9d6cf;--muted:#8d887c;--rail:#1e1d1a;--line:#2c2a26;--accent:#8fc27a;--soft:#22301f;--hover:#242320;--add:#223521;--del:#3a2a24;--alert:#e08a7a;--g0:#26251f;--g1:#2a3d24;--g2:#3b5a2f;--g3:#4f7d3d;--g4:#8fc27a;--bark:#d3a15c;--w1:#d3a15c;--w2:#9cc48a;--w3:#6ea55a;--w4:#d98a5c}} |
| 763 | |
| 764 | +html{color-scheme:light}html[data-theme=dark]{color-scheme:dark}@media(prefers-color-scheme:dark){html:not([data-theme=light]){color-scheme:dark}} |
| 765 | html,body{margin:0;padding:0;height:100%;overflow:hidden;overscroll-behavior:none} |
| 766 | body{font:14px/1.45 var(--sans);color:var(--ink);background:var(--paper)} |
| 767 | a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline} |
| 768 | @@ -161,8 +162,8 @@ view-form{display:block;border-bottom:1px solid var(--line);padding:10px 0;max-w |
| 769 | view-form label{display:inline-block;margin:0 10px 6px 0;vertical-align:top;font:13px var(--sans);color:var(--muted)} |
| 770 | view-form label input,view-form label select{display:block;margin-top:2px;font:13px var(--mono);min-width:200px} |
| 771 | view-form [role=alert]{color:var(--alert);font:13px var(--sans);margin:8px 0 4px}view-form label[data-secret] input{min-width:320px} |
| 772 | -/* figure[data-view]: an artifact a custom view rendered; the code inside the object shows when the artifact is missing. */ |
| 773 | -figure[data-view]{margin:12px 0}figure[data-view] object{display:block;max-width:100%}figure[data-view] pre{margin:0} |
| 774 | +/* figure[data-view]: a transparent artifact with a native source disclosure. */ |
| 775 | +figure[data-view]{margin:12px 0}view-artifact{display:block}view-artifact img{display:block;max-width:100%;height:auto;background:transparent}view-artifact img[hidden]{display:none}view-artifact details{margin-top:6px}view-artifact details>summary{cursor:pointer;color:var(--muted);font:12px var(--mono)}view-artifact pre{margin:6px 0 0} |
| 776 | /* agent-callback: one registered callback on an agent card, with its controls. */ |
| 777 | agent-callback{display:block;margin:0 0 4px}agent-callback code{color:inherit} |
| 778 | agent-callback rpc-form{display:inline-block;border:0;padding:0;margin:0 0 0 6px;vertical-align:middle}agent-callback rpc-form output:empty{display:none}agent-callback button{font-size:12px;padding:1px 6px} |
| 779 | diff --git a/package.json b/package.json |
| 780 | index 35a2a6f..d7d951c 100644 |
| 781 | --- a/package.json |
| 782 | +++ b/package.json |
| 783 | @@ -15,7 +15,8 @@ |
| 784 | "test:wiki": "node --test scripts/wiki-compose.test.mjs", |
| 785 | "test:rooms": "node --test scripts/rooms.test.mjs", |
| 786 | "test:profile": "node --test scripts/profile-form.test.mjs", |
| 787 | - "test:page-link": "node --test scripts/page-link.test.mjs" |
| 788 | + "test:page-link": "node --test scripts/page-link.test.mjs", |
| 789 | + "test:view-artifact": "node --test scripts/view-artifact.test.mjs" |
| 790 | }, |
| 791 | "devDependencies": { |
| 792 | "@noble/hashes": "2.4.0", |
| 793 | diff --git a/scripts/view-artifact.test.mjs b/scripts/view-artifact.test.mjs |
| 794 | new file mode 100644 |
| 795 | index 0000000..1c3dd6d |
| 796 | --- /dev/null |
| 797 | +++ b/scripts/view-artifact.test.mjs |
| 798 | @@ -0,0 +1,39 @@ |
| 799 | +import test from "node:test"; |
| 800 | +import assert from "node:assert/strict"; |
| 801 | +import fs from "node:fs"; |
| 802 | +import vm from "node:vm"; |
| 803 | + |
| 804 | +const source = fs.readFileSync("internal/webui/components.js", "utf8"); |
| 805 | +const classSource = source.slice(source.indexOf(" class ViewArtifact"), source.indexOf(" // ViewForm adds")); |
| 806 | + |
| 807 | +class HTMLElement {} |
| 808 | +const ViewArtifact = vm.runInNewContext(`${classSource}\nViewArtifact`, {HTMLElement}); |
| 809 | + |
| 810 | +const setup = ({complete = false, naturalWidth = 100} = {}) => { |
| 811 | + const listeners = {}; |
| 812 | + const image = {complete, naturalWidth, hidden: false, addEventListener: (name, fn) => { listeners[name] = fn; }}; |
| 813 | + const details = {open: false}; |
| 814 | + const artifact = new ViewArtifact(); |
| 815 | + artifact.querySelector = selector => selector === "img" ? image : details; |
| 816 | + artifact.connectedCallback(); |
| 817 | + return {image, details, fail: () => listeners.error()}; |
| 818 | +}; |
| 819 | + |
| 820 | +test("view artifact keeps a loaded image and source disclosure collapsed", () => { |
| 821 | + const {image, details} = setup({complete: true}); |
| 822 | + assert.equal(image.hidden, false); |
| 823 | + assert.equal(details.open, false); |
| 824 | +}); |
| 825 | + |
| 826 | +test("view artifact opens source and hides a failed image", () => { |
| 827 | + const {image, details, fail} = setup(); |
| 828 | + fail(); |
| 829 | + assert.equal(image.hidden, true); |
| 830 | + assert.equal(details.open, true); |
| 831 | +}); |
| 832 | + |
| 833 | +test("view artifact handles an already failed cached image", () => { |
| 834 | + const {image, details} = setup({complete: true, naturalWidth: 0}); |
| 835 | + assert.equal(image.hidden, true); |
| 836 | + assert.equal(details.open, true); |
| 837 | +}); |