clone

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

perf: reuse frontend templates and embedded asset handlers

Dami committed 9e4b395
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>
7 files changed, +214 -49
Mtinyclient/README.md3+-
Atinyclient/assets.go73++++++++++++++++++++++++++++
Mtinyclient/custom_views_test.go26++++++++++
Mtinyclient/remote.go3+
Mtinyclient/remote_browser_test.go77++++++++++++++++++++++++++++++
Mtinyclient/templates.go36++++++++++++--
Mtinyclient/webui.go45+-----------------
M tinyclient/README.md +2 -1
A tinyclient/assets.go +73 -0
@@ -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+}
M tinyclient/custom_views_test.go +26 -0
@@ -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{
M tinyclient/remote.go +3 -0
M tinyclient/remote_browser_test.go +77 -0
@@ -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+}
M tinyclient/templates.go +32 -4
@@ -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,
M tinyclient/webui.go +1 -44
@@ -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" {