Parse templates once and bind request-specific helpers on isolated clones. Serve static assets without backend snapshot requests and preserve their HTTP headers. Verify client isolation, tenant scope, GET and HEAD parity, and offline asset availability.
- commit
- 9e4b39518f36996a148d83468ed130c4ba7e8563
- parent
- 6c32fc3546cf69265e2c544e775bec493abb4aef
- author
- Dami <dami.etoile@gmail.com>
| M | tinyclient/ | 3 | +- |
| A | tinyclient/ | 73 | ++++++++++++++++++++++++++++ |
| M | tinyclient/ | 26 | ++++++++++ |
| M | tinyclient/ | 3 | + |
| M | tinyclient/ | 77 | ++++++++++++++++++++++++++++++ |
| M | tinyclient/ | 36 | ++++++++++++-- |
| M | tinyclient/ | 45 | +----------------- |
@@ -41,7 +41,8 @@ The default listener is `127.0.0.1:8081`. Run `tinyclient-server -help` for flag
4141 4242 The binary embeds its assets and needs no working directory, SQLite database,4343 Git installation or relay signing key at runtime. It requires a running backend44-for relay data and session validation. Failure to load the relay snapshot44+for relay data and session validation. Embedded scripts and icons remain available45+when the backend is unavailable. Failure to load the relay snapshot4546 returns HTTP 502 rather than serving cached private data. It is not an offline client or a frontend for4647 an arbitrary Nostr relay.4748 @@ -0,0 +1,73 @@
1+package tinyclient2+3+import (4+ "io"5+ "net/http"6+ "strings"7+)8+9+var immutablePNGAssets = map[string][]byte{10+ "/icon-192.png": icon192PNG,11+ "/icon-512.png": icon512PNG,12+ "/icon-maskable-512.png": iconMaskablePNG,13+ "/apple-touch-icon.png": appleTouchIconPNG,14+ "/badge-96.png": badgePNG,15+ "/screenshot-narrow.png": screenshotNarrowPNG,16+ "/screenshot-wide.png": screenshotWidePNG,17+}18+19+// serveEmbeddedAsset serves assets that do not depend on the backend20+// snapshot. It returns false for unknown paths or unsupported methods.21+func serveEmbeddedAsset(w http.ResponseWriter, r *http.Request, path string) bool {22+ if r.Method != http.MethodGet && r.Method != http.MethodHead {23+ return false24+ }25+ if strings.HasPrefix(path, "/scripts/") {26+ source, ok := scripts[strings.TrimPrefix(path, "/scripts/")]27+ if !ok {28+ http.NotFound(w, r)29+ return true30+ }31+ w.Header().Set("content-type", "application/javascript; charset=utf-8")32+ if r.URL.Query().Get("v") == scriptsVersion {33+ w.Header().Set("cache-control", "public, max-age=31536000, immutable")34+ } else {35+ w.Header().Set("cache-control", "no-cache")36+ }37+ if r.Method == http.MethodGet {38+ _, _ = io.WriteString(w, source)39+ }40+ return true41+ }42+ if path == "/signer.js" {43+ return writeEmbeddedAsset(w, r, "application/javascript; charset=utf-8", signerJS, "")44+ }45+ if path == "/fixi.js" {46+ return writeEmbeddedAsset(w, r, "application/javascript; charset=utf-8", fixiJS, "")47+ }48+ if path == "/sw.js" {49+ w.Header().Set("service-worker-allowed", "/")50+ return writeEmbeddedAsset(w, r, "application/javascript; charset=utf-8", serviceWorkerJS, "")51+ }52+ if path == "/icon.svg" {53+ return writeEmbeddedAsset(w, r, "image/svg+xml; charset=utf-8", iconSVG, "")54+ }55+ if path == "/icon-mono.svg" {56+ return writeEmbeddedAsset(w, r, "image/svg+xml; charset=utf-8", iconMonoSVG, "")57+ }58+ if png, ok := immutablePNGAssets[path]; ok {59+ return writeEmbeddedAsset(w, r, "image/png", png, "public, max-age=86400")60+ }61+ return false62+}63+64+func writeEmbeddedAsset(w http.ResponseWriter, r *http.Request, contentType string, body []byte, cacheControl string) bool {65+ w.Header().Set("content-type", contentType)66+ if cacheControl != "" {67+ w.Header().Set("cache-control", cacheControl)68+ }69+ if r.Method == http.MethodGet {70+ _, _ = w.Write(body)71+ }72+ return true73+}@@ -119,6 +119,32 @@ func TestPagesRenderCustomViewFiguresFromTheBackendSummary(t *testing.T) {
119119 }120120 }121121 122+func TestTemplateClonesKeepBackendSpecificMarkdownHelpers(t *testing.T) {123+ owner := strings.Repeat("a", 64)124+ withView := &viewsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(owner)}, views: []views.View{{Name: "diagrams", Languages: []string{"mermaid"}}}}125+ withView.policy.Description = "```mermaid\ngraph TD; A-->B\n```"126+ withoutView := &viewsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(owner)}}127+ withoutView.policy.Description = withView.policy.Description128+ withApp, err := New(withView, Options{})129+ if err != nil {130+ t.Fatal(err)131+ }132+ withoutApp, err := New(withoutView, Options{})133+ if err != nil {134+ t.Fatal(err)135+ }136+ withResponse := httptest.NewRecorder()137+ withoutResponse := httptest.NewRecorder()138+ withApp.ServeHTTP(withResponse, httptest.NewRequest(http.MethodGet, "/", nil))139+ withoutApp.ServeHTTP(withoutResponse, httptest.NewRequest(http.MethodGet, "/", nil))140+ if !strings.Contains(withResponse.Body.String(), `<figure data-view="diagrams">`) {141+ t.Fatal("backend with a custom view did not render its figure")142+ }143+ if strings.Contains(withoutResponse.Body.String(), `<figure data-view="diagrams">`) {144+ t.Fatal("backend without a custom view inherited another App's markdown helper")145+ }146+}147+122148 func TestViewsPageListsCustomViewsWithControlsAndTheAddForm(t *testing.T) {123149 owner := strings.Repeat("a", 64)124150 backend := &viewsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(owner)}, rows: []map[string]any{@@ -264,6 +264,9 @@ func (remote *remoteClient) ServeHTTP(w http.ResponseWriter, r *http.Request) {
264264 remote.proxy.ServeHTTP(w, r)265265 return266266 }267+ if serveEmbeddedAsset(w, r, path) {268+ return269+ }267270 b := &requestBackend{remote: remote, cookie: r.Header.Get("Cookie")}268271 if err := remote.read(r.Context(), b.cookie, nil, &b.snapshot); err != nil {269272 http.Error(w, "relay backend unavailable", http.StatusBadGateway)@@ -5,6 +5,7 @@ import (
55 "net/http"66 "net/http/httptest"77 "strings"8+ "sync/atomic"89 "testing"910 "time"1011 @@ -86,3 +87,79 @@ func TestRemoteProxiesWebSocketUpgrades(t *testing.T) {
8687 t.Fatalf("websocket relay: %s %v", body, err)8788 }8889 }90+91+func TestRemoteServesImmutableAssetsWithoutBackendReads(t *testing.T) {92+ var calls atomic.Int3293+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {94+ calls.Add(1)95+ http.Error(w, "backend should not be called for immutable assets", http.StatusBadGateway)96+ }))97+ remote, err := NewRemote(RemoteOptions{BackendURL: upstream.URL, PublicURL: "http://relay.example"})98+ if err != nil {99+ t.Fatal(err)100+ }101+ upstream.Close()102+ for _, method := range []string{http.MethodGet, http.MethodHead} {103+ for _, path := range []string{"/scripts/bridge.js", "/signer.js", "/fixi.js", "/sw.js", "/icon.svg", "/icon-192.png"} {104+ recorder := httptest.NewRecorder()105+ request := httptest.NewRequest(method, "http://relay.example"+path, nil)106+ request.AddCookie(&http.Cookie{Name: "tiny_session", Value: "session"})107+ remote.ServeHTTP(recorder, request)108+ if recorder.Code != http.StatusOK || calls.Load() != 0 {109+ t.Fatalf("%s %s: status=%d backend calls=%d", method, path, recorder.Code, calls.Load())110+ }111+ }112+ }113+ for _, path := range []string{"/r/other/scripts/bridge.js", "/r/other/icon.svg"} {114+ recorder := httptest.NewRecorder()115+ remote.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "http://relay.example"+path, nil))116+ if recorder.Code != http.StatusNotFound || calls.Load() != 0 {117+ t.Fatalf("cross-tenant %s: status=%d backend calls=%d", path, recorder.Code, calls.Load())118+ }119+ }120+}121+122+func TestRemoteEmbeddedAssetsMatchIntegratedClient(t *testing.T) {123+ app, err := New(&fakeBackend{policy: DefaultPolicy(strings.Repeat("a", 64))}, Options{})124+ if err != nil {125+ t.Fatal(err)126+ }127+ var calls atomic.Int32128+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {129+ calls.Add(1)130+ http.Error(w, "unexpected upstream request", http.StatusBadGateway)131+ }))132+ defer upstream.Close()133+ paths := []string{"/scripts/bridge.js", "/scripts/bridge.js?v=" + scriptsVersion, "/signer.js", "/fixi.js", "/sw.js", "/icon.svg", "/icon-mono.svg", "/icon-192.png"}134+ for _, prefix := range []string{"", "/r/team"} {135+ remote, err := NewRemote(RemoteOptions{BackendURL: upstream.URL + prefix, PublicURL: "http://relay.example" + prefix})136+ if err != nil {137+ t.Fatal(err)138+ }139+ for _, method := range []string{http.MethodGet, http.MethodHead} {140+ for _, path := range paths {141+ request := httptest.NewRequest(method, "http://relay.example"+prefix+path, nil)142+ request.AddCookie(&http.Cookie{Name: "tiny_session", Value: "session"})143+ got, want := httptest.NewRecorder(), httptest.NewRecorder()144+ remote.ServeHTTP(got, request)145+ local := request.Clone(request.Context())146+ local.URL.Path = strings.TrimPrefix(local.URL.Path, prefix)147+ app.ServeHTTP(want, local)148+ if got.Code != want.Code || got.Body.String() != want.Body.String() {149+ t.Fatalf("%s %s%s: standalone status/body differs from integrated", method, prefix, path)150+ }151+ if method == http.MethodHead && got.Body.Len() != 0 {152+ t.Fatalf("HEAD %s carries a response body", path)153+ }154+ for _, header := range []string{"Content-Type", "Cache-Control", "Service-Worker-Allowed"} {155+ if got.Header().Get(header) != want.Header().Get(header) {156+ t.Fatalf("%s %s: %s=%q, want %q", method, path, header, got.Header().Get(header), want.Header().Get(header))157+ }158+ }159+ }160+ }161+ }162+ if calls.Load() != 0 {163+ t.Fatalf("embedded assets made %d upstream requests", calls.Load())164+ }165+}@@ -10,6 +10,7 @@ import (
1010 "net/url"1111 "sort"1212 "strings"13+ "sync"1314 "time"1415 )1516 @@ -267,7 +268,34 @@ var scriptsVersion = func() string {
267268 return hex.EncodeToString(digest.Sum(nil))[:12]268269 }()269270 271+var (272+ templateBaseOnce sync.Once273+ templateBase *template.Template274+ templateBaseErr error275+)276+277+// parseTemplates clones the immutable parsed template set and installs the278+// request backend's rendering helpers. Parsing is expensive, while the279+// helpers must remain bound to the current App and cannot be shared safely.270280 func parseTemplates(a *App) (*template.Template, error) {281+ templateBaseOnce.Do(func() { templateBase, templateBaseErr = parseTemplateBase() })282+ if templateBaseErr != nil {283+ return nil, templateBaseErr284+ }285+ tmpl, err := templateBase.Clone()286+ if err != nil {287+ return nil, fmt.Errorf("clone web templates: %w", err)288+ }289+ tmpl = tmpl.Funcs(template.FuncMap{290+ "socialBody": a.socialBody,291+ "markdown": a.markdown,292+ "chatMarkdown": a.chatMarkdown,293+ "wikiHTML": a.wikiHTML,294+ })295+ return tmpl, nil296+}297+298+func parseTemplateBase() (*template.Template, error) {271299 funcs := template.FuncMap{272300 "stylesheet": func() template.CSS { return template.CSS(styleCSS) },273301 "scriptURL": func(name string) (string, error) {@@ -298,14 +326,14 @@ func parseTemplates(a *App) (*template.Template, error) {
298326 "socialQuery": socialQuery,299327 "socialURL": socialURL,300328 "socialThreadURL": socialURL,301- "socialBody": a.socialBody,329+ "socialBody": func(any) template.HTML { return "" },302330 "socialPreview": socialPreview,303331 "socialMediaURL": socialMediaURL,304332 "avatarURL": avatarURL,305333 "roomAvatarURL": roomAvatarURL,306334 "mobileHeader": mobileHeader,307- "markdown": a.markdown,308- "chatMarkdown": a.chatMarkdown,335+ "markdown": func(string) template.HTML { return "" },336+ "chatMarkdown": func(string) template.HTML { return "" },309337 "hasPrefix": strings.HasPrefix,310338 "npub": identityNpub,311339 "wsURL": wsURL,@@ -356,7 +384,7 @@ func parseTemplates(a *App) (*template.Template, error) {
356384 "approvalViews": approvalViews,357385 "approvalCounts": approvalCounts,358386 "approvalDevices": approvalDevices,359- "wikiHTML": a.wikiHTML,387+ "wikiHTML": func(any) template.HTML { return "" },360388 "viewState": customViewState,361389 "viewKinds": customViewKinds,362390 "viewLanguages": customViewLanguages,@@ -230,50 +230,7 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
230230 a.invitePage(writer, request)231231 return232232 }233- if strings.HasPrefix(request.URL.Path, "/scripts/") {234- source, ok := scripts[strings.TrimPrefix(request.URL.Path, "/scripts/")]235- if !ok {236- http.NotFound(writer, request)237- return238- }239- writer.Header().Set("content-type", "application/javascript; charset=utf-8")240- if request.URL.Query().Get("v") == scriptsVersion {241- writer.Header().Set("cache-control", "public, max-age=31536000, immutable")242- } else {243- writer.Header().Set("cache-control", "no-cache")244- }245- _, _ = writer.Write([]byte(source))246- return247- }248- if request.URL.Path == "/signer.js" {249- writer.Header().Set("content-type", "application/javascript; charset=utf-8")250- _, _ = writer.Write([]byte(signerJS))251- return252- }253- if request.URL.Path == "/fixi.js" {254- writer.Header().Set("content-type", "application/javascript; charset=utf-8")255- _, _ = writer.Write(fixiJS)256- return257- }258- if request.URL.Path == "/sw.js" {259- writer.Header().Set("content-type", "application/javascript; charset=utf-8")260- writer.Header().Set("service-worker-allowed", "/")261- _, _ = writer.Write(serviceWorkerJS)262- return263- }264- 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 {265- writer.Header().Set("content-type", "image/png")266- writer.Header().Set("cache-control", "public, max-age=86400")267- _, _ = writer.Write(png)268- return269- }270- if request.URL.Path == "/icon.svg" || request.URL.Path == "/icon-mono.svg" {271- writer.Header().Set("content-type", "image/svg+xml; charset=utf-8")272- if request.URL.Path == "/icon.svg" {273- _, _ = writer.Write(iconSVG)274- } else {275- _, _ = writer.Write(iconMonoSVG)276- }233+ if serveEmbeddedAsset(writer, request, request.URL.Path) {277234 return278235 }279236 if request.URL.Path == "/open" {