1package tinyclient23import (4 "context"5 "encoding/json"6 "errors"7 "fmt"8 "io"9 "net/http"10 "net/http/httputil"11 "net/url"12 "strings"13 "time"14)1516// BackendPath is the read-only adapter endpoint mounted by App. It uses the17// relay's normal actor resolver; a client-supplied public key is never trusted.18const BackendPath = "/api/tinyclient"1920type remoteSnapshot struct {21 Policy Policy `json:"policy"`22 URL string `json:"url"`23 Slug string `json:"slug"`24 Identity string `json:"identity"`25 Actor string `json:"actor"`26 Version string `json:"version"`27 Revision string `json:"revision"`28 Views []CustomView `json:"views,omitempty"`29 SharePresence bool `json:"share_presence"`30 ReadError string `json:"read_error,omitempty"`31 Rooms bool `json:"rooms"`32 Activity bool `json:"activity"`33}3435// BackendHandler exposes the read contract without rendering HTML. Mount it36// at BackendPath when embedding the client separately from its data service.37// Query authorization remains the responsibility of Backend.Query.38func (a *App) BackendHandler() http.Handler {39 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {40 w.Header().Set("Cache-Control", "private, no-store")41 if r.Method != http.MethodGet {42 w.Header().Set("Allow", "GET")43 http.Error(w, "method not allowed", 405)44 return45 }46 actor, err := a.resolveActor(r)47 if err != nil {48 http.Error(w, "authentication required", 401)49 return50 }51 if r.URL.Query().Get("op") != "" {52 a.typedBackendRead(w, r, actor)53 return54 }55 if r.URL.Query().Get("method") == "" {56 snapshot := remoteSnapshot{Policy: a.backend.Policy(), URL: a.backend.URL(), Slug: a.backend.Slug(), Identity: a.backend.Identity(), Actor: actor, Version: a.version, Revision: a.revision}57 _, snapshot.Rooms = a.backend.(RoomsReader)58 _, snapshot.Activity = a.backend.(ChatActivityReader)59 if snapshot.Policy.Features.Grasp08 {60 if err := a.privateReadAllowed(r, actor); err != nil {61 snapshot.Policy = Policy{Features: Features{Grasp08: true}}62 snapshot.URL, snapshot.Slug, snapshot.Identity = "", "private relay", ""63 snapshot.ReadError = err.Error()64 writeJSON(w, 200, snapshot)65 return66 }67 }68 if actor == "" || actor != snapshot.Policy.Owner {69 p := snapshot.Policy70 snapshot.Policy = Policy{Owner: p.Owner, Name: p.Name, Description: p.Description, Icon: p.Icon, Banner: p.Banner, Contact: p.Contact, PostingPolicy: p.PostingPolicy, PrivacyPolicy: p.PrivacyPolicy, Tags: p.Tags, LanguageTags: p.LanguageTags, RelayCountries: p.RelayCountries, Reads: p.Reads, Writes: p.Writes, JoinTerms: p.JoinTerms, DirectoryPublic: p.DirectoryPublic, Features: p.Features, Rooms: p.Rooms, Callbacks: p.Callbacks, MemberInvites: p.MemberInvites, FileLimits: p.FileLimits}71 }72 if views, ok := a.backend.(CustomViewSource); ok {73 snapshot.Views = views.CustomViews()74 }75 if actor != "" {76 if preferences, ok := a.backend.(AccountPreferencesReader); ok {77 snapshot.SharePresence = preferences.SharePresence(r.Context(), actor)78 }79 }80 writeJSON(w, 200, snapshot)81 return82 }83 method := r.URL.Query().Get("method")84 if !remoteReadMethods[method] {85 http.Error(w, "unsupported client read", 400)86 return87 }88 if a.backend.Policy().Features.Grasp08 {89 if err := a.privateReadAllowed(r, actor); err != nil {90 http.Error(w, err.Error(), 403)91 return92 }93 }94 var params []json.RawMessage95 if raw := r.URL.Query().Get("params"); raw != "" {96 if err := json.Unmarshal([]byte(raw), ¶ms); err != nil {97 http.Error(w, "invalid client query", 400)98 return99 }100 }101 result, err := a.backend.Query(r.Context(), method, params, actor)102 if err != nil {103 http.Error(w, err.Error(), 403)104 return105 }106 writeJSON(w, 200, result)107 })108}109110var remoteReadMethods = map[string]bool{111 "connections": true, "queryevents": true, "eventdetail": true, "listview": true,112 "listpeople": true, "listjoinrequests": true, "listagents": true, "listcallbacks": true,113 "listconnections": true, "listconnectiontemplates": true, "listcustomviews": true, "listjobs": true,114 "browserepos": true, "browserepo": true, "browsefiles": true, "browsefile": true,115 "browseapprovals": true, "browseapproval": true, "browseprofile": true, "browsewiki": true,116 "browsewikipage": true, "browsewikimerge": true, "browsestatus": true,117 "browserooms": true, "browseroom": true, "browsethread": true,118 "browseissues": true, "browseissue": true, "browsepulls": true, "browsepull": true,119 "browseagent": true, "browsejobs": true, "browsejob": true,120 "browsesocial": true, "browsesocialthread": true, "browsepodcasts": true, "browsedirectmessages": true,121}122123// RemoteOptions connects a standalone renderer to one relay tenant. PublicURL124// is the browser-facing URL; BackendURL is its upstream HTTP address. Use the125// same tenant path at both addresses so cookies and signed URLs retain scope.126type RemoteOptions struct {127 BackendURL string128 PublicURL string129 // Transport optionally supplies TLS roots or other HTTP transport settings.130 Transport http.RoundTripper131}132133type remoteClient struct {134 target, public *url.URL135 client *http.Client136 proxy *httputil.ReverseProxy137}138139type requestBackend struct {140 remote *remoteClient141 snapshot remoteSnapshot142 cookie string143}144145func (b *requestBackend) Policy() Policy { return b.snapshot.Policy }146func (b *requestBackend) URL() string { return b.snapshot.URL }147func (b *requestBackend) Slug() string { return b.snapshot.Slug }148func (b *requestBackend) Identity() string { return b.snapshot.Identity }149func (b *requestBackend) CustomViews() []CustomView { return b.snapshot.Views }150func (b *requestBackend) SharePresence(context.Context, string) bool { return b.snapshot.SharePresence }151func (b *requestBackend) ReadAllowed(context.Context, string) error {152 if b.snapshot.ReadError != "" {153 return errors.New(b.snapshot.ReadError)154 }155 return nil156}157func (b *requestBackend) Query(ctx context.Context, method string, params []json.RawMessage, actor string) (any, error) {158 raw, err := json.Marshal(params)159 if err != nil {160 return nil, err161 }162 var result any163 err = b.readAsActor(ctx, actor, url.Values{"method": {method}, "params": {string(raw)}}, &result)164 return result, err165}166167func (b *requestBackend) readAsActor(ctx context.Context, actor string, query url.Values, out any) error {168 if actor != "" && actor != b.snapshot.Actor {169 return errors.New("client read actor does not match authenticated session")170 }171 cookie := b.cookie172 // Public reads must remain anonymous even on an authenticated page request.173 if actor == "" {174 cookie = ""175 }176 return b.remote.read(ctx, cookie, query, out)177}178179// NewRemote serves embedded templates and assets locally and forwards relay180// protocols, signed requests and mutations upstream. It does not open a181// database, own relay keys or start relay workers.182func NewRemote(options RemoteOptions) (http.Handler, error) {183 parse := func(raw string) (*url.URL, error) {184 u, err := url.Parse(raw)185 if err != nil || u == nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.RawPath != "" {186 return nil, fmt.Errorf("tinyclient requires an absolute HTTP(S) URL without credentials, query or fragment")187 }188 u.Path = strings.TrimRight(u.Path, "/")189 return u, nil190 }191 target, err := parse(options.BackendURL)192 if err != nil {193 return nil, err194 }195 public, err := parse(options.PublicURL)196 if err != nil {197 return nil, err198 }199 if target.Path != public.Path {200 return nil, errors.New("tinyclient backend and public URL must use the same tenant path")201 }202 if public.Path != "" && pathPrefix(public.Path) != public.Path {203 return nil, errors.New("tinyclient tenant path must be /r/<name>")204 }205 transport := options.Transport206 if transport == nil {207 transport = http.DefaultTransport208 }209 remote := &remoteClient{target: target, public: public, client: &http.Client{Transport: transport, Timeout: 30 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}210 remote.proxy = &httputil.ReverseProxy{Transport: transport, Rewrite: func(p *httputil.ProxyRequest) {211 p.Out.URL.Scheme, p.Out.URL.Host = target.Scheme, target.Host212 p.Out.Host = public.Host213 }, ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {214 http.Error(w, "relay backend unavailable", http.StatusBadGateway)215 }}216 return remote, nil217}218219func (remote *remoteClient) read(ctx context.Context, cookie string, query url.Values, out any) error {220 target := *remote.target221 target.Path += BackendPath222 target.RawQuery = query.Encode()223 req, err := http.NewRequestWithContext(ctx, "GET", target.String(), nil)224 if err != nil {225 return err226 }227 req.Host = remote.public.Host228 req.Header.Set("Cookie", cookie)229 response, err := remote.client.Do(req)230 if err != nil {231 return err232 }233 defer response.Body.Close()234 if response.StatusCode != 200 {235 raw, _ := io.ReadAll(io.LimitReader(response.Body, 4096))236 return fmt.Errorf("%s", strings.TrimSpace(string(raw)))237 }238 return json.NewDecoder(io.LimitReader(response.Body, 32<<20)).Decode(out)239}240241func (remote *remoteClient) ServeHTTP(w http.ResponseWriter, r *http.Request) {242 if !strings.EqualFold(r.Host, remote.public.Host) {243 http.Error(w, "unexpected public host", http.StatusMisdirectedRequest)244 return245 }246 path := r.URL.Path247 prefix := remote.public.Path248 if prefix == "" && strings.HasPrefix(path, "/r/") {249 http.NotFound(w, r)250 return251 }252 if prefix != "" {253 if !strings.HasPrefix(path, prefix+"/") && path != prefix {254 http.NotFound(w, r)255 return256 }257 path = strings.TrimPrefix(path, prefix)258 if path == "" {259 http.Redirect(w, r, prefix+"/", http.StatusMovedPermanently)260 return261 }262 }263 if r.Method != "GET" && r.Method != "HEAD" || r.Header.Get("Authorization") != "" || strings.EqualFold(r.Header.Get("Upgrade"), "websocket") || strings.Contains(r.Header.Get("Accept"), "application/nostr+json") || !clientPath(path) {264 remote.proxy.ServeHTTP(w, r)265 return266 }267 if serveEmbeddedAsset(w, r, path) {268 return269 }270 b := &requestBackend{remote: remote, cookie: r.Header.Get("Cookie")}271 if err := remote.read(r.Context(), b.cookie, nil, &b.snapshot); err != nil {272 http.Error(w, "relay backend unavailable", http.StatusBadGateway)273 return274 }275 b.snapshot.URL = remote.public.String()276 var backend Backend = b277 if b.snapshot.Rooms {278 backend = &remoteRooms{b}279 }280 app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return b.snapshot.Actor, nil }, Version: b.snapshot.Version, Revision: b.snapshot.Revision})281 if err != nil {282 http.Error(w, "client renderer unavailable", 500)283 return284 }285 local := r.Clone(r.Context())286 local.URL.Path = path287 // Preserve RequestURI so prefix-aware templates and browser requests agree.288 if local.RequestURI == "" {289 local.RequestURI = r.URL.RequestURI()290 }291 if r.Method == "HEAD" {292 local.Method = "GET"293 app.ServeHTTP(headWriter{w}, local)294 return295 }296 app.ServeHTTP(w, local)297}298299type headWriter struct{ http.ResponseWriter }300301func (w headWriter) Write(p []byte) (int, error) { return len(p), nil }302303func clientPath(path string) bool {304 if path == "/repo/raw" || path == "/files/raw" || path == "/chat/preferences" || path == "/manage/jobs/status" || path == BackendPath {305 return false306 }307 if tabForPath(path) != "home" || isBrowsePath(path) {308 return true309 }310 switch path {311 case "/", "/card.json", "/people", "/connect.json", "/signer.js", "/fixi.js", "/sw.js", "/icon.svg", "/icon-mono.svg", "/manifest.webmanifest", "/qr.svg", "/card.svg", "/card.nostr", "/webmcp.js", "/webmcp/query", "/api/join-policy", "/share", "/tools", "/manage/status", "/articles.json", "/feed", "/feed.xml", "/social", "/social.json", "/social.xml", "/icon-192.png", "/icon-512.png", "/icon-maskable-512.png", "/apple-touch-icon.png", "/badge-96.png", "/screenshot-narrow.png", "/screenshot-wide.png":312 return true313 }314 for _, prefix := range []string{"/avatars/", "/scripts/", "/e/", "/a/", "/view/", "/invite/", "/social/", "/chat/"} {315 if strings.HasPrefix(path, prefix) {316 return true317 }318 }319 return false320}