- commit
- cd6f6d0eca9accf4d7e5a60bf842c97f1d3064ef
- parent
- 8dd79a2655be8af901f089c924c060493daebb5b
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 44 | +++++++++++++++++++++++++- |
| A | internal/ | 31 | ++++++++++++++++++ |
| M | internal/ | 26 | +++++++++++++++ |
| M | internal/ | 51 | +++++++++++++++++++++++++----- |
| M | internal/ | 2 | +- |
| M | internal/ | 2 | +- |
| M | internal/ | 13 | ++++++++ |
| M | internal/ | 11 | ++++-- |
| M | internal/ | 51 | ++++++++++++++++++++++++++---- |
| M | internal/ | 4 | +- |
| M | internal/ | 25 | ++++++++++++++- |
@@ -181,6 +181,47 @@
181181 }182182 }183183 184+ // ViewForm adds a custom view: it composes the addcustomview object from185+ // its fields and shows the returned secret once, since the relay never186+ // shows it again.187+ class ViewForm extends RpcForm {188+ params(form) {189+ const value = name => (form.elements[name]?.value || "").trim();190+ const options = {191+ name: value("view-name"),192+ kinds: list(value("view-kinds")).map(Number),193+ languages: list(value("view-languages")),194+ transform: value("view-transform"),195+ trigger: value("view-trigger") || "write",196+ audience: value("view-audience") || "public"197+ };198+ if (value("view-max-bytes")) options.max_bytes = Number(value("view-max-bytes"));199+ if (value("view-secret")) options.secret = value("view-secret");200+ return [options];201+ }202+203+ show(result) {204+ const secret = result && typeof result === "object" ? result.secret : "";205+ if (!secret) return super.show(result);206+ const warning = el("p", "Copy the view's secret now. The relay signs each transform request with it and will not show it again.");207+ warning.setAttribute("role", "alert");208+ const label = el("label", "View secret ");209+ label.setAttribute("data-secret", "");210+ const input = el("input");211+ input.readOnly = true;212+ input.name = "secret";213+ input.value = secret;214+ input.addEventListener("focus", () => input.select());215+ label.append(input);216+ const link = el("a", "Show the new view");217+ link.href = globalThis.location?.pathname || "";218+ const note = el("p");219+ note.append(link);220+ this.form.reset();221+ this.append(warning, label, note);222+ }223+ }224+184225 class SignedForm extends FormElement {185226 async submit(form) {186227 const action = this.getAttribute("action");@@ -1674,6 +1715,7 @@
16741715 ensureModules();16751716 16761717 customElements.define("rpc-form", RpcForm);1718+ customElements.define("view-form", ViewForm);16771719 customElements.define("connect-card", ConnectCard);16781720 customElements.define("connect-list", ConnectList);16791721 customElements.define("relay-lists", RelayLists);@@ -1737,7 +1779,7 @@
17371779 document.addEventListener("fx:sse:close", event => streams.delete(event.detail.cfg));17381780 window.addEventListener("pagehide", closeStreams);17391781 const repairComponents = () => {1740- document.querySelectorAll("rpc-form,signed-form,publish-list,agent-grant,profile-form,nostr-react,wiki-compose,room-compose,room-create,room-action").forEach(node => {1782+ document.querySelectorAll("rpc-form,view-form,signed-form,publish-list,agent-grant,profile-form,nostr-react,wiki-compose,room-compose,room-create,room-action").forEach(node => {17411783 if (node.form?.isConnected) return;17421784 node.form = null;17431785 node.output = null;@@ -0,0 +1,31 @@
1+package webui2+3+import "strings"4+5+// customViewState is the row state on the Views page: paused when the6+// owner or the relay paused the view, active otherwise.7+func customViewState(view any) string {8+ if valueMap(view)["enabled"] == false {9+ return "paused"10+ }11+ return "active"12+}13+14+// customViewKinds lists the kinds a custom view watches.15+func customViewKinds(view any) string {16+ return joinValues(valueMap(view)["kinds"])17+}18+19+// customViewLanguages lists the fenced block languages a custom view renders.20+func customViewLanguages(view any) string {21+ return joinValues(valueMap(view)["languages"])22+}23+24+func joinValues(value any) string {25+ items, _ := value.([]any)26+ parts := make([]string, 0, len(items))27+ for _, item := range items {28+ parts = append(parts, plainString(item))29+ }30+ return strings.Join(parts, ", ")31+}@@ -246,6 +246,32 @@
246246 <h1>Views</h1>247247 <rpc-form method="listviews"><button>List views</button></rpc-form>248248 <rpc-form method="publishview"><label>View name <input name="param" required></label><button>Publish view</button></rpc-form>249+{{if .Owner}}<h3>Custom views</h3>250+<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>251+{{if .CustomViews}}<table id="custom-views">252+<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>253+<tbody>{{range .CustomViews}}{{$v := asMap .}}<tr id="view-{{str (index $v "name")}}">254+<td><code>{{str (index $v "name")}}</code></td>255+<td>{{viewKinds $v}}</td>256+<td>{{viewLanguages $v}}</td>257+<td>{{str (index $v "trigger")}}</td>258+<td>{{str (index $v "audience")}}</td>259+<td data-state="{{viewState $v}}">{{viewState $v}}{{with str (index $v "lastStatus")}} | {{.}}{{end}}</td>260+<td>{{with index $v "lastRun"}}{{when .}}{{else}}never{{end}}</td>261+<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>262+</tr>{{end}}</tbody>263+</table>{{else}}<p id="custom-views-empty">No custom views yet.</p>{{end}}264+<view-form method="addcustomview">265+<label>Name <input name="view-name" pattern="[a-z0-9-]{1,32}" placeholder="diagrams" required></label>266+<label>Kinds <input name="view-kinds" placeholder="1, 30023, 30618" required></label>267+<label>Languages <input name="view-languages" placeholder="mermaid, dot" required></label>268+<label>Transform URL <input name="view-transform" type="url" placeholder="https://render.example/tiny" required></label>269+<label>Trigger <select name="view-trigger"><option value="write">write</option><option value="hourly">hourly</option></select></label>270+<label>Audience <select name="view-audience"><option value="public">public</option><option value="members">members</option></select></label>271+<label>Max artifact bytes <input name="view-max-bytes" type="number" min="1" max="4194304" placeholder="1048576"></label>272+<label>Secret <input name="view-secret" minlength="16" maxlength="128" placeholder="generated when empty"></label>273+<button>Add view</button>274+</view-form>{{end}}249275 </section>250276 {{end}}251277 @@ -7,25 +7,42 @@ import (
77 "path"88 "regexp"99 "strings"10+11+ "github.com/FelineStateMachine/tinyrelay/internal/views"1012 )1113 14+// blockRenderer decides how a fenced code block is shown. It returns the15+// markup for blocks a custom view renders and false for the rest, which16+// stay code blocks.17+type blockRenderer func(lang, source string) (string, bool)18+1219 // renderMarkdown turns a small, common subset of Markdown into HTML: ATX1320 // headings, paragraphs, fenced code, unordered and ordered lists, inline1421 // code, emphasis, and links. Every piece of text is escaped first and only1522 // the tags this function emits reach the page, so untrusted README files and1623 // relay descriptions cannot inject markup. Anything else stays plain text.1724 func renderMarkdown(source string) template.HTML {18- return markdownHTML(source, false)25+ return markdownHTML(source, false, nil)26+}27+28+// renderMarkdownWith is renderMarkdown with fenced blocks offered to a29+// custom view renderer first.30+func renderMarkdownWith(source string, blocks blockRenderer) template.HTML {31+ return markdownHTML(source, false, blocks)1932 }2033 2134 // renderChatMarkdown is the same subset for chat messages, where a single2235 // newline is a line break and bare http(s) and nostr: references become2336 // links, as people and agents type them.2437 func renderChatMarkdown(source string) template.HTML {25- return template.HTML(autolinkOutsideTags(string(markdownHTML(source, true))))38+ return renderChatMarkdownWith(source, nil)39+}40+41+func renderChatMarkdownWith(source string, blocks blockRenderer) template.HTML {42+ return template.HTML(autolinkOutsideTags(string(markdownHTML(source, true, blocks))))2643 }2744 28-func markdownHTML(source string, breaks bool) template.HTML {45+func markdownHTML(source string, breaks bool, blocks blockRenderer) template.HTML {2946 lines := strings.Split(strings.ReplaceAll(source, "\r\n", "\n"), "\n")3047 var out strings.Builder3148 var paragraph []string@@ -46,14 +63,26 @@ func markdownHTML(source string, breaks bool) template.HTML {
4663 }4764 for i := 0; i < len(lines); i++ {4865 line := lines[i]66+ marker, lang, fenced := views.OpenFence(line)4967 switch {50- case strings.HasPrefix(line, "```"):68+ case fenced:5169 flush()5270 var code []string53- for i++; i < len(lines) && !strings.HasPrefix(lines[i], "```"); i++ {71+ for i++; i < len(lines) && !views.ClosesFence(lines[i], marker); i++ {5472 code = append(code, lines[i])5573 }56- out.WriteString("<pre><code>" + html.EscapeString(strings.Join(code, "\n")) + "</code></pre>\n")74+ body := strings.Join(code, "\n")75+ if blocks != nil {76+ if figure, ok := blocks(lang, body); ok {77+ out.WriteString(figure)78+ continue79+ }80+ }81+ out.WriteString("<pre><code")82+ if lang != "" {83+ out.WriteString(` data-lang="` + html.EscapeString(lang) + `"`)84+ }85+ out.WriteString(">" + html.EscapeString(body) + "</code></pre>\n")5786 case strings.HasPrefix(line, "#"):5887 flush()5988 level := len(line) - len(strings.TrimLeft(line, "#"))@@ -137,7 +166,11 @@ var markdownAnchor = regexp.MustCompile(`<a href="([^"]+)">`)
137166 // renderRepositoryMarkdown resolves relative README links to relay file138167 // viewers while retaining external links and fragment-only links.139168 func renderRepositoryMarkdown(source string, query url.Values) template.HTML {140- rendered := string(renderMarkdown(source))169+ return renderRepositoryMarkdownWith(source, query, nil)170+}171+172+func renderRepositoryMarkdownWith(source string, query url.Values, blocks blockRenderer) template.HTML {173+ rendered := string(renderMarkdownWith(source, blocks))141174 return template.HTML(markdownAnchor.ReplaceAllStringFunc(rendered, func(anchor string) string {142175 match := markdownAnchor.FindStringSubmatch(anchor)143176 target, err := url.Parse(html.UnescapeString(match[1]))@@ -183,9 +216,9 @@ func autolinkOutsideTags(rendered string) string {
183216 if strings.HasPrefix(part, "<") {184217 lower := strings.ToLower(part)185218 switch {186- case strings.HasPrefix(lower, "<a ") || strings.HasPrefix(lower, "<code") || strings.HasPrefix(lower, "<pre"):219+ case strings.HasPrefix(lower, "<a ") || strings.HasPrefix(lower, "<code") || strings.HasPrefix(lower, "<pre") || strings.HasPrefix(lower, "<object"):187220 skip++188- case strings.HasPrefix(lower, "</a") || strings.HasPrefix(lower, "</code") || strings.HasPrefix(lower, "</pre"):221+ case strings.HasPrefix(lower, "</a") || strings.HasPrefix(lower, "</code") || strings.HasPrefix(lower, "</pre") || strings.HasPrefix(lower, "</object"):189222 if skip > 0 {190223 skip--191224 }@@ -8,7 +8,7 @@ import (
88 99 func TestMarkdownRendersCommonFormsAndEscapesTheRest(t *testing.T) {1010 got := string(renderMarkdown("# Title\n\nA `code` span with **bold** and *em* and [a link](https://example.com).\n\n```sh\ngo build <x>\n```\n\n- one\n- two\n\n1. first\n2. second\n\n<script>alert(1)</script> and [bad](javascript:alert(1))"))11- for _, want := range []string{"<h1>Title</h1>", "<code>code</code>", "<strong>bold</strong>", "<em>em</em>", `<a href="https://example.com">a link</a>`, "<pre><code>go build <x></code></pre>", "<ul><li>one</li><li>two</li></ul>", "<ol><li>first</li><li>second</li></ol>", "<script>alert(1)</script>"} {11+ for _, want := range []string{"<h1>Title</h1>", "<code>code</code>", "<strong>bold</strong>", "<em>em</em>", `<a href="https://example.com">a link</a>`, `<pre><code data-lang="sh">go build <x></code></pre>`, "<ul><li>one</li><li>two</li></ul>", "<ol><li>first</li><li>second</li></ol>", "<script>alert(1)</script>"} {1212 if !strings.Contains(got, want) {1313 t.Errorf("missing %q in %s", want, got)1414 }@@ -7,7 +7,7 @@ import (
77 "golang.org/x/net/html"88 )99 10-var rootURLAttribute = regexp.MustCompile(`\s(?:href|src|fx-action|action)="(/[^\"]*)"`)10+var rootURLAttribute = regexp.MustCompile(`\s(?:href|src|fx-action|action|data)="(/[^\"]*)"`)1111 1212 // injectBase scopes root-relative template URLs without changing scripts or1313 // literal content. The signer bridge scopes its requests with localPath.@@ -150,6 +150,19 @@ agent-card p>a{display:inline-block;font:12px var(--mono);line-height:29px;verti
150150 #decided{list-style:none;margin:8px 0 0;padding:0;max-width:720px}#decided li{padding:3px 0;font:13px var(--mono)}#decided li small{color:var(--muted)}151151 #decided li[data-state=approved] small{color:var(--accent)}#decided li[data-state=denied] small{color:var(--alert)}152152 153+/* #custom-views: the owner's custom views with their controls. view-form adds one and shows its secret once. */154+#custom-views{border-collapse:collapse;width:100%;max-width:960px;font:13px var(--mono)}155+#custom-views th{text-align:left;font:12px var(--mono);color:var(--muted);font-weight:normal;padding:0 6px;height:28px;border-bottom:1px solid var(--line)}156+#custom-views td{padding:8px 6px;border-bottom:1px solid var(--line);vertical-align:top;overflow-wrap:anywhere;line-height:1.35}157+#custom-views td:nth-child(7){white-space:nowrap;color:var(--muted);font-size:12px}#custom-views td:last-child{white-space:nowrap;width:210px}158+#custom-views rpc-form{display:inline-block;border:0;padding:0;margin:0 6px 0 0;vertical-align:middle}#custom-views rpc-form output:empty{display:none}#custom-views button{font-size:12px;padding:1px 6px}159+#custom-views-empty{color:var(--muted);font:13px var(--sans)}160+view-form{display:block;border-bottom:1px solid var(--line);padding:10px 0;max-width:720px}161+view-form label{display:inline-block;margin:0 10px 6px 0;vertical-align:top;font:13px var(--sans);color:var(--muted)}162+view-form label input,view-form label select{display:block;margin-top:2px;font:13px var(--mono);min-width:200px}163+view-form [role=alert]{color:var(--alert);font:13px var(--sans);margin:8px 0 4px}view-form label[data-secret] input{min-width:320px}164+/* figure[data-view]: an artifact a custom view rendered; the code inside the object shows when the artifact is missing. */165+figure[data-view]{margin:12px 0}figure[data-view] object{display:block;max-width:100%}figure[data-view] pre{margin:0}153166 /* agent-callback: one registered callback on an agent card, with its controls. */154167 agent-callback{display:block;margin:0 0 4px}agent-callback code{color:inherit}155168 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}@@ -236,7 +236,7 @@ var scriptsVersion = func() string {
236236 return hex.EncodeToString(digest.Sum(nil))[:12]237237 }()238238 239-func parseTemplates() (*template.Template, error) {239+func parseTemplates(a *App) (*template.Template, error) {240240 funcs := template.FuncMap{241241 "stylesheet": func() template.CSS { return template.CSS(styleCSS) },242242 "scriptURL": func(name string) (string, error) {@@ -259,8 +259,8 @@ func parseTemplates() (*template.Template, error) {
259259 "str": plainString,260260 "datetime": datetime,261261 "when": when,262- "markdown": renderMarkdown,263- "chatMarkdown": renderChatMarkdown,262+ "markdown": a.markdown,263+ "chatMarkdown": a.chatMarkdown,264264 "hasPrefix": strings.HasPrefix,265265 "npub": identityNpub,266266 "wsURL": wsURL,@@ -310,7 +310,10 @@ func parseTemplates() (*template.Template, error) {
310310 "approvalViews": approvalViews,311311 "approvalCounts": approvalCounts,312312 "approvalDevices": approvalDevices,313- "wikiHTML": wikiHTML,313+ "wikiHTML": a.wikiHTML,314+ "viewState": customViewState,315+ "viewKinds": customViewKinds,316+ "viewLanguages": customViewLanguages,314317 "wikiView": wikiPageView,315318 "wikiProposal": wikiProposal,316319 "wikiURL": wikiURL,@@ -18,6 +18,7 @@ import (
1818 "time"1919 2020 "github.com/FelineStateMachine/tinyrelay/internal/policy"21+ "github.com/FelineStateMachine/tinyrelay/internal/views"2122 "github.com/skip2/go-qrcode"2223 )2324 @@ -42,6 +43,12 @@ type Backend interface {
4243 Identity() string4344 }4445 46+// CustomViewSource is the optional backend summary of the custom views the47+// renderers show in place of fenced code blocks: names and languages only.48+type CustomViewSource interface {49+ CustomViews() []views.View50+}51+4552 type ActorResolver func(*http.Request) (string, error)4653 type QRProvider func(string) ([]byte, error)4754 @@ -94,6 +101,8 @@ type PageData struct {
94101 Rooms []any95102 // Callbacks lists the event callbacks shown beside each agent.96103 Callbacks []any104+ // CustomViews lists the owner's custom views on the Views page.105+ CustomViews []any97106 // Requests lists the access requests on the People page, pending first;98107 // Review is true when the viewer may decide them.99108 Requests []any@@ -104,15 +113,39 @@ func New(backend Backend, options Options) (*App, error) {
104113 if backend == nil {105114 return nil, fmt.Errorf("webui backend is required")106115 }107- tmpl, err := parseTemplates()108- if err != nil {109- return nil, err110- }111116 qr := options.QR112117 if qr == nil {113118 qr = defaultQR114119 }115- return &App{backend: backend, actor: options.Actor, qr: qr, tmpl: tmpl, version: options.Version, revision: options.Revision}, nil120+ a := &App{backend: backend, actor: options.Actor, qr: qr, version: options.Version, revision: options.Revision}121+ tmpl, err := parseTemplates(a)122+ if err != nil {123+ return nil, err124+ }125+ a.tmpl = tmpl126+ return a, nil127+}128+129+// blocks is the block renderer for the custom views the backend reports,130+// or nil when there are none so fenced blocks stay code.131+func (a *App) blocks() blockRenderer {132+ source, ok := a.backend.(CustomViewSource)133+ if !ok {134+ return nil135+ }136+ renderer := views.NewRenderer(source.CustomViews())137+ if len(renderer.Languages) == 0 {138+ return nil139+ }140+ return renderer.Block141+}142+143+func (a *App) markdown(source string) template.HTML {144+ return renderMarkdownWith(source, a.blocks())145+}146+147+func (a *App) chatMarkdown(source string) template.HTML {148+ return renderChatMarkdownWith(source, a.blocks())116149 }117150 118151 func defaultQR(text string) ([]byte, error) {@@ -711,7 +744,7 @@ func (a *App) readme(ctx context.Context, actor string, query url.Values) templa
711744 if binary, _ := page["binary"].(bool); binary || content == "" {712745 continue713746 }714- return renderRepositoryMarkdown(content, query)747+ return renderRepositoryMarkdownWith(content, query, a.blocks())715748 }716749 return ""717750 }@@ -925,6 +958,12 @@ func (a *App) page(writer http.ResponseWriter, request *http.Request) {
925958 if tab == "people" && actor != "" {926959 a.peoplePage(request, actor, &data)927960 }961+ if tab == "views" && actor != "" {962+ // The listing is owner-only; anyone else keeps the built-in controls.963+ if result, err := a.backend.Query(request.Context(), "listcustomviews", nil, actor); err == nil {964+ data.CustomViews = browseRows(result)965+ }966+ }928967 if tab != "" {929968 data.Title = tab + " | " + a.backend.Slug()930969 }@@ -153,8 +153,8 @@ func wikiURL(d string, pairs ...string) string {
153153 return path + "?" + values.Encode()154154 }155155 156-func wikiHTML(content any) template.HTML {157- return wiki.RenderHTML(plainString(content))156+func (a *App) wikiHTML(content any) template.HTML {157+ return wiki.RenderHTMLWith(plainString(content), wiki.Options{Block: a.blocks()})158158 }159159 160160 // wikiPageName is the name in a /wiki/<d> path, normalized.@@ -89,6 +89,20 @@ func Links(content string) []string {
8989 // escaped text. Link targets are limited to http, https, mailto, nostr and9090 // site-relative paths.9191 func RenderHTML(content string) template.HTML {92+ return RenderHTMLWith(content, Options{})93+}94+95+// Options adjusts rendering. Block, when set, is offered every fenced code96+// block with its language and source and returns markup to show in its97+// place, or false to keep the code block. The relay uses it for custom98+// views without this package knowing about them.99+type Options struct {100+ Block func(lang, source string) (html string, ok bool)101+}102+103+// RenderHTMLWith renders Djot the way RenderHTML does, with the options104+// applied.105+func RenderHTMLWith(content string, options Options) template.HTML {92106 refs := definitions(content)93107 var b strings.Builder94108 for _, block := range blocks(content) {@@ -96,6 +110,12 @@ func RenderHTML(content string) template.HTML {
96110 case "def":97111 continue98112 case "pre":113+ if options.Block != nil {114+ if html, ok := options.Block(block.lang, strings.TrimSuffix(block.text, "\n")); ok {115+ b.WriteString(html)116+ continue117+ }118+ }99119 b.WriteString("<pre><code")100120 if block.lang != "" {101121 b.WriteString(` data-lang="` + template.HTMLEscapeString(block.lang) + `"`)@@ -184,10 +204,11 @@ func blocks(content string) []block {
184204 }185205 if fence := trimmed[:min(3, len(trimmed))]; fence == "```" || fence == "~~~" {186206 flush()187- lang := append(strings.Fields(trimmed[3:]), "")[0]207+ run := len(trimmed) - len(strings.TrimLeft(trimmed, fence[:1]))208+ lang := strings.ToLower(append(strings.Fields(trimmed[run:]), "")[0])188209 var body []string189210 for i++; i < len(lines); i++ {190- if t := strings.TrimSpace(lines[i]); len(t) >= 3 && strings.Trim(t, fence[:1]) == "" {211+ if t := strings.TrimSpace(lines[i]); len(t) >= run && strings.Trim(t, fence[:1]) == "" {191212 break192213 }193214 body = append(body, lines[i])