eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "encoding/json"
5 "net/http"
6 "net/url"
7 "os"
8 "regexp"
9 "strings"
10 "sync"
11 "time"
12)
13
14// Connected third-party services (docs/connected-services.md).
15//
16// The server stores one opaque, client-encrypted credential blob per (member,
17// service) and never reads it — the same trust boundary as instance_keys. A
18// member seals their credential to their OWN key before storing it; actually
19// *using* it is a separate, time-boxed grant the member seals to a bot. Nothing
20// usable ever rests on the server, so a server compromise yields ciphertext.
21const maxConnectedBytes = 16 * 1024
22
23// Service names are our own keys, never interpolated into an outbound request,
24// but keep them to a tidy slug regardless.
25var serviceRE = regexp.MustCompile(`^[a-z0-9-]{1,32}$`)
26
27// GET /api/connected?token= -> {"services": ["dnsimple", ...]}
28func (s *Server) listConnected(w http.ResponseWriter, r *http.Request) {
29 token := r.URL.Query().Get("token")
30 if !s.tokenOK(token) {
31 httpError(w, http.StatusForbidden, "invalid invite")
32 return
33 }
34 userID, _ := s.db.deviceTokenUser(token)
35 services, err := s.db.listConnectedServices(userID)
36 if err != nil {
37 httpError(w, http.StatusInternalServerError, "db error")
38 return
39 }
40 if services == nil {
41 services = []string{}
42 }
43 writeJSON(w, http.StatusOK, map[string]any{"services": services})
44}
45
46// GET /api/connected/{service}?token= -> {"service","blob","connected"}
47func (s *Server) getConnected(w http.ResponseWriter, r *http.Request) {
48 token := r.URL.Query().Get("token")
49 if !s.tokenOK(token) {
50 httpError(w, http.StatusForbidden, "invalid invite")
51 return
52 }
53 service := r.PathValue("service")
54 if !serviceRE.MatchString(service) {
55 httpError(w, http.StatusBadRequest, "bad service")
56 return
57 }
58 userID, _ := s.db.deviceTokenUser(token)
59 blob, ok, err := s.db.getConnectedService(userID, service)
60 if err != nil {
61 httpError(w, http.StatusInternalServerError, "db error")
62 return
63 }
64 writeJSON(w, http.StatusOK, map[string]any{"service": service, "blob": blob, "connected": ok})
65}
66
67// PUT /api/connected/{service} body {"token","blob"} -> {"ok":true}
68func (s *Server) putConnected(w http.ResponseWriter, r *http.Request) {
69 r.Body = http.MaxBytesReader(w, r.Body, maxConnectedBytes+4096)
70 var in struct {
71 Token string `json:"token"`
72 Blob string `json:"blob"`
73 }
74 if json.NewDecoder(r.Body).Decode(&in) != nil {
75 httpError(w, http.StatusBadRequest, "bad json")
76 return
77 }
78 if !s.tokenOK(in.Token) {
79 httpError(w, http.StatusForbidden, "invalid invite")
80 return
81 }
82 service := r.PathValue("service")
83 if !serviceRE.MatchString(service) {
84 httpError(w, http.StatusBadRequest, "bad service")
85 return
86 }
87 if in.Blob == "" || len(in.Blob) > maxConnectedBytes {
88 httpError(w, http.StatusBadRequest, "bad blob")
89 return
90 }
91 userID, _ := s.db.deviceTokenUser(in.Token)
92 if err := s.db.putConnectedService(userID, service, in.Blob); err != nil {
93 httpError(w, http.StatusInternalServerError, "db error")
94 return
95 }
96 writeJSON(w, http.StatusOK, map[string]any{"ok": true})
97}
98
99// DELETE /api/connected/{service}?token= -> {"ok":true} (disconnect)
100func (s *Server) deleteConnected(w http.ResponseWriter, r *http.Request) {
101 token := r.URL.Query().Get("token")
102 if !s.tokenOK(token) {
103 httpError(w, http.StatusForbidden, "invalid invite")
104 return
105 }
106 service := r.PathValue("service")
107 if !serviceRE.MatchString(service) {
108 httpError(w, http.StatusBadRequest, "bad service")
109 return
110 }
111 userID, _ := s.db.deviceTokenUser(token)
112 if err := s.db.deleteConnectedService(userID, service); err != nil {
113 httpError(w, http.StatusInternalServerError, "db error")
114 return
115 }
116 writeJSON(w, http.StatusOK, map[string]any{"ok": true})
117}
118
119// brokerURL is the connect broker (docs/connected-services.md) that instances
120// fetch OAuth config from and sidecars exchange codes through. It defaults to the
121// shared Eleven broker, so a stock instance needs NO connect config at all; a
122// self-hoster running their own points LCHAT_CONNECT_BROKER_URL at it. No DNSimple
123// config ever lives on the instance — it all comes from the broker.
124func brokerURL() string {
125 if v := strings.TrimSpace(os.Getenv("LCHAT_CONNECT_BROKER_URL")); v != "" {
126 return strings.TrimRight(v, "/")
127 }
128 return "https://connect.elevenmessenger.com"
129}
130
131// The broker's public OAuth config per service (client_id, authorize_url,
132// redirect_uri — all non-secret), cached briefly so opening the connect
133// surface doesn't refetch each time. A service the broker has no app for
134// caches as an explicit "absent" (the catalog then offers paste-a-token only);
135// on a fetch failure we serve the last-known value.
136var (
137 oauthCfgMu sync.Mutex
138 oauthCfgVals = map[string]*oauthCfgEntry{}
139)
140
141type oauthCfgEntry struct {
142 val map[string]any // nil = the broker answered "no app for this service"
143 when time.Time
144}
145
146func brokerOAuthConfig(service string) map[string]any {
147 oauthCfgMu.Lock()
148 defer oauthCfgMu.Unlock()
149 if e := oauthCfgVals[service]; e != nil && time.Since(e.when) < 10*time.Minute {
150 return e.val
151 }
152 resp, err := (&http.Client{Timeout: 8 * time.Second}).Get(brokerURL() + "/config?service=" + url.QueryEscape(service))
153 if err == nil {
154 defer resp.Body.Close()
155 switch resp.StatusCode {
156 case http.StatusOK:
157 var cfg map[string]any
158 if json.NewDecoder(resp.Body).Decode(&cfg) == nil {
159 oauthCfgVals[service] = &oauthCfgEntry{val: cfg, when: time.Now()}
160 }
161 case http.StatusNotFound: // a definitive "not offered" — cache that too
162 oauthCfgVals[service] = &oauthCfgEntry{val: nil, when: time.Now()}
163 }
164 }
165 if e := oauthCfgVals[service]; e != nil {
166 return e.val
167 }
168 return nil
169}
170
171// GET /api/oauth/config?service=github -> {"client_id","authorize_url","redirect_uri"}
172//
173// The browser needs the *public* OAuth params to start a flow. The instance
174// holds none of this itself — it asks the connect broker (override with
175// LCHAT_CONNECT_BROKER_URL) for whichever service the catalog is offering; an
176// empty {} means "no OAuth app for this service" and the catalog falls back to
177// paste-a-token. No secret is ever involved.
178func (s *Server) oauthConfig(w http.ResponseWriter, r *http.Request) {
179 if !s.tokenOK(r.URL.Query().Get("token")) {
180 httpError(w, http.StatusForbidden, "invalid invite")
181 return
182 }
183 service := r.URL.Query().Get("service")
184 if !serviceRE.MatchString(service) {
185 writeJSON(w, http.StatusOK, map[string]any{})
186 return
187 }
188 cfg := brokerOAuthConfig(service)
189 if cfg == nil {
190 cfg = map[string]any{}
191 }
192 writeJSON(w, http.StatusOK, cfg)
193}
194
195// GET /oauth/{service}/callback — the registered OAuth redirect target. It runs a
196// tiny page that hands the one-time `code` back to the app window that started
197// the flow (which alone holds the PKCE verifier) via postMessage, then closes.
198// It stores nothing and sees only a single-use code — safe if compromised.
199func (s *Server) oauthCallbackPage(w http.ResponseWriter, r *http.Request) {
200 w.Header().Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'")
201 w.Header().Set("Content-Type", "text/html; charset=utf-8")
202 w.Write([]byte(`<!doctype html><meta charset="utf-8"><title>Connecting…</title>` +
203 `<body style="font:16px system-ui;margin:2rem;color:#333">Connecting… you can close this window.` +
204 `<script src="/static/oauth-callback.js"></script>`))
205}
206