nandi/gleanpublic Fork
⑂ 00a8370
Commits
⬇ Clone ▾
git clone https://git.rickub.com/nandi/glean.git
git clone ssh://git@rickub.com/nandi/glean.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

Gate the reader with a token and let it serve a web client

Two things that only matter once this leaves loopback.

GLEAN_TOKEN puts a shared secret in front of everything. It is checked
before any route reads the database or fetches a URL, which is the point:
/feeds and /fetch-content will retrieve any URL handed to them, so an open
instance is an SSRF proxy as much as it is someone else's feed list. Three
ways to present it, because there are three callers -- a header for a native
client, ?key= for a person following a link, and the cookie that ?key= then
sets so the token stays out of history and Referer. The comparison is
constant-time; an early return is an oracle for recovering it a byte at a
time.

Unset, there is no gate and nothing changes for local use. The listener
follows: loopback when there is no token, all interfaces when there is, so
"reachable in a container" cannot happen without the token.

GLEAN_WEB serves a Flutter web build instead of the page baked into the
binary. Resolved paths are checked to still be inside the root, since "../"
in a request path is how a static file server becomes a file server for the
whole disk. Content types are set explicitly -- a browser will not execute
JavaScript served as text/plain.

The Flutter client takes a token too, as a header. The web build leaves its
base URL empty and uses relative paths: served by the reader, it is already
same-origin, which means no CORS preflight and the reader's own cookie on
every request.

Checked in a browser against a real reader: the app loads, lists articles
and renders one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandithebull committed 2026-09-21T20:39:36-07:00 Browse files
00a8370 parent: af3d2f7
modified app/.gitignore +3 -0
@@ -46,3 +46,6 @@ app.*.map.json
4646
4747 # Widget Preview related
4848 .widget_preview/
49+
50+# web build output
51+/build/
@@ -46,3 +46,6 @@ app.*.map.json
46 46
47 # Widget Preview related47 # Widget Preview related
48 .widget_preview/48 .widget_preview/
49+
50+# web build output
51+/build/
modified app/lib/src/api/client.dart +17 -4
@@ -17,12 +17,23 @@ class ApiException implements Exception {
1717 /// No cookies, no CSRF, no auth: the server is single-user and binds
1818 /// loopback. That is why this class is a thin wrapper rather than a session.
1919 class GleanClient {
20- GleanClient({required this.baseUrl, http.Client? client})
20+ GleanClient({required this.baseUrl, this.token = '', http.Client? client})
2121 : _client = client ?? http.Client();
2222
2323 final String baseUrl;
24+
25+ /// Shared secret, when the reader is running with GLEAN_TOKEN set. Empty
26+ /// for a local reader, which has no gate.
27+ ///
28+ /// Sent as a header rather than a query parameter so it stays out of the
29+ /// browser's history and any Referer it sends.
30+ final String token;
31+
2432 final http.Client _client;
2533
34+ Map<String, String> get _headers =>
35+ token.isEmpty ? const {} : {'X-Glean-Token': token};
36+
2637 Uri _uri(String path, [Map<String, String>? query]) {
2738 final q = query?.entries.where((e) => e.value.isNotEmpty);
2839 return Uri.parse('$baseUrl$path').replace(
@@ -47,10 +58,11 @@ class GleanClient {
4758 }
4859
4960 Future<dynamic> _get(String path, [Map<String, String>? q]) async =>
50- _decode(await _client.get(_uri(path, q)));
61+ _decode(await _client.get(_uri(path, q), headers: _headers));
5162
5263 Future<dynamic> _post(String path, [Map<String, String>? form]) async =>
53- _decode(await _client.post(_uri(path), body: form ?? const {}));
64+ _decode(await _client.post(_uri(path),
65+ headers: _headers, body: form ?? const {}));
5466
5567 // --- feeds ---
5668
@@ -71,7 +83,8 @@ class GleanClient {
7183 }
7284
7385 Future<void> removeFeed(String feedUrl) async =>
74- _decode(await _client.delete(_uri('/feeds', {'url': feedUrl})));
86+ _decode(await _client.delete(_uri('/feeds', {'url': feedUrl}),
87+ headers: _headers));
7588
7689 Future<RefreshResult> refresh() async =>
7790 RefreshResult.fromJson(await _post('/refresh') as Map<String, dynamic>);
@@ -17,12 +17,23 @@ class ApiException implements Exception {
17 /// No cookies, no CSRF, no auth: the server is single-user and binds17 /// No cookies, no CSRF, no auth: the server is single-user and binds
18 /// loopback. That is why this class is a thin wrapper rather than a session.18 /// loopback. That is why this class is a thin wrapper rather than a session.
19 class GleanClient {19 class GleanClient {
20- GleanClient({required this.baseUrl, http.Client? client})20+ GleanClient({required this.baseUrl, this.token = '', http.Client? client})
21 : _client = client ?? http.Client();21 : _client = client ?? http.Client();
22 22
23 final String baseUrl;23 final String baseUrl;
24+
25+ /// Shared secret, when the reader is running with GLEAN_TOKEN set. Empty
26+ /// for a local reader, which has no gate.
27+ ///
28+ /// Sent as a header rather than a query parameter so it stays out of the
29+ /// browser's history and any Referer it sends.
30+ final String token;
31+
24 final http.Client _client;32 final http.Client _client;
25 33
34+ Map<String, String> get _headers =>
35+ token.isEmpty ? const {} : {'X-Glean-Token': token};
36+
26 Uri _uri(String path, [Map<String, String>? query]) {37 Uri _uri(String path, [Map<String, String>? query]) {
27 final q = query?.entries.where((e) => e.value.isNotEmpty);38 final q = query?.entries.where((e) => e.value.isNotEmpty);
28 return Uri.parse('$baseUrl$path').replace(39 return Uri.parse('$baseUrl$path').replace(
@@ -47,10 +58,11 @@ class GleanClient {
47 }58 }
48 59
49 Future<dynamic> _get(String path, [Map<String, String>? q]) async =>60 Future<dynamic> _get(String path, [Map<String, String>? q]) async =>
50- _decode(await _client.get(_uri(path, q)));61+ _decode(await _client.get(_uri(path, q), headers: _headers));
51 62
52 Future<dynamic> _post(String path, [Map<String, String>? form]) async =>63 Future<dynamic> _post(String path, [Map<String, String>? form]) async =>
53- _decode(await _client.post(_uri(path), body: form ?? const {}));64+ _decode(await _client.post(_uri(path),
65+ headers: _headers, body: form ?? const {}));
54 66
55 // --- feeds ---67 // --- feeds ---
56 68
@@ -71,7 +83,8 @@ class GleanClient {
71 }83 }
72 84
73 Future<void> removeFeed(String feedUrl) async =>85 Future<void> removeFeed(String feedUrl) async =>
74- _decode(await _client.delete(_uri('/feeds', {'url': feedUrl})));86+ _decode(await _client.delete(_uri('/feeds', {'url': feedUrl}),
87+ headers: _headers));
75 88
76 Future<RefreshResult> refresh() async =>89 Future<RefreshResult> refresh() async =>
77 RefreshResult.fromJson(await _post('/refresh') as Map<String, dynamic>);90 RefreshResult.fromJson(await _post('/refresh') as Map<String, dynamic>);
modified app/lib/src/app_state.dart +17 -4
@@ -7,21 +7,34 @@ import 'models/models.dart';
77 /// flutter run --dart-define=GLEAN_BASE_URL=http://10.0.2.2:8080
88 ///
99 /// 10.0.2.2 is how the Android emulator reaches the host's loopback; a real
10-/// device needs the machine's LAN address, and the server has no auth, so
11-/// only do that on a network you trust.
10+/// device needs the machine's LAN address.
11+///
12+/// The web build is served *by* the reader, so it leaves this empty and uses
13+/// relative paths: same origin, which means no CORS preflight and the
14+/// reader's own cookie on every request.
1215 const kDefaultBaseUrl = String.fromEnvironment(
1316 'GLEAN_BASE_URL',
1417 defaultValue: 'http://127.0.0.1:8080',
1518 );
1619
20+/// Shared secret, for a reader running with GLEAN_TOKEN set.
21+///
22+/// Empty by default, which is right for a local reader and for the web build
23+/// served by the reader itself -- there the browser already holds the cookie
24+/// the reader set, and same-origin requests carry it automatically.
25+const kToken = String.fromEnvironment('GLEAN_TOKEN', defaultValue: '');
26+
1727 /// Feed list and unread counts, shared across screens.
1828 ///
1929 /// Articles are not held here: each screen fetches its own, because the
2030 /// filters differ and a shared list would be wrong for whichever screen did
2131 /// not set them.
2232 class AppState extends ChangeNotifier {
23- AppState({String baseUrl = kDefaultBaseUrl, GleanClient? client})
24- : client = client ?? GleanClient(baseUrl: baseUrl);
33+ AppState({
34+ String baseUrl = kDefaultBaseUrl,
35+ String token = kToken,
36+ GleanClient? client,
37+ }) : client = client ?? GleanClient(baseUrl: baseUrl, token: token);
2538
2639 final GleanClient client;
2740
@@ -7,21 +7,34 @@ import 'models/models.dart';
7 /// flutter run --dart-define=GLEAN_BASE_URL=http://10.0.2.2:80807 /// flutter run --dart-define=GLEAN_BASE_URL=http://10.0.2.2:8080
8 ///8 ///
9 /// 10.0.2.2 is how the Android emulator reaches the host's loopback; a real9 /// 10.0.2.2 is how the Android emulator reaches the host's loopback; a real
10-/// device needs the machine's LAN address, and the server has no auth, so10+/// device needs the machine's LAN address.
11-/// only do that on a network you trust.11+///
12+/// The web build is served *by* the reader, so it leaves this empty and uses
13+/// relative paths: same origin, which means no CORS preflight and the
14+/// reader's own cookie on every request.
12 const kDefaultBaseUrl = String.fromEnvironment(15 const kDefaultBaseUrl = String.fromEnvironment(
13 'GLEAN_BASE_URL',16 'GLEAN_BASE_URL',
14 defaultValue: 'http://127.0.0.1:8080',17 defaultValue: 'http://127.0.0.1:8080',
15 );18 );
16 19
20+/// Shared secret, for a reader running with GLEAN_TOKEN set.
21+///
22+/// Empty by default, which is right for a local reader and for the web build
23+/// served by the reader itself -- there the browser already holds the cookie
24+/// the reader set, and same-origin requests carry it automatically.
25+const kToken = String.fromEnvironment('GLEAN_TOKEN', defaultValue: '');
26+
17 /// Feed list and unread counts, shared across screens.27 /// Feed list and unread counts, shared across screens.
18 ///28 ///
19 /// Articles are not held here: each screen fetches its own, because the29 /// Articles are not held here: each screen fetches its own, because the
20 /// filters differ and a shared list would be wrong for whichever screen did30 /// filters differ and a shared list would be wrong for whichever screen did
21 /// not set them.31 /// not set them.
22 class AppState extends ChangeNotifier {32 class AppState extends ChangeNotifier {
23- AppState({String baseUrl = kDefaultBaseUrl, GleanClient? client})33+ AppState({
24- : client = client ?? GleanClient(baseUrl: baseUrl);34+ String baseUrl = kDefaultBaseUrl,
35+ String token = kToken,
36+ GleanClient? client,
37+ }) : client = client ?? GleanClient(baseUrl: baseUrl, token: token);
25 38
26 final GleanClient client;39 final GleanClient client;
27 40
modified spike/nim/glean.nim +122 -7
@@ -3,10 +3,19 @@
33 ## nim c -r -d:ssl glean.nim
44 ## GLEAN_DB=~/.local/share/glean/db GLEAN_PORT=8080 ./glean
55 ##
6-## Single user, no accounts, no auth. It runs on your machine and reads your
7-## feeds. That assumption is what keeps this a few hundred lines instead of a
6+## Single user, no accounts. It runs on your machine and reads your feeds,
7+## and that assumption is what keeps this a few hundred lines instead of a
88 ## few thousand: no sessions, no CSRF, no per-user scoping, no ATProto.
99 ##
10+## GLEAN_TOKEN puts a shared secret in front of everything, which is what
11+## makes it safe anywhere but loopback -- `/feeds` and `/fetch-content` will
12+## fetch any URL handed to them, so an open instance is an SSRF proxy as well
13+## as someone else's feed list. Unset, there is no gate at all and the local
14+## case stays exactly as simple as it was.
15+##
16+## GLEAN_WEB points at a directory of static files (a Flutter web build) to
17+## serve instead of the built-in page.
18+##
1019 ## The pieces underneath -- fetching, parsing, storage, search, full-text
1120 ## extraction -- are the same modules the larger port built and tested.
1221
@@ -28,6 +37,34 @@ const
2837
2938 type Reader = ref object
3039 db: GleanDb
40+ token: string ## empty disables the gate entirely
41+ webRoot: string ## empty serves the built-in page
42+
43+proc cookieValue(header, name: string): string =
44+ for part in header.split(';'):
45+ let kv = part.strip()
46+ let i = kv.find('=')
47+ if i > 0 and kv[0 ..< i] == name: return kv[i + 1 .. ^1]
48+ ""
49+
50+proc constantTimeEq(a, b: string): bool =
51+ ## Compare without leaking where the first difference is. A check that
52+ ## returns early is an oracle for recovering the token a byte at a time.
53+ if a.len != b.len: return false
54+ var diff = 0'u8
55+ for i in 0 ..< a.len:
56+ diff = diff or (a[i].uint8 xor b[i].uint8)
57+ diff == 0
58+
59+proc presentedToken(req: Request, queryKey: string): string =
60+ ## Header first, then ?key=, then the cookie ?key= sets.
61+ ##
62+ ## Three ways because three callers: a native client sends a header, a
63+ ## person opens a link, and the browser then has a cookie.
64+ let h = req.headers.getOrDefault("X-Glean-Token").string
65+ if h.len > 0: return h
66+ if queryKey.len > 0: return queryKey
67+ cookieValue(req.headers.getOrDefault("Cookie").string, "glean_token")
3168
3269 proc jsonResponse(req: Request, code: HttpCode, node: JsonNode) {.async.} =
3370 await req.respond(code, $node, newHttpHeaders({
@@ -39,6 +76,48 @@ proc jsonResponse(req: Request, code: HttpCode, node: JsonNode) {.async.} =
3976 proc fail(req: Request, code: HttpCode, msg: string) {.async.} =
4077 await jsonResponse(req, code, %*{"error": msg})
4178
79+proc contentTypeFor(path: string): string =
80+ ## Enough types for a Flutter web build. A wrong type here is not cosmetic:
81+ ## a browser will refuse to execute JavaScript served as text/plain.
82+ let ext = path.splitFile.ext.toLowerAscii
83+ case ext
84+ of ".html": "text/html; charset=utf-8"
85+ of ".js", ".mjs": "text/javascript; charset=utf-8"
86+ of ".css": "text/css; charset=utf-8"
87+ of ".json": "application/json"
88+ of ".wasm": "application/wasm"
89+ of ".png": "image/png"
90+ of ".jpg", ".jpeg": "image/jpeg"
91+ of ".svg": "image/svg+xml"
92+ of ".ico": "image/x-icon"
93+ of ".woff2": "font/woff2"
94+ of ".woff": "font/woff"
95+ of ".ttf": "font/ttf"
96+ of ".otf": "font/otf"
97+ of ".map": "application/json"
98+ else: "application/octet-stream"
99+
100+proc serveStatic(r: Reader, req: Request, rel: string): Future[bool] {.async.} =
101+ ## Serve `rel` from the web root, if it is there. Returns false when it is
102+ ## not, so the caller can fall through.
103+ if r.webRoot.len == 0: return false
104+ # Resolve and confirm the result is still inside the root: "../" in a
105+ # request path is how a static file server turns into a file server for
106+ # the whole disk.
107+ let full = absolutePath(r.webRoot / rel)
108+ let root = absolutePath(r.webRoot)
109+ if not full.startsWith(root) or not fileExists(full): return false
110+ await req.respond(Http200, readFile(full),
111+ newHttpHeaders({"Content-Type": contentTypeFor(full)}))
112+ true
113+
114+proc serveApp(r: Reader, req: Request) {.async.} =
115+ ## The client: a Flutter web build when one is configured, otherwise the
116+ ## page baked into the binary.
117+ if await serveStatic(r, req, "index.html"): return
118+ await req.respond(Http200, IndexHtml,
119+ newHttpHeaders({"Content-Type": "text/html; charset=utf-8"}))
120+
42121 proc toJson(a: Article): JsonNode =
43122 %*{
44123 "id": a.id,
@@ -120,12 +199,27 @@ proc handle(r: Reader, req: Request) {.async.} =
120199 else:
121200 try: parseInt(raw) except ValueError: fallback
122201
202+ # The gate, before anything reads the database or fetches a URL.
203+ if r.token.len > 0:
204+ if not constantTimeEq(presentedToken(req, param("key")), r.token):
205+ await fail(req, Http401, "unauthorized")
206+ return
207+ # Arriving with ?key= means a person followed a link; remember it so the
208+ # rest of the app's requests carry it without the token sitting in every
209+ # URL (and in the browser history, and in any Referer it sends).
210+ if param("key").len > 0:
211+ await req.respond(Http303, "", newHttpHeaders({
212+ "Location": "/",
213+ "Set-Cookie": "glean_token=" & r.token &
214+ "; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000",
215+ }))
216+ return
217+
123218 case req.reqMethod
124219 of HttpGet:
125220 case segs[0]
126221 of "":
127- await req.respond(Http200, IndexHtml,
128- newHttpHeaders({"Content-Type": "text/html; charset=utf-8"}))
222+ await serveApp(r, req)
129223 of "health":
130224 await jsonResponse(req, Http200, %*{"ok": true})
131225
@@ -166,7 +260,9 @@ proc handle(r: Reader, req: Request) {.async.} =
166260 await jsonResponse(req, Http200,
167261 %*{"count": r.db.getUnreadCount(LocalUser, "", "")})
168262 else:
169- await fail(req, Http404, "not found")
263+ # Not an API route: it may be an asset of the web client.
264+ if not await serveStatic(r, req, path):
265+ await fail(req, Http404, "not found")
170266
171267 of HttpPost:
172268 case segs[0]
@@ -250,9 +346,24 @@ proc main() {.async.} =
250346 var db = gleandb.open(dbPath)
251347 db.migrate()
252348 db.db.run("INSERT OR IGNORE INTO users (did) VALUES (?)", p(LocalUser))
253- let reader = Reader(db: db)
349+
350+ let token = getEnv("GLEAN_TOKEN")
351+ var webRoot = getEnv("GLEAN_WEB")
352+ if webRoot.len > 0 and not dirExists(webRoot):
353+ # Failing loudly beats silently serving the fallback page and leaving
354+ # someone wondering why their client never updated.
355+ quit(&"GLEAN_WEB points at {webRoot}, which is not a directory")
356+
357+ let reader = Reader(db: db, token: token, webRoot: webRoot)
254358
255359 echo &"glean reading from {dbPath}"
360+ if webRoot.len > 0: echo &"serving the client from {webRoot}"
361+ if token.len > 0:
362+ echo "token required: append ?key=… once to set the cookie"
363+ else:
364+ # Worth saying out loud, because the difference between this and an open
365+ # instance on a public address is the whole of the security model.
366+ echo "no token set — anyone who can reach this port has full access"
256367 echo &"open http://127.0.0.1:{port}"
257368
258369 var server = newAsyncHttpServer()
@@ -264,7 +375,11 @@ proc main() {.async.} =
264375 proc cb(req: Request) {.async, gcsafe.} =
265376 {.cast(gcsafe).}:
266377 await reader.handle(req)
267- await server.serve(Port(port), cb, address = "127.0.0.1")
378+ # Loopback unless a token guards it. Modal and other containers need a
379+ # listener on all interfaces, and requiring the token for that is what
380+ # keeps "reachable" from meaning "open".
381+ let address = if token.len > 0: "0.0.0.0" else: "127.0.0.1"
382+ await server.serve(Port(port), cb, address = address)
268383
269384 when isMainModule:
270385 waitFor main()
@@ -3,10 +3,19 @@
3 ## nim c -r -d:ssl glean.nim3 ## nim c -r -d:ssl glean.nim
4 ## GLEAN_DB=~/.local/share/glean/db GLEAN_PORT=8080 ./glean4 ## GLEAN_DB=~/.local/share/glean/db GLEAN_PORT=8080 ./glean
5 ##5 ##
6-## Single user, no accounts, no auth. It runs on your machine and reads your6+## Single user, no accounts. It runs on your machine and reads your feeds,
7-## feeds. That assumption is what keeps this a few hundred lines instead of a7+## and that assumption is what keeps this a few hundred lines instead of a
8 ## few thousand: no sessions, no CSRF, no per-user scoping, no ATProto.8 ## few thousand: no sessions, no CSRF, no per-user scoping, no ATProto.
9 ##9 ##
10+## GLEAN_TOKEN puts a shared secret in front of everything, which is what
11+## makes it safe anywhere but loopback -- `/feeds` and `/fetch-content` will
12+## fetch any URL handed to them, so an open instance is an SSRF proxy as well
13+## as someone else's feed list. Unset, there is no gate at all and the local
14+## case stays exactly as simple as it was.
15+##
16+## GLEAN_WEB points at a directory of static files (a Flutter web build) to
17+## serve instead of the built-in page.
18+##
10 ## The pieces underneath -- fetching, parsing, storage, search, full-text19 ## The pieces underneath -- fetching, parsing, storage, search, full-text
11 ## extraction -- are the same modules the larger port built and tested.20 ## extraction -- are the same modules the larger port built and tested.
12 21
@@ -28,6 +37,34 @@ const
28 37
29 type Reader = ref object38 type Reader = ref object
30 db: GleanDb39 db: GleanDb
40+ token: string ## empty disables the gate entirely
41+ webRoot: string ## empty serves the built-in page
42+
43+proc cookieValue(header, name: string): string =
44+ for part in header.split(';'):
45+ let kv = part.strip()
46+ let i = kv.find('=')
47+ if i > 0 and kv[0 ..< i] == name: return kv[i + 1 .. ^1]
48+ ""
49+
50+proc constantTimeEq(a, b: string): bool =
51+ ## Compare without leaking where the first difference is. A check that
52+ ## returns early is an oracle for recovering the token a byte at a time.
53+ if a.len != b.len: return false
54+ var diff = 0'u8
55+ for i in 0 ..< a.len:
56+ diff = diff or (a[i].uint8 xor b[i].uint8)
57+ diff == 0
58+
59+proc presentedToken(req: Request, queryKey: string): string =
60+ ## Header first, then ?key=, then the cookie ?key= sets.
61+ ##
62+ ## Three ways because three callers: a native client sends a header, a
63+ ## person opens a link, and the browser then has a cookie.
64+ let h = req.headers.getOrDefault("X-Glean-Token").string
65+ if h.len > 0: return h
66+ if queryKey.len > 0: return queryKey
67+ cookieValue(req.headers.getOrDefault("Cookie").string, "glean_token")
31 68
32 proc jsonResponse(req: Request, code: HttpCode, node: JsonNode) {.async.} =69 proc jsonResponse(req: Request, code: HttpCode, node: JsonNode) {.async.} =
33 await req.respond(code, $node, newHttpHeaders({70 await req.respond(code, $node, newHttpHeaders({
@@ -39,6 +76,48 @@ proc jsonResponse(req: Request, code: HttpCode, node: JsonNode) {.async.} =
39 proc fail(req: Request, code: HttpCode, msg: string) {.async.} =76 proc fail(req: Request, code: HttpCode, msg: string) {.async.} =
40 await jsonResponse(req, code, %*{"error": msg})77 await jsonResponse(req, code, %*{"error": msg})
41 78
79+proc contentTypeFor(path: string): string =
80+ ## Enough types for a Flutter web build. A wrong type here is not cosmetic:
81+ ## a browser will refuse to execute JavaScript served as text/plain.
82+ let ext = path.splitFile.ext.toLowerAscii
83+ case ext
84+ of ".html": "text/html; charset=utf-8"
85+ of ".js", ".mjs": "text/javascript; charset=utf-8"
86+ of ".css": "text/css; charset=utf-8"
87+ of ".json": "application/json"
88+ of ".wasm": "application/wasm"
89+ of ".png": "image/png"
90+ of ".jpg", ".jpeg": "image/jpeg"
91+ of ".svg": "image/svg+xml"
92+ of ".ico": "image/x-icon"
93+ of ".woff2": "font/woff2"
94+ of ".woff": "font/woff"
95+ of ".ttf": "font/ttf"
96+ of ".otf": "font/otf"
97+ of ".map": "application/json"
98+ else: "application/octet-stream"
99+
100+proc serveStatic(r: Reader, req: Request, rel: string): Future[bool] {.async.} =
101+ ## Serve `rel` from the web root, if it is there. Returns false when it is
102+ ## not, so the caller can fall through.
103+ if r.webRoot.len == 0: return false
104+ # Resolve and confirm the result is still inside the root: "../" in a
105+ # request path is how a static file server turns into a file server for
106+ # the whole disk.
107+ let full = absolutePath(r.webRoot / rel)
108+ let root = absolutePath(r.webRoot)
109+ if not full.startsWith(root) or not fileExists(full): return false
110+ await req.respond(Http200, readFile(full),
111+ newHttpHeaders({"Content-Type": contentTypeFor(full)}))
112+ true
113+
114+proc serveApp(r: Reader, req: Request) {.async.} =
115+ ## The client: a Flutter web build when one is configured, otherwise the
116+ ## page baked into the binary.
117+ if await serveStatic(r, req, "index.html"): return
118+ await req.respond(Http200, IndexHtml,
119+ newHttpHeaders({"Content-Type": "text/html; charset=utf-8"}))
120+
42 proc toJson(a: Article): JsonNode =121 proc toJson(a: Article): JsonNode =
43 %*{122 %*{
44 "id": a.id,123 "id": a.id,
@@ -120,12 +199,27 @@ proc handle(r: Reader, req: Request) {.async.} =
120 else:199 else:
121 try: parseInt(raw) except ValueError: fallback200 try: parseInt(raw) except ValueError: fallback
122 201
202+ # The gate, before anything reads the database or fetches a URL.
203+ if r.token.len > 0:
204+ if not constantTimeEq(presentedToken(req, param("key")), r.token):
205+ await fail(req, Http401, "unauthorized")
206+ return
207+ # Arriving with ?key= means a person followed a link; remember it so the
208+ # rest of the app's requests carry it without the token sitting in every
209+ # URL (and in the browser history, and in any Referer it sends).
210+ if param("key").len > 0:
211+ await req.respond(Http303, "", newHttpHeaders({
212+ "Location": "/",
213+ "Set-Cookie": "glean_token=" & r.token &
214+ "; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000",
215+ }))
216+ return
217+
123 case req.reqMethod218 case req.reqMethod
124 of HttpGet:219 of HttpGet:
125 case segs[0]220 case segs[0]
126 of "":221 of "":
127- await req.respond(Http200, IndexHtml,222+ await serveApp(r, req)
128- newHttpHeaders({"Content-Type": "text/html; charset=utf-8"}))
129 of "health":223 of "health":
130 await jsonResponse(req, Http200, %*{"ok": true})224 await jsonResponse(req, Http200, %*{"ok": true})
131 225
@@ -166,7 +260,9 @@ proc handle(r: Reader, req: Request) {.async.} =
166 await jsonResponse(req, Http200,260 await jsonResponse(req, Http200,
167 %*{"count": r.db.getUnreadCount(LocalUser, "", "")})261 %*{"count": r.db.getUnreadCount(LocalUser, "", "")})
168 else:262 else:
169- await fail(req, Http404, "not found")263+ # Not an API route: it may be an asset of the web client.
264+ if not await serveStatic(r, req, path):
265+ await fail(req, Http404, "not found")
170 266
171 of HttpPost:267 of HttpPost:
172 case segs[0]268 case segs[0]
@@ -250,9 +346,24 @@ proc main() {.async.} =
250 var db = gleandb.open(dbPath)346 var db = gleandb.open(dbPath)
251 db.migrate()347 db.migrate()
252 db.db.run("INSERT OR IGNORE INTO users (did) VALUES (?)", p(LocalUser))348 db.db.run("INSERT OR IGNORE INTO users (did) VALUES (?)", p(LocalUser))
253- let reader = Reader(db: db)349+
350+ let token = getEnv("GLEAN_TOKEN")
351+ var webRoot = getEnv("GLEAN_WEB")
352+ if webRoot.len > 0 and not dirExists(webRoot):
353+ # Failing loudly beats silently serving the fallback page and leaving
354+ # someone wondering why their client never updated.
355+ quit(&"GLEAN_WEB points at {webRoot}, which is not a directory")
356+
357+ let reader = Reader(db: db, token: token, webRoot: webRoot)
254 358
255 echo &"glean reading from {dbPath}"359 echo &"glean reading from {dbPath}"
360+ if webRoot.len > 0: echo &"serving the client from {webRoot}"
361+ if token.len > 0:
362+ echo "token required: append ?key=… once to set the cookie"
363+ else:
364+ # Worth saying out loud, because the difference between this and an open
365+ # instance on a public address is the whole of the security model.
366+ echo "no token set — anyone who can reach this port has full access"
256 echo &"open http://127.0.0.1:{port}"367 echo &"open http://127.0.0.1:{port}"
257 368
258 var server = newAsyncHttpServer()369 var server = newAsyncHttpServer()
@@ -264,7 +375,11 @@ proc main() {.async.} =
264 proc cb(req: Request) {.async, gcsafe.} =375 proc cb(req: Request) {.async, gcsafe.} =
265 {.cast(gcsafe).}:376 {.cast(gcsafe).}:
266 await reader.handle(req)377 await reader.handle(req)
267- await server.serve(Port(port), cb, address = "127.0.0.1")378+ # Loopback unless a token guards it. Modal and other containers need a
379+ # listener on all interfaces, and requiring the token for that is what
380+ # keeps "reachable" from meaning "open".
381+ let address = if token.len > 0: "0.0.0.0" else: "127.0.0.1"
382+ await server.serve(Port(port), cb, address = address)
268 383
269 when isMainModule:384 when isMainModule:
270 waitFor main()385 waitFor main()