tinyrelayfile
tinyrelay/tinyclient/remote.go/

tinyclient/remote.go raw
1package tinyclient
2
3import (
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.
18const BackendPath = "/api/tinyclient"
19
20type 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.
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 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), &params); 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
110var 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.
126type 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
133type remoteClient struct {
134 target, public *url.URL
135 client *http.Client
136 proxy *httputil.ReverseProxy
137}
138
139type requestBackend struct {
140 remote *remoteClient
141 snapshot remoteSnapshot
142 cookie string
143}
144
145func (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 nil
156}
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, 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
167func (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.
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, 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
219func (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
241func (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
299type headWriter struct{ http.ResponseWriter }
300
301func (w headWriter) Write(p []byte) (int, error) { return len(p), nil }
302
303func 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}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs