tinyrelaycommit

Commit 9e4b39518f36

browse files at this commit | history

1diff --git a/tinyclient/README.md b/tinyclient/README.md
2index 96bacf9..3f82924 100644
3--- a/tinyclient/README.md
4+++ b/tinyclient/README.md
5@@ -41,7 +41,8 @@ The default listener is `127.0.0.1:8081`. Run `tinyclient-server -help` for flag
6
7 The binary embeds its assets and needs no working directory, SQLite database,
8 Git installation or relay signing key at runtime. It requires a running backend
9-for relay data and session validation. Failure to load the relay snapshot
10+for relay data and session validation. Embedded scripts and icons remain available
11+when the backend is unavailable. Failure to load the relay snapshot
12 returns HTTP 502 rather than serving cached private data. It is not an offline client or a frontend for
13 an arbitrary Nostr relay.
14
15diff --git a/tinyclient/assets.go b/tinyclient/assets.go
16new file mode 100644
17index 0000000..b377f40
18--- /dev/null
19+++ b/tinyclient/assets.go
20@@ -0,0 +1,73 @@
21+package tinyclient
22+
23+import (
24+ "io"
25+ "net/http"
26+ "strings"
27+)
28+
29+var immutablePNGAssets = map[string][]byte{
30+ "/icon-192.png": icon192PNG,
31+ "/icon-512.png": icon512PNG,
32+ "/icon-maskable-512.png": iconMaskablePNG,
33+ "/apple-touch-icon.png": appleTouchIconPNG,
34+ "/badge-96.png": badgePNG,
35+ "/screenshot-narrow.png": screenshotNarrowPNG,
36+ "/screenshot-wide.png": screenshotWidePNG,
37+}
38+
39+// serveEmbeddedAsset serves assets that do not depend on the backend
40+// snapshot. It returns false for unknown paths or unsupported methods.
41+func serveEmbeddedAsset(w http.ResponseWriter, r *http.Request, path string) bool {
42+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
43+ return false
44+ }
45+ if strings.HasPrefix(path, "/scripts/") {
46+ source, ok := scripts[strings.TrimPrefix(path, "/scripts/")]
47+ if !ok {
48+ http.NotFound(w, r)
49+ return true
50+ }
51+ w.Header().Set("content-type", "application/javascript; charset=utf-8")
52+ if r.URL.Query().Get("v") == scriptsVersion {
53+ w.Header().Set("cache-control", "public, max-age=31536000, immutable")
54+ } else {
55+ w.Header().Set("cache-control", "no-cache")
56+ }
57+ if r.Method == http.MethodGet {
58+ _, _ = io.WriteString(w, source)
59+ }
60+ return true
61+ }
62+ if path == "/signer.js" {
63+ return writeEmbeddedAsset(w, r, "application/javascript; charset=utf-8", signerJS, "")
64+ }
65+ if path == "/fixi.js" {
66+ return writeEmbeddedAsset(w, r, "application/javascript; charset=utf-8", fixiJS, "")
67+ }
68+ if path == "/sw.js" {
69+ w.Header().Set("service-worker-allowed", "/")
70+ return writeEmbeddedAsset(w, r, "application/javascript; charset=utf-8", serviceWorkerJS, "")
71+ }
72+ if path == "/icon.svg" {
73+ return writeEmbeddedAsset(w, r, "image/svg+xml; charset=utf-8", iconSVG, "")
74+ }
75+ if path == "/icon-mono.svg" {
76+ return writeEmbeddedAsset(w, r, "image/svg+xml; charset=utf-8", iconMonoSVG, "")
77+ }
78+ if png, ok := immutablePNGAssets[path]; ok {
79+ return writeEmbeddedAsset(w, r, "image/png", png, "public, max-age=86400")
80+ }
81+ return false
82+}
83+
84+func writeEmbeddedAsset(w http.ResponseWriter, r *http.Request, contentType string, body []byte, cacheControl string) bool {
85+ w.Header().Set("content-type", contentType)
86+ if cacheControl != "" {
87+ w.Header().Set("cache-control", cacheControl)
88+ }
89+ if r.Method == http.MethodGet {
90+ _, _ = w.Write(body)
91+ }
92+ return true
93+}
94diff --git a/tinyclient/custom_views_test.go b/tinyclient/custom_views_test.go
95index e46720c..bdc274c 100644
96--- a/tinyclient/custom_views_test.go
97+++ b/tinyclient/custom_views_test.go
98@@ -119,6 +119,32 @@ func TestPagesRenderCustomViewFiguresFromTheBackendSummary(t *testing.T) {
99 }
100 }
101
102+func TestTemplateClonesKeepBackendSpecificMarkdownHelpers(t *testing.T) {
103+ owner := strings.Repeat("a", 64)
104+ withView := &viewsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(owner)}, views: []views.View{{Name: "diagrams", Languages: []string{"mermaid"}}}}
105+ withView.policy.Description = "```mermaid\ngraph TD; A-->B\n```"
106+ withoutView := &viewsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(owner)}}
107+ withoutView.policy.Description = withView.policy.Description
108+ withApp, err := New(withView, Options{})
109+ if err != nil {
110+ t.Fatal(err)
111+ }
112+ withoutApp, err := New(withoutView, Options{})
113+ if err != nil {
114+ t.Fatal(err)
115+ }
116+ withResponse := httptest.NewRecorder()
117+ withoutResponse := httptest.NewRecorder()
118+ withApp.ServeHTTP(withResponse, httptest.NewRequest(http.MethodGet, "/", nil))
119+ withoutApp.ServeHTTP(withoutResponse, httptest.NewRequest(http.MethodGet, "/", nil))
120+ if !strings.Contains(withResponse.Body.String(), `<figure data-view="diagrams">`) {
121+ t.Fatal("backend with a custom view did not render its figure")
122+ }
123+ if strings.Contains(withoutResponse.Body.String(), `<figure data-view="diagrams">`) {
124+ t.Fatal("backend without a custom view inherited another App's markdown helper")
125+ }
126+}
127+
128 func TestViewsPageListsCustomViewsWithControlsAndTheAddForm(t *testing.T) {
129 owner := strings.Repeat("a", 64)
130 backend := &viewsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(owner)}, rows: []map[string]any{
131diff --git a/tinyclient/remote.go b/tinyclient/remote.go
132index 8f86b3c..046a729 100644
133--- a/tinyclient/remote.go
134+++ b/tinyclient/remote.go
135@@ -264,6 +264,9 @@ func (remote *remoteClient) ServeHTTP(w http.ResponseWriter, r *http.Request) {
136 remote.proxy.ServeHTTP(w, r)
137 return
138 }
139+ if serveEmbeddedAsset(w, r, path) {
140+ return
141+ }
142 b := &requestBackend{remote: remote, cookie: r.Header.Get("Cookie")}
143 if err := remote.read(r.Context(), b.cookie, nil, &b.snapshot); err != nil {
144 http.Error(w, "relay backend unavailable", http.StatusBadGateway)
145diff --git a/tinyclient/remote_browser_test.go b/tinyclient/remote_browser_test.go
146index 7757d5b..744ab7a 100644
147--- a/tinyclient/remote_browser_test.go
148+++ b/tinyclient/remote_browser_test.go
149@@ -5,6 +5,7 @@ import (
150 "net/http"
151 "net/http/httptest"
152 "strings"
153+ "sync/atomic"
154 "testing"
155 "time"
156
157@@ -86,3 +87,79 @@ func TestRemoteProxiesWebSocketUpgrades(t *testing.T) {
158 t.Fatalf("websocket relay: %s %v", body, err)
159 }
160 }
161+
162+func TestRemoteServesImmutableAssetsWithoutBackendReads(t *testing.T) {
163+ var calls atomic.Int32
164+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
165+ calls.Add(1)
166+ http.Error(w, "backend should not be called for immutable assets", http.StatusBadGateway)
167+ }))
168+ remote, err := NewRemote(RemoteOptions{BackendURL: upstream.URL, PublicURL: "http://relay.example"})
169+ if err != nil {
170+ t.Fatal(err)
171+ }
172+ upstream.Close()
173+ for _, method := range []string{http.MethodGet, http.MethodHead} {
174+ for _, path := range []string{"/scripts/bridge.js", "/signer.js", "/fixi.js", "/sw.js", "/icon.svg", "/icon-192.png"} {
175+ recorder := httptest.NewRecorder()
176+ request := httptest.NewRequest(method, "http://relay.example"+path, nil)
177+ request.AddCookie(&http.Cookie{Name: "tiny_session", Value: "session"})
178+ remote.ServeHTTP(recorder, request)
179+ if recorder.Code != http.StatusOK || calls.Load() != 0 {
180+ t.Fatalf("%s %s: status=%d backend calls=%d", method, path, recorder.Code, calls.Load())
181+ }
182+ }
183+ }
184+ for _, path := range []string{"/r/other/scripts/bridge.js", "/r/other/icon.svg"} {
185+ recorder := httptest.NewRecorder()
186+ remote.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "http://relay.example"+path, nil))
187+ if recorder.Code != http.StatusNotFound || calls.Load() != 0 {
188+ t.Fatalf("cross-tenant %s: status=%d backend calls=%d", path, recorder.Code, calls.Load())
189+ }
190+ }
191+}
192+
193+func TestRemoteEmbeddedAssetsMatchIntegratedClient(t *testing.T) {
194+ app, err := New(&fakeBackend{policy: DefaultPolicy(strings.Repeat("a", 64))}, Options{})
195+ if err != nil {
196+ t.Fatal(err)
197+ }
198+ var calls atomic.Int32
199+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
200+ calls.Add(1)
201+ http.Error(w, "unexpected upstream request", http.StatusBadGateway)
202+ }))
203+ defer upstream.Close()
204+ paths := []string{"/scripts/bridge.js", "/scripts/bridge.js?v=" + scriptsVersion, "/signer.js", "/fixi.js", "/sw.js", "/icon.svg", "/icon-mono.svg", "/icon-192.png"}
205+ for _, prefix := range []string{"", "/r/team"} {
206+ remote, err := NewRemote(RemoteOptions{BackendURL: upstream.URL + prefix, PublicURL: "http://relay.example" + prefix})
207+ if err != nil {
208+ t.Fatal(err)
209+ }
210+ for _, method := range []string{http.MethodGet, http.MethodHead} {
211+ for _, path := range paths {
212+ request := httptest.NewRequest(method, "http://relay.example"+prefix+path, nil)
213+ request.AddCookie(&http.Cookie{Name: "tiny_session", Value: "session"})
214+ got, want := httptest.NewRecorder(), httptest.NewRecorder()
215+ remote.ServeHTTP(got, request)
216+ local := request.Clone(request.Context())
217+ local.URL.Path = strings.TrimPrefix(local.URL.Path, prefix)
218+ app.ServeHTTP(want, local)
219+ if got.Code != want.Code || got.Body.String() != want.Body.String() {
220+ t.Fatalf("%s %s%s: standalone status/body differs from integrated", method, prefix, path)
221+ }
222+ if method == http.MethodHead && got.Body.Len() != 0 {
223+ t.Fatalf("HEAD %s carries a response body", path)
224+ }
225+ for _, header := range []string{"Content-Type", "Cache-Control", "Service-Worker-Allowed"} {
226+ if got.Header().Get(header) != want.Header().Get(header) {
227+ t.Fatalf("%s %s: %s=%q, want %q", method, path, header, got.Header().Get(header), want.Header().Get(header))
228+ }
229+ }
230+ }
231+ }
232+ }
233+ if calls.Load() != 0 {
234+ t.Fatalf("embedded assets made %d upstream requests", calls.Load())
235+ }
236+}
237diff --git a/tinyclient/templates.go b/tinyclient/templates.go
238index 0e1e609..a4e4d77 100644
239--- a/tinyclient/templates.go
240+++ b/tinyclient/templates.go
241@@ -10,6 +10,7 @@ import (
242 "net/url"
243 "sort"
244 "strings"
245+ "sync"
246 "time"
247 )
248
249@@ -267,7 +268,34 @@ var scriptsVersion = func() string {
250 return hex.EncodeToString(digest.Sum(nil))[:12]
251 }()
252
253+var (
254+ templateBaseOnce sync.Once
255+ templateBase *template.Template
256+ templateBaseErr error
257+)
258+
259+// parseTemplates clones the immutable parsed template set and installs the
260+// request backend's rendering helpers. Parsing is expensive, while the
261+// helpers must remain bound to the current App and cannot be shared safely.
262 func parseTemplates(a *App) (*template.Template, error) {
263+ templateBaseOnce.Do(func() { templateBase, templateBaseErr = parseTemplateBase() })
264+ if templateBaseErr != nil {
265+ return nil, templateBaseErr
266+ }
267+ tmpl, err := templateBase.Clone()
268+ if err != nil {
269+ return nil, fmt.Errorf("clone web templates: %w", err)
270+ }
271+ tmpl = tmpl.Funcs(template.FuncMap{
272+ "socialBody": a.socialBody,
273+ "markdown": a.markdown,
274+ "chatMarkdown": a.chatMarkdown,
275+ "wikiHTML": a.wikiHTML,
276+ })
277+ return tmpl, nil
278+}
279+
280+func parseTemplateBase() (*template.Template, error) {
281 funcs := template.FuncMap{
282 "stylesheet": func() template.CSS { return template.CSS(styleCSS) },
283 "scriptURL": func(name string) (string, error) {
284@@ -298,14 +326,14 @@ func parseTemplates(a *App) (*template.Template, error) {
285 "socialQuery": socialQuery,
286 "socialURL": socialURL,
287 "socialThreadURL": socialURL,
288- "socialBody": a.socialBody,
289+ "socialBody": func(any) template.HTML { return "" },
290 "socialPreview": socialPreview,
291 "socialMediaURL": socialMediaURL,
292 "avatarURL": avatarURL,
293 "roomAvatarURL": roomAvatarURL,
294 "mobileHeader": mobileHeader,
295- "markdown": a.markdown,
296- "chatMarkdown": a.chatMarkdown,
297+ "markdown": func(string) template.HTML { return "" },
298+ "chatMarkdown": func(string) template.HTML { return "" },
299 "hasPrefix": strings.HasPrefix,
300 "npub": identityNpub,
301 "wsURL": wsURL,
302@@ -356,7 +384,7 @@ func parseTemplates(a *App) (*template.Template, error) {
303 "approvalViews": approvalViews,
304 "approvalCounts": approvalCounts,
305 "approvalDevices": approvalDevices,
306- "wikiHTML": a.wikiHTML,
307+ "wikiHTML": func(any) template.HTML { return "" },
308 "viewState": customViewState,
309 "viewKinds": customViewKinds,
310 "viewLanguages": customViewLanguages,
311diff --git a/tinyclient/webui.go b/tinyclient/webui.go
312index 875ad05..bd369ff 100644
313--- a/tinyclient/webui.go
314+++ b/tinyclient/webui.go
315@@ -230,50 +230,7 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
316 a.invitePage(writer, request)
317 return
318 }
319- if strings.HasPrefix(request.URL.Path, "/scripts/") {
320- source, ok := scripts[strings.TrimPrefix(request.URL.Path, "/scripts/")]
321- if !ok {
322- http.NotFound(writer, request)
323- return
324- }
325- writer.Header().Set("content-type", "application/javascript; charset=utf-8")
326- if request.URL.Query().Get("v") == scriptsVersion {
327- writer.Header().Set("cache-control", "public, max-age=31536000, immutable")
328- } else {
329- writer.Header().Set("cache-control", "no-cache")
330- }
331- _, _ = writer.Write([]byte(source))
332- return
333- }
334- if request.URL.Path == "/signer.js" {
335- writer.Header().Set("content-type", "application/javascript; charset=utf-8")
336- _, _ = writer.Write([]byte(signerJS))
337- return
338- }
339- if request.URL.Path == "/fixi.js" {
340- writer.Header().Set("content-type", "application/javascript; charset=utf-8")
341- _, _ = writer.Write(fixiJS)
342- return
343- }
344- if request.URL.Path == "/sw.js" {
345- writer.Header().Set("content-type", "application/javascript; charset=utf-8")
346- writer.Header().Set("service-worker-allowed", "/")
347- _, _ = writer.Write(serviceWorkerJS)
348- return
349- }
350- if png, ok := map[string][]byte{"/icon-192.png": icon192PNG, "/icon-512.png": icon512PNG, "/icon-maskable-512.png": iconMaskablePNG, "/apple-touch-icon.png": appleTouchIconPNG, "/badge-96.png": badgePNG, "/screenshot-narrow.png": screenshotNarrowPNG, "/screenshot-wide.png": screenshotWidePNG}[request.URL.Path]; ok {
351- writer.Header().Set("content-type", "image/png")
352- writer.Header().Set("cache-control", "public, max-age=86400")
353- _, _ = writer.Write(png)
354- return
355- }
356- if request.URL.Path == "/icon.svg" || request.URL.Path == "/icon-mono.svg" {
357- writer.Header().Set("content-type", "image/svg+xml; charset=utf-8")
358- if request.URL.Path == "/icon.svg" {
359- _, _ = writer.Write(iconSVG)
360- } else {
361- _, _ = writer.Write(iconMonoSVG)
362- }
363+ if serveEmbeddedAsset(writer, request, request.URL.Path) {
364 return
365 }
366 if request.URL.Path == "/open" {

About

ownerc01503e8035f
visibilitypublic
head9e4b39518f36

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs