| 1 | package tinyclient |
| 2 | |
| 3 | import ( |
| 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 | ) |
| 15 | |
| 16 | // BackendPath is the read-only adapter endpoint mounted by App. It uses the |
| 17 | // relay's normal actor resolver; a client-supplied public key is never trusted. |
| 18 | const BackendPath = "/api/tinyclient" |
| 19 | |
| 20 | type 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 | } |
| 34 | |
| 35 | // BackendHandler exposes the read contract without rendering HTML. Mount it |
| 36 | // at BackendPath when embedding the client separately from its data service. |
| 37 | // Query authorization remains the responsibility of Backend.Query. |
| 38 | func (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 | return |
| 45 | } |
| 46 | actor, err := a.resolveActor(r) |
| 47 | if err != nil { |
| 48 | http.Error(w, "authentication required" , 401) |
| 49 | return |
| 50 | } |
| 51 | if r.URL.Query().Get("op" ) != "" { |
| 52 | a.typedBackendRead(w, r, actor) |
| 53 | return |
| 54 | } |
| 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 | return |
| 66 | } |
| 67 | } |
| 68 | if actor == "" || actor != snapshot.Policy.Owner { |
| 69 | p := snapshot.Policy |
| 70 | 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 | return |
| 82 | } |
| 83 | method := r.URL.Query().Get("method" ) |
| 84 | if !remoteReadMethods[method] { |
| 85 | http.Error(w, "unsupported client read" , 400) |
| 86 | return |
| 87 | } |
| 88 | if a.backend.Policy().Features.Grasp08 { |
| 89 | if err := a.privateReadAllowed(r, actor); err != nil { |
| 90 | http.Error(w, err.Error(), 403) |
| 91 | return |
| 92 | } |
| 93 | } |
| 94 | var params []json.RawMessage |
| 95 | 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 | return |
| 99 | } |
| 100 | } |
| 101 | result, err := a.backend.Query(r.Context(), method, params, actor) |
| 102 | if err != nil { |
| 103 | http.Error(w, err.Error(), 403) |
| 104 | return |
| 105 | } |
| 106 | writeJSON(w, 200, result) |
| 107 | }) |
| 108 | } |
| 109 | |
| 110 | var 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, "browsedirectmessages" : true, |
| 121 | } |
| 122 | |
| 123 | // RemoteOptions connects a standalone renderer to one relay tenant. PublicURL |
| 124 | // is the browser-facing URL; BackendURL is its upstream HTTP address. Use the |
| 125 | // same tenant path at both addresses so cookies and signed URLs retain scope. |
| 126 | type RemoteOptions struct { |
| 127 | BackendURL string |
| 128 | PublicURL string |
| 129 | // Transport optionally supplies TLS roots or other HTTP transport settings. |
| 130 | Transport http.RoundTripper |
| 131 | } |
| 132 | |
| 133 | type remoteClient struct { |
| 134 | target, public *url.URL |
| 135 | client *http.Client |
| 136 | proxy *httputil.ReverseProxy |
| 137 | } |
| 138 | |
| 139 | type requestBackend struct { |
| 140 | remote *remoteClient |
| 141 | snapshot remoteSnapshot |
| 142 | cookie string |
| 143 | } |
| 144 | |
| 145 | func (b *requestBackend) Policy() Policy { return b.snapshot.Policy } |
| 146 | func (b *requestBackend) URL() string { return b.snapshot.URL } |
| 147 | func (b *requestBackend) Slug() string { return b.snapshot.Slug } |
| 148 | func (b *requestBackend) Identity() string { return b.snapshot.Identity } |
| 149 | func (b *requestBackend) CustomViews() []CustomView { return b.snapshot.Views } |
| 150 | func (b *requestBackend) SharePresence(context.Context, string) bool { return b.snapshot.SharePresence } |
| 151 | func (b *requestBackend) ReadAllowed(context.Context, string) error { |
| 152 | if b.snapshot.ReadError != "" { |
| 153 | return errors.New(b.snapshot.ReadError) |
| 154 | } |
| 155 | return nil |
| 156 | } |
| 157 | func (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, err |
| 161 | } |
| 162 | var result any |
| 163 | err = b.readAsActor(ctx, actor, url.Values{"method" : {method}, "params" : {string(raw)}}, &result) |
| 164 | return result, err |
| 165 | } |
| 166 | |
| 167 | func (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.cookie |
| 172 | // 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 | } |
| 178 | |
| 179 | // NewRemote serves embedded templates and assets locally and forwards relay |
| 180 | // protocols, signed requests and mutations upstream. It does not open a |
| 181 | // database, own relay keys or start relay workers. |
| 182 | func 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, nil |
| 190 | } |
| 191 | target, err := parse(options.BackendURL) |
| 192 | if err != nil { |
| 193 | return nil, err |
| 194 | } |
| 195 | public, err := parse(options.PublicURL) |
| 196 | if err != nil { |
| 197 | return nil, err |
| 198 | } |
| 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.Transport |
| 206 | if transport == nil { |
| 207 | transport = http.DefaultTransport |
| 208 | } |
| 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.Host |
| 212 | p.Out.Host = public.Host |
| 213 | }, ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { |
| 214 | http.Error(w, "relay backend unavailable" , http.StatusBadGateway) |
| 215 | }} |
| 216 | return remote, nil |
| 217 | } |
| 218 | |
| 219 | func (remote *remoteClient) read(ctx context.Context, cookie string, query url.Values, out any) error { |
| 220 | target := *remote.target |
| 221 | target.Path += BackendPath |
| 222 | target.RawQuery = query.Encode() |
| 223 | req, err := http.NewRequestWithContext(ctx, "GET" , target.String(), nil) |
| 224 | if err != nil { |
| 225 | return err |
| 226 | } |
| 227 | req.Host = remote.public.Host |
| 228 | req.Header.Set("Cookie" , cookie) |
| 229 | response, err := remote.client.Do(req) |
| 230 | if err != nil { |
| 231 | return err |
| 232 | } |
| 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 | } |
| 240 | |
| 241 | func (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 | return |
| 245 | } |
| 246 | path := r.URL.Path |
| 247 | prefix := remote.public.Path |
| 248 | if prefix == "" && strings.HasPrefix(path, "/r/" ) { |
| 249 | http.NotFound(w, r) |
| 250 | return |
| 251 | } |
| 252 | if prefix != "" { |
| 253 | if !strings.HasPrefix(path, prefix+"/" ) && path != prefix { |
| 254 | http.NotFound(w, r) |
| 255 | return |
| 256 | } |
| 257 | path = strings.TrimPrefix(path, prefix) |
| 258 | if path == "" { |
| 259 | http.Redirect(w, r, prefix+"/" , http.StatusMovedPermanently) |
| 260 | return |
| 261 | } |
| 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 | return |
| 266 | } |
| 267 | if serveEmbeddedAsset(w, r, path) { |
| 268 | return |
| 269 | } |
| 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 | return |
| 274 | } |
| 275 | b.snapshot.URL = remote.public.String() |
| 276 | var backend Backend = b |
| 277 | 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 | return |
| 284 | } |
| 285 | local := r.Clone(r.Context()) |
| 286 | local.URL.Path = path |
| 287 | // 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 | return |
| 295 | } |
| 296 | app.ServeHTTP(w, local) |
| 297 | } |
| 298 | |
| 299 | type headWriter struct{ http.ResponseWriter } |
| 300 | |
| 301 | func (w headWriter) Write(p []byte) (int, error) { return len(p), nil } |
| 302 | |
| 303 | func clientPath(path string) bool { |
| 304 | if path == "/repo/raw" || path == "/files/raw" || path == "/chat/preferences" || path == "/manage/jobs/status" || path == BackendPath { |
| 305 | return false |
| 306 | } |
| 307 | if tabForPath(path) != "home" || isBrowsePath(path) { |
| 308 | return true |
| 309 | } |
| 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 true |
| 313 | } |
| 314 | for _, prefix := range []string{"/avatars/" , "/scripts/" , "/e/" , "/a/" , "/view/" , "/invite/" , "/social/" , "/chat/" } { |
| 315 | if strings.HasPrefix(path, prefix) { |
| 316 | return true |
| 317 | } |
| 318 | } |
| 319 | return false |
| 320 | } |