clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

serve browser scripts as cached files loaded where used and anchor the theme control in the rail

Dami committed aad6cc9
commit
aad6cc915b4103587f8731820e51f4dbee8bb409
parent
88d0641c934981c0a13bde054eb84989ab60007c
author
Dami <dami.etoile@gmail.com>
10 files changed, +139 -25
Minternal/webui/browse_test.go21++++++++++++++++++--
Minternal/webui/components.js21+++++++++++++++++++-
Minternal/webui/page.html22++++++++++----------
Minternal/webui/shell_test.go5++++-
Minternal/webui/style.css6++++--
Minternal/webui/sw.js2+-
Minternal/webui/templates.go32+++++++++++++++++++++++++-----
Minternal/webui/tiny.js24+++++++++++++++++++++++
Minternal/webui/webui.go15++++++++++++++
Minternal/webui/webui_test.go16+++++++++++++--
M internal/webui/browse_test.go +19 -2
@@ -49,7 +49,7 @@ func TestBrowseRoutesUseObjectContractsAndRenderData(t *testing.T) {
4949 {"/repo?owner=alice&repo=notes&view=file", "browserepo", "one"},5050 {"/files", "browsefiles", "deadbeef"},5151 {"/file?hash=deadbeef", "browsefile", "blob"},52- {"/manage/status", "browsestatus", "storage"},52+ {"/manage/status", "browsestatus", "manage this relay"},5353 }5454 for _, test := range tests {5555 recorder := httptest.NewRecorder()
@@ -79,11 +79,17 @@ func TestConnectPublishesGRASPAndDeliveryKinds(t *testing.T) {
7979 recorder := httptest.NewRecorder()8080 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/connect", nil))8181 body := recorder.Body.String()82- for _, marker := range []string{`<publish-list kind="10317" tag="g" scheme="ws">`, `<publish-list kind="10063" tag="server">`, `name="lines"`, `customElements.define("publish-list"`, `tiny.signedFetch("/events", "POST"`} {82+ for _, marker := range []string{`<publish-list kind="10317" tag="g" scheme="ws">`, `<publish-list kind="10063" tag="server">`, `name="lines"`, `src="/scripts/components.js?v=`} {8383 if !strings.Contains(body, marker) {8484 t.Fatalf("connect page missing %q", marker)8585 }8686 }87+ components := servedScript(t, app, "/scripts/components.js")88+ for _, marker := range []string{`customElements.define("publish-list"`, `tiny.signedFetch("/events", "POST"`} {89+ if !strings.Contains(components, marker) {90+ t.Fatalf("components script missing %q", marker)91+ }92+ }8793 }8894 8995 func TestBrowseRendersTypedGitRelayPage(t *testing.T) {
@@ -141,3 +147,14 @@ func (b *typedBrowseBackend) Query(_ context.Context, method string, _ []json.Ra
141147 }142148 return b.browseBackend.Query(context.Background(), method, nil, "")143149 }150+151+// servedScript fetches one of the cached browser scripts through the app.152+func servedScript(t *testing.T, app *App, path string) string {153+ t.Helper()154+ recorder := httptest.NewRecorder()155+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))156+ if recorder.Code != http.StatusOK {157+ t.Fatalf("%s: status=%d", path, recorder.Code)158+ }159+ return recorder.Body.String()160+}
M internal/webui/components.js +20 -1
@@ -220,6 +220,9 @@
220220 connectedCallback() {221221 if (this.bound) return;222222 this.bound = true;223+ // The encryption and messaging modules load on demand; the controls224+ // render right away and decryption waits for them.225+ this.ready = Promise.resolve(tiny.require?.("files")).catch(() => {});223226 this.innerHTML = '<h3>Share</h3><p><button type="button" data-copy>Copy Blossom URI</button> <button type="button" data-link>Copy share link</button> <share-link></share-link></p><form data-share><label>Send to <input name="recipient" inputmode="text" placeholder="npub or hex pubkey"></label><button>Send privately</button></form><output role="status"></output>';224227 this.output = this.querySelector("output");225228 this.querySelector("[data-copy]").addEventListener("click", () => this.copy(this.blossomURI()));
@@ -229,7 +232,7 @@
229232 const params = new URLSearchParams(location.hash.slice(1));230233 this.querySelector("[data-share]").hidden = !(params.get("key") && params.get("iv"));231234 this.updateFileControls();232- this.decryptFromFragment();235+ this.ready.then(() => this.decryptFromFragment());233236 }234237 235238 hash() { return this.getAttribute("hash") || ""; }
@@ -279,6 +282,7 @@
279282 280283 async share(form) {281284 if (this.sharing) return;285+ await this.ready;282286 const recipient = form.elements.namedItem("recipient").value.trim();283287 if (!recipient) { this.say("Enter a public key or npub.", true); return; }284288 const params = new URLSearchParams(location.hash.slice(1));
@@ -820,6 +824,20 @@
820824 }821825 }822826 827+ // ensureModules loads the feature bundle for any custom element on the828+ // page whose definition has not arrived, such as after an in-place829+ // navigation to the Files or Connect pages.830+ const bundlesFor = {"file-upload": "files", "file-workspace-root": "files", "private-services": "private"};831+ const ensureModules = () => {832+ if (!tiny.require || typeof customElements.get !== "function") return;833+ const wanted = new Set();834+ for (const [tag, bundle] of Object.entries(bundlesFor)) {835+ if (!customElements.get(tag) && document.querySelector(tag)) wanted.add(bundle);836+ }837+ wanted.forEach(bundle => tiny.require(bundle).catch(() => {}));838+ };839+ ensureModules();840+823841 customElements.define("rpc-form", RpcForm);824842 customElements.define("connect-card", ConnectCard);825843 customElements.define("connect-list", ConnectList);
@@ -902,6 +920,7 @@
902920 if (focus && typeof focus.focus === "function") focus.focus({preventScroll: true});903921 else document.querySelector("#content")?.focus({preventScroll: true});904922 document.dispatchEvent(new CustomEvent("tiny:navigation", {detail: {url: url.href}}));923+ ensureModules();905924 decorate();906925 decorateForms();907926 document.dispatchEvent(new CustomEvent("fx:process", {detail: {url: url.href}, bubbles: false}));
M internal/webui/page.html +11 -11
@@ -1,5 +1,5 @@
11 {{define "page"}}<!doctype html>2-<html lang="en" data-js="no">2+<html lang="en" data-js="no" data-scripts="{{scriptsVersion}}">33 <head>44 <meta charset="utf-8">55 <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
@@ -62,16 +62,16 @@
6262 <td id="aside"><div id="panel">{{template "panel" .}}</div></td>6363 </tr></tbody></table>6464 <footer id="footer"><table><tr><td><span id="prompt"><b>{{.Slug}}</b> &raquo; {{prompt .Path .Query}}</span></td><td><span id="who">{{if .Private}}private relay{{else if .Actor}}signed in as {{template "nostrKey" .Actor}} (<button id="session-logout" type="button">sign out</button>){{else}}browsing as guest (<a href="/signin">Sign in</a>){{end}}</span> <span id="session-status" role="status"></span><span id="navigation-status" role="status" aria-live="polite"></span></td></tr></table></footer>65-<script>{{script "tiny.js"}}</script>66-<script>{{script "bridge.js"}}</script>67-<script>{{script "blossom-encryption.js"}}</script>68-<script>{{script "blossom-manifests.js"}}</script>69-<script>{{script "blossom-upload.js"}}</script>70-<script>{{script "file-messages.js"}}</script>71-<script>{{script "file-workspace.js"}}</script>72-<script>{{script "components.js"}}</script>73-<script>{{script "private-services.js"}}</script>74-</body>65+<script src="{{scriptURL "tiny.js"}}"></script>66+<script src="{{scriptURL "bridge.js"}}"></script>67+{{if or (eq .Tab "files") (eq .Tab "file")}}<script src="{{scriptURL "blossom-encryption.js"}}"></script>68+<script src="{{scriptURL "blossom-manifests.js"}}"></script>69+<script src="{{scriptURL "blossom-upload.js"}}"></script>70+<script src="{{scriptURL "file-messages.js"}}"></script>71+<script src="{{scriptURL "file-workspace.js"}}"></script>72+{{end}}<script src="{{scriptURL "components.js"}}"></script>73+{{if eq .Tab "connect"}}<script src="{{scriptURL "private-services.js"}}"></script>74+{{end}}</body>7575 </html>7676 {{end}}7777
M internal/webui/shell_test.go +4 -1
M internal/webui/style.css +4 -2
@@ -36,7 +36,8 @@ img{max-width:100%}
3636 #prompt{color:var(--muted)}#prompt b{color:var(--ink);font-weight:500}3737 #who{color:var(--muted)}#who b,#who nostr-key{color:var(--ink)}3838 #who button{border:0;background:none;padding:0;color:var(--accent);font:inherit}#who button:hover{text-decoration:underline}39-#rail-tools{margin-top:12px}39+#railbox{box-sizing:border-box;padding-bottom:44px}40+#rail-tools{position:absolute;right:0;bottom:0;margin:0;padding:10px 2ch 10px 2.5ch}4041 share-link{display:inline-block}share-link:empty{display:none}4142 #theme{border:0;background:none;padding:0;color:var(--muted);font:12px var(--mono)}#theme:hover{color:var(--ink);text-decoration:underline}4243
@@ -201,7 +202,8 @@ html,body{overflow:auto}
201202 #layout,#layout>tbody,#layout>tbody>tr,#layout>tbody>tr>td{display:block;width:auto;height:auto}202203 #layout{padding:40px 0 28px}203204 #railbox,#content,#panel{position:static;overflow:visible}204-#layout>tbody>tr>td#rail{position:fixed;top:40px;bottom:28px;left:0;width:240px;height:auto;border-right:1px solid var(--line);transform:translateX(-102%);visibility:hidden;transition:transform .15s;z-index:2;overflow:auto}205+#layout>tbody>tr>td#rail{position:fixed;top:40px;bottom:28px;left:0;width:240px;height:auto;box-sizing:border-box;border-right:1px solid var(--line);transform:translateX(-102%);visibility:hidden;transition:transform .15s;z-index:2;overflow:hidden auto}206+#railbox{position:relative;min-height:100%}205207 #topbar:has(#nav-menu[open]) + #layout>tbody>tr>td#rail{transform:none;visibility:visible;box-shadow:8px 0 24px rgba(0,0,0,.15)}206208 #layout>tbody>tr>td#aside{width:auto;border-left:0;border-top:1px solid var(--line)}207209 #content>section{padding:14px 2.5ch 20px}
M internal/webui/sw.js +1 -1
M internal/webui/templates.go +27 -5
@@ -1,11 +1,14 @@
11 package webui22 33 import (4+ "crypto/sha256"45 "embed"6+ "encoding/hex"57 "encoding/json"68 "fmt"79 "html/template"810 "net/url"11+ "sort"912 "strings"1013 "time"1114 )
@@ -145,17 +148,36 @@ func repoView(query url.Values) string {
145148 return "tree"146149 }147150 151+// scripts are served at /scripts/<name> and cached by the service worker.152+// One version stamp covers them all, so a change to any file refreshes every153+// cached copy together.154+var scripts = map[string]string{"bridge.js": bridgeJS, "tiny.js": tinyJS, "components.js": componentsJS, "blossom-encryption.js": blossomEncryptionJS, "blossom-manifests.js": blossomManifestsJS, "blossom-upload.js": blossomUploadJS, "file-messages.js": fileMessagesJS, "private-services.js": privateServicesJS, "file-workspace.js": fileWorkspaceJS}155+156+var scriptsVersion = func() string {157+ names := make([]string, 0, len(scripts))158+ for name := range scripts {159+ names = append(names, name)160+ }161+ sort.Strings(names)162+ digest := sha256.New()163+ for _, name := range names {164+ digest.Write([]byte(name))165+ digest.Write([]byte{0})166+ digest.Write([]byte(scripts[name]))167+ }168+ return hex.EncodeToString(digest.Sum(nil))[:12]169+}()170+148171 func parseTemplates() (*template.Template, error) {149- scripts := map[string]string{"bridge.js": bridgeJS, "tiny.js": tinyJS, "components.js": componentsJS, "blossom-encryption.js": blossomEncryptionJS, "blossom-manifests.js": blossomManifestsJS, "blossom-upload.js": blossomUploadJS, "file-messages.js": fileMessagesJS, "private-services.js": privateServicesJS, "file-workspace.js": fileWorkspaceJS}150172 funcs := template.FuncMap{151173 "stylesheet": func() template.CSS { return template.CSS(styleCSS) },152- "script": func(name string) (template.JS, error) {153- source, ok := scripts[name]154- if !ok {174+ "scriptURL": func(name string) (string, error) {175+ if _, ok := scripts[name]; !ok {155176 return "", fmt.Errorf("unknown script %q", name)156177 }157- return template.JS(source), nil178+ return "/scripts/" + name + "?v=" + scriptsVersion, nil158179 },180+ "scriptsVersion": func() string { return scriptsVersion },159181 "json": func(value any) string {160182 encoded, err := json.Marshal(value)161183 if err != nil {
M internal/webui/tiny.js +24 -0
@@ -68,6 +68,30 @@
6868 };6969 tiny.nip44Encrypt = (pubkey, text, signer) => nip44("Encrypt", pubkey, text, signer);7070 tiny.nip44Decrypt = (pubkey, text, signer) => nip44("Decrypt", pubkey, text, signer);71+ // Feature bundles load on the pages that use them and again after an72+ // in-place navigation lands on such a page. Each script loads once.73+ tiny.bundles = {74+ files: ["blossom-encryption.js", "blossom-manifests.js", "blossom-upload.js", "file-messages.js", "file-workspace.js"],75+ private: ["private-services.js"]76+ };77+ const loaded = new Map();78+ const loadScript = name => {79+ if (!loaded.has(name)) {80+ const version = document.documentElement.dataset.scripts || "";81+ const present = [...(document.scripts || [])].some(script => (script.src || "").includes("/scripts/" + name));82+ loaded.set(name, present ? Promise.resolve() : new Promise((resolve, reject) => {83+ const script = document.createElement("script");84+ script.src = (tiny.localPath ? tiny.localPath("/scripts/" + name) : "/scripts/" + name) + (version ? "?v=" + version : "");85+ script.onload = () => resolve();86+ script.onerror = () => { loaded.delete(name); reject(Error("Could not load " + name)); };87+ document.head.append(script);88+ }));89+ }90+ return loaded.get(name);91+ };92+ tiny.require = async bundle => {93+ for (const name of tiny.bundles[bundle] || [bundle]) await loadScript(name);94+ };7195 // Feature modules add to these groups; guards can read them before load.7296 tiny.blossom = tiny.blossom || {};7397 tiny.files = tiny.files || {};
M internal/webui/webui.go +15 -0
M internal/webui/webui_test.go +14 -2
@@ -229,11 +229,14 @@ func TestSignerBundleAndDedicatedJourneysArePresent(t *testing.T) {
229229 recorder := httptest.NewRecorder()230230 app.ServeHTTP(recorder, request)231231 body := recorder.Body.String()232- for _, want := range []string{"bunker://", "nostrconnect", "signer-qr", "27235", "/signer.js"} {232+ for _, want := range []string{"bunker://", "nostrconnect", "signer-qr", "/signer.js", `src="/scripts/bridge.js?v=`} {233233 if !strings.Contains(body, want) {234234 t.Errorf("connect page missing %q", want)235235 }236236 }237+ if !strings.Contains(servedScript(t, app, "/scripts/bridge.js"), "27235") {238+ t.Error("bridge script missing the NIP-98 kind")239+ }237240 signerRequest := httptest.NewRequest(http.MethodGet, "/signer.js", nil)238241 signerResponse := httptest.NewRecorder()239242 app.ServeHTTP(signerResponse, signerRequest)
@@ -343,11 +346,20 @@ func TestTenantPrefixRewritesUIEndpoints(t *testing.T) {
343346 recorder := httptest.NewRecorder()344347 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/r/alice/manage/sync", nil))345348 body := recorder.Body.String()346- for _, want := range []string{`src="/r/alice/fixi.js"`, `src="/r/alice/signer.js"`, `fx-action="/r/alice/manage/jobs/status"`, `localPath(action || "/manage/rpc")`, `signedSession("/session")`, `replace(/^http/,"ws")+root`} {349+ for _, want := range []string{`src="/r/alice/fixi.js"`, `src="/r/alice/signer.js"`, `fx-action="/r/alice/manage/jobs/status"`, `src="/r/alice/scripts/bridge.js?v=`, `src="/r/alice/scripts/components.js?v=`} {347350 if !strings.Contains(body, want) {348351 t.Errorf("tenant prefix missing %q", want)349352 }350353 }354+ bridge := servedScript(t, app, "/scripts/bridge.js")355+ for _, want := range []string{`signedSession("/session")`, `replace(/^http/,"ws")+root`} {356+ if !strings.Contains(bridge, want) {357+ t.Errorf("bridge script missing %q", want)358+ }359+ }360+ if !strings.Contains(servedScript(t, app, "/scripts/components.js"), `localPath(action || "/manage/rpc")`) {361+ t.Error("components script missing the prefixed management endpoint")362+ }351363 }352364 353365 func TestTenantPrefixHeaderSurvivesDaemonPathStripping(t *testing.T) {