nandi/gleanpublic Fork
⑂ 4cd4748
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

Add a small RSS reader that actually runs

The parity port was heading for a second backend nobody had decided to
deploy: 4,130 lines across 14 modules, each passing its own probe, none of
them ever running in the same process. This is the opposite bet -- a few
hundred lines of routing over those modules, producing one binary that reads
feeds.

Single user, no accounts, no sessions, no CSRF, no ATProto. That assumption
is the whole simplification: it binds loopback and anything that reaches the
port can do anything, which is fine for something running on your own
machine and is why the readme says not to expose it.

Verified end to end rather than by probe: subscribes to two real feeds,
ingests 20 articles, marks read and unread, searches over FTS5, scopes to
one feed, scrapes an article's full text and persists it, refreshes without
duplicating, and unsubscribes.

Parameters are read from the query string or a form body. Nim's
asynchttpserver rejects a POST with no Content-Length, so a query-only POST
fails in a way that looks like a routing bug; accepting both is what Go's
FormValue does anyway.

Storage keeps one hard-coded user, because the schema came from a multi-user
server and satisfying that was cheaper than rewriting it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandithebull committed 2026-09-21T19:16:49-07:00 Browse files
4cd4748 parent: 863259c
modified spike/nim/.gitignore +1 -0
@@ -19,3 +19,4 @@ feedfetcher_probe
1919 scraper_probe
2020 cluster_probe
2121 diag
22+glean
@@ -19,3 +19,4 @@ feedfetcher_probe
19 scraper_probe19 scraper_probe
20 cluster_probe20 cluster_probe
21 diag21 diag
22+glean
added spike/nim/glean.nim +255 -0
new file mode 100644
@@ -0,0 +1,255 @@
1+## A small RSS reader.
2+##
3+## nim c -r -d:ssl glean.nim
4+## GLEAN_DB=~/.local/share/glean/db GLEAN_PORT=8080 ./glean
5+##
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
8+## few thousand: no sessions, no CSRF, no per-user scoping, no ATProto.
9+##
10+## The pieces underneath -- fetching, parsing, storage, search, full-text
11+## extraction -- are the same modules the larger port built and tested.
12+
13+import std/[asyncdispatch, asynchttpserver, httpclient, json, options, os,
14+ strformat, strutils, uri]
15+import atproto/[articlestore, feedfetcher, feedparser, feedstore, gleandb,
16+ scraper, sqlite]
17+
18+const
19+ ## Everything is stored per-user underneath because the schema came from a
20+ ## multi-user server. One constant user keeps that plumbing satisfied
21+ ## without inventing accounts.
22+ LocalUser = "local"
23+ DefaultPort = 8080
24+
25+type Reader = ref object
26+ db: GleanDb
27+
28+proc jsonResponse(req: Request, code: HttpCode, node: JsonNode) {.async.} =
29+ await req.respond(code, $node, newHttpHeaders({
30+ "Content-Type": "application/json",
31+ # The reader may be opened from a local web page or a native client.
32+ "Access-Control-Allow-Origin": "*",
33+ }))
34+
35+proc fail(req: Request, code: HttpCode, msg: string) {.async.} =
36+ await jsonResponse(req, code, %*{"error": msg})
37+
38+proc toJson(a: Article): JsonNode =
39+ %*{
40+ "id": a.id,
41+ "feed_url": a.feedUrl,
42+ "feed_title": a.feedTitle,
43+ "title": a.title,
44+ "url": a.url,
45+ "author": a.author,
46+ "summary": a.summary,
47+ "content": (if a.fullContent.len > 0: a.fullContent else: a.content),
48+ "published": (if a.published.isSome: $a.published.get else: ""),
49+ "is_read": a.isRead,
50+ }
51+
52+proc toJson(s: Subscription, unread: int): JsonNode =
53+ %*{
54+ "feed_url": s.feedUrl,
55+ "title": s.feedTitle,
56+ "category": s.category,
57+ "unread": unread,
58+ "favicon_url": s.faviconUrl,
59+ }
60+
61+# --- feed refresh ----------------------------------------------------------
62+
63+proc refreshFeed(r: Reader, feedUrl: string): tuple[added: int, err: string] =
64+ ## Fetch one feed and store what came back.
65+ ##
66+ ## A failure is recorded rather than raised: one unreachable feed should
67+ ## not stop a refresh of the others, and the error belongs on the feed so
68+ ## the reader can see which one is broken.
69+ try:
70+ let parsed = fetchFeed(feedUrl)
71+ var batch: seq[NewArticle]
72+ for a in parsed.articles:
73+ batch.add NewArticle(
74+ feedUrl: feedUrl, guid: a.guid, title: a.title, url: a.url,
75+ author: a.author, summary: a.summary, content: a.content,
76+ published: a.published, updated: a.updated)
77+ let before = r.db.getUnreadCount(LocalUser, feedUrl, "")
78+ r.db.batchUpsertArticles(batch)
79+ r.db.upsertFeed(Feed(feedUrl: feedUrl, title: parsed.feed.title,
80+ siteUrl: parsed.feed.siteUrl,
81+ description: parsed.feed.description,
82+ feedType: $parsed.feed.kind,
83+ faviconUrl: parsed.feed.faviconUrl))
84+ r.db.markFeedFetched(feedUrl)
85+ (r.db.getUnreadCount(LocalUser, feedUrl, "") - before, "")
86+ except CatchableError as e:
87+ r.db.markFeedFetchError(feedUrl, e.msg)
88+ (0, e.msg)
89+
90+# --- routes ----------------------------------------------------------------
91+
92+proc handle(r: Reader, req: Request) {.async.} =
93+ let path = req.url.path.strip(chars = {'/'})
94+ let segs = path.split('/')
95+ # Query string and form body both, the way Go's FormValue does: a caller
96+ # should not have to care which one a given route wanted.
97+ var q: seq[(string, string)] = @[]
98+ for kv in decodeQuery(req.url.query): q.add kv
99+ if req.body.len > 0:
100+ for kv in decodeQuery(req.body): q.add kv
101+ proc param(name: string, fallback = ""): string =
102+ for (k, v) in q:
103+ if k == name: return v
104+ fallback
105+ proc intParam(name: string, fallback: int): int =
106+ let raw = param(name)
107+ if raw.len == 0: fallback
108+ else:
109+ try: parseInt(raw) except ValueError: fallback
110+
111+ case req.reqMethod
112+ of HttpGet:
113+ case segs[0]
114+ of "", "health":
115+ await jsonResponse(req, Http200, %*{"ok": true})
116+
117+ of "feeds":
118+ var items = newJArray()
119+ for s in r.db.listSubscriptions(LocalUser, "", 500, 0):
120+ items.add toJson(s, s.unreadCount)
121+ await jsonResponse(req, Http200, items)
122+
123+ of "articles":
124+ if segs.len == 2:
125+ # One article, with its full text if we have it.
126+ let id = try: parseInt(segs[1]) except ValueError: 0
127+ let a = r.db.getArticle(LocalUser, id)
128+ if a.isNone:
129+ await fail(req, Http404, "no such article")
130+ else:
131+ await jsonResponse(req, Http200, toJson(a.get))
132+ return
133+
134+ let search = param("q")
135+ let articles =
136+ if search.len > 0:
137+ r.db.searchArticles(LocalUser, search, intParam("limit", 50), 0)
138+ else:
139+ r.db.listArticles(
140+ LocalUser,
141+ filter = (if param("status") == "all": afAll
142+ elif param("status") == "read": afRead
143+ else: afUnread),
144+ feedUrl = param("feed"),
145+ limit = intParam("limit", 50))
146+ var items = newJArray()
147+ for a in articles: items.add toJson(a)
148+ await jsonResponse(req, Http200, items)
149+
150+ of "unread":
151+ await jsonResponse(req, Http200,
152+ %*{"count": r.db.getUnreadCount(LocalUser, "", "")})
153+ else:
154+ await fail(req, Http404, "not found")
155+
156+ of HttpPost:
157+ case segs[0]
158+ of "feeds":
159+ let url = param("url")
160+ if url.len == 0:
161+ await fail(req, Http400, "url required")
162+ return
163+ # Subscribe first so a feed that fails to fetch is still listed, with
164+ # its error visible, rather than vanishing.
165+ r.db.upsertFeed(Feed(feedUrl: url, title: url))
166+ r.db.createSubscription(LocalUser, url, "", param("category"), "", "")
167+ let (added, err) = r.refreshFeed(url)
168+ if err.len > 0:
169+ await jsonResponse(req, Http200,
170+ %*{"feed_url": url, "added": 0, "error": err})
171+ else:
172+ await jsonResponse(req, Http200, %*{"feed_url": url, "added": added})
173+
174+ of "refresh":
175+ var total = 0
176+ var errors = newJArray()
177+ for s in r.db.listSubscriptions(LocalUser, "", 500, 0):
178+ let (added, err) = r.refreshFeed(s.feedUrl)
179+ total += added
180+ if err.len > 0:
181+ errors.add %*{"feed_url": s.feedUrl, "error": err}
182+ await jsonResponse(req, Http200, %*{"added": total, "errors": errors})
183+
184+ of "read":
185+ let id = intParam("id", 0)
186+ if id == 0:
187+ await fail(req, Http400, "id required")
188+ return
189+ if param("undo") == "1":
190+ r.db.markArticleUnread(LocalUser, id)
191+ else:
192+ r.db.markArticleRead(LocalUser, id)
193+ await jsonResponse(req, Http200, %*{"id": id})
194+
195+ of "read-all":
196+ r.db.markAllRead(LocalUser, param("feed"))
197+ await jsonResponse(req, Http200,
198+ %*{"unread": r.db.getUnreadCount(LocalUser, "", "")})
199+
200+ of "fetch-content":
201+ # Most feeds ship an excerpt; this pulls the real article.
202+ let id = intParam("id", 0)
203+ let a = r.db.getArticle(LocalUser, id)
204+ if a.isNone or a.get.url.len == 0:
205+ await fail(req, Http404, "no such article")
206+ return
207+ try:
208+ let client = newHttpClient(timeout = 20_000,
209+ userAgent = "glean/0.1")
210+ defer: client.close()
211+ let text = extractArticle(client.getContent(a.get.url))
212+ r.db.updateArticleFullContent(id, text)
213+ await jsonResponse(req, Http200, %*{"id": id, "content": text})
214+ except CatchableError as e:
215+ await fail(req, Http502, e.msg)
216+ else:
217+ await fail(req, Http404, "not found")
218+
219+ of HttpDelete:
220+ if segs[0] == "feeds" and param("url").len > 0:
221+ r.db.deleteSubscription(LocalUser, param("url"))
222+ await jsonResponse(req, Http200, %*{"ok": true})
223+ else:
224+ await fail(req, Http400, "url required")
225+
226+ else:
227+ await fail(req, Http405, "method not allowed")
228+
229+proc main() {.async.} =
230+ let dbPath = getEnv("GLEAN_DB", getHomeDir() / ".local/share/glean/glean")
231+ createDir(dbPath.parentDir)
232+ let port = try: parseInt(getEnv("GLEAN_PORT", $DefaultPort))
233+ except ValueError: DefaultPort
234+
235+ var db = gleandb.open(dbPath)
236+ db.migrate()
237+ db.db.run("INSERT OR IGNORE INTO users (did) VALUES (?)", p(LocalUser))
238+ let reader = Reader(db: db)
239+
240+ echo &"glean reading from {dbPath}"
241+ echo &"listening on http://127.0.0.1:{port}"
242+
243+ var server = newAsyncHttpServer()
244+ # Single-threaded server, single Reader: the cast is asserting that, not
245+ # claiming the Reader is safe to share across threads.
246+ # Single-threaded server with one Reader. The cast asserts that, rather
247+ # than claiming the modules underneath are thread-safe -- they are not, and
248+ # do not need to be.
249+ proc cb(req: Request) {.async, gcsafe.} =
250+ {.cast(gcsafe).}:
251+ await reader.handle(req)
252+ await server.serve(Port(port), cb, address = "127.0.0.1")
253+
254+when isMainModule:
255+ waitFor main()
new file mode 100644
@@ -0,0 +1,255 @@
1+## A small RSS reader.
2+##
3+## nim c -r -d:ssl glean.nim
4+## GLEAN_DB=~/.local/share/glean/db GLEAN_PORT=8080 ./glean
5+##
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
8+## few thousand: no sessions, no CSRF, no per-user scoping, no ATProto.
9+##
10+## The pieces underneath -- fetching, parsing, storage, search, full-text
11+## extraction -- are the same modules the larger port built and tested.
12+
13+import std/[asyncdispatch, asynchttpserver, httpclient, json, options, os,
14+ strformat, strutils, uri]
15+import atproto/[articlestore, feedfetcher, feedparser, feedstore, gleandb,
16+ scraper, sqlite]
17+
18+const
19+ ## Everything is stored per-user underneath because the schema came from a
20+ ## multi-user server. One constant user keeps that plumbing satisfied
21+ ## without inventing accounts.
22+ LocalUser = "local"
23+ DefaultPort = 8080
24+
25+type Reader = ref object
26+ db: GleanDb
27+
28+proc jsonResponse(req: Request, code: HttpCode, node: JsonNode) {.async.} =
29+ await req.respond(code, $node, newHttpHeaders({
30+ "Content-Type": "application/json",
31+ # The reader may be opened from a local web page or a native client.
32+ "Access-Control-Allow-Origin": "*",
33+ }))
34+
35+proc fail(req: Request, code: HttpCode, msg: string) {.async.} =
36+ await jsonResponse(req, code, %*{"error": msg})
37+
38+proc toJson(a: Article): JsonNode =
39+ %*{
40+ "id": a.id,
41+ "feed_url": a.feedUrl,
42+ "feed_title": a.feedTitle,
43+ "title": a.title,
44+ "url": a.url,
45+ "author": a.author,
46+ "summary": a.summary,
47+ "content": (if a.fullContent.len > 0: a.fullContent else: a.content),
48+ "published": (if a.published.isSome: $a.published.get else: ""),
49+ "is_read": a.isRead,
50+ }
51+
52+proc toJson(s: Subscription, unread: int): JsonNode =
53+ %*{
54+ "feed_url": s.feedUrl,
55+ "title": s.feedTitle,
56+ "category": s.category,
57+ "unread": unread,
58+ "favicon_url": s.faviconUrl,
59+ }
60+
61+# --- feed refresh ----------------------------------------------------------
62+
63+proc refreshFeed(r: Reader, feedUrl: string): tuple[added: int, err: string] =
64+ ## Fetch one feed and store what came back.
65+ ##
66+ ## A failure is recorded rather than raised: one unreachable feed should
67+ ## not stop a refresh of the others, and the error belongs on the feed so
68+ ## the reader can see which one is broken.
69+ try:
70+ let parsed = fetchFeed(feedUrl)
71+ var batch: seq[NewArticle]
72+ for a in parsed.articles:
73+ batch.add NewArticle(
74+ feedUrl: feedUrl, guid: a.guid, title: a.title, url: a.url,
75+ author: a.author, summary: a.summary, content: a.content,
76+ published: a.published, updated: a.updated)
77+ let before = r.db.getUnreadCount(LocalUser, feedUrl, "")
78+ r.db.batchUpsertArticles(batch)
79+ r.db.upsertFeed(Feed(feedUrl: feedUrl, title: parsed.feed.title,
80+ siteUrl: parsed.feed.siteUrl,
81+ description: parsed.feed.description,
82+ feedType: $parsed.feed.kind,
83+ faviconUrl: parsed.feed.faviconUrl))
84+ r.db.markFeedFetched(feedUrl)
85+ (r.db.getUnreadCount(LocalUser, feedUrl, "") - before, "")
86+ except CatchableError as e:
87+ r.db.markFeedFetchError(feedUrl, e.msg)
88+ (0, e.msg)
89+
90+# --- routes ----------------------------------------------------------------
91+
92+proc handle(r: Reader, req: Request) {.async.} =
93+ let path = req.url.path.strip(chars = {'/'})
94+ let segs = path.split('/')
95+ # Query string and form body both, the way Go's FormValue does: a caller
96+ # should not have to care which one a given route wanted.
97+ var q: seq[(string, string)] = @[]
98+ for kv in decodeQuery(req.url.query): q.add kv
99+ if req.body.len > 0:
100+ for kv in decodeQuery(req.body): q.add kv
101+ proc param(name: string, fallback = ""): string =
102+ for (k, v) in q:
103+ if k == name: return v
104+ fallback
105+ proc intParam(name: string, fallback: int): int =
106+ let raw = param(name)
107+ if raw.len == 0: fallback
108+ else:
109+ try: parseInt(raw) except ValueError: fallback
110+
111+ case req.reqMethod
112+ of HttpGet:
113+ case segs[0]
114+ of "", "health":
115+ await jsonResponse(req, Http200, %*{"ok": true})
116+
117+ of "feeds":
118+ var items = newJArray()
119+ for s in r.db.listSubscriptions(LocalUser, "", 500, 0):
120+ items.add toJson(s, s.unreadCount)
121+ await jsonResponse(req, Http200, items)
122+
123+ of "articles":
124+ if segs.len == 2:
125+ # One article, with its full text if we have it.
126+ let id = try: parseInt(segs[1]) except ValueError: 0
127+ let a = r.db.getArticle(LocalUser, id)
128+ if a.isNone:
129+ await fail(req, Http404, "no such article")
130+ else:
131+ await jsonResponse(req, Http200, toJson(a.get))
132+ return
133+
134+ let search = param("q")
135+ let articles =
136+ if search.len > 0:
137+ r.db.searchArticles(LocalUser, search, intParam("limit", 50), 0)
138+ else:
139+ r.db.listArticles(
140+ LocalUser,
141+ filter = (if param("status") == "all": afAll
142+ elif param("status") == "read": afRead
143+ else: afUnread),
144+ feedUrl = param("feed"),
145+ limit = intParam("limit", 50))
146+ var items = newJArray()
147+ for a in articles: items.add toJson(a)
148+ await jsonResponse(req, Http200, items)
149+
150+ of "unread":
151+ await jsonResponse(req, Http200,
152+ %*{"count": r.db.getUnreadCount(LocalUser, "", "")})
153+ else:
154+ await fail(req, Http404, "not found")
155+
156+ of HttpPost:
157+ case segs[0]
158+ of "feeds":
159+ let url = param("url")
160+ if url.len == 0:
161+ await fail(req, Http400, "url required")
162+ return
163+ # Subscribe first so a feed that fails to fetch is still listed, with
164+ # its error visible, rather than vanishing.
165+ r.db.upsertFeed(Feed(feedUrl: url, title: url))
166+ r.db.createSubscription(LocalUser, url, "", param("category"), "", "")
167+ let (added, err) = r.refreshFeed(url)
168+ if err.len > 0:
169+ await jsonResponse(req, Http200,
170+ %*{"feed_url": url, "added": 0, "error": err})
171+ else:
172+ await jsonResponse(req, Http200, %*{"feed_url": url, "added": added})
173+
174+ of "refresh":
175+ var total = 0
176+ var errors = newJArray()
177+ for s in r.db.listSubscriptions(LocalUser, "", 500, 0):
178+ let (added, err) = r.refreshFeed(s.feedUrl)
179+ total += added
180+ if err.len > 0:
181+ errors.add %*{"feed_url": s.feedUrl, "error": err}
182+ await jsonResponse(req, Http200, %*{"added": total, "errors": errors})
183+
184+ of "read":
185+ let id = intParam("id", 0)
186+ if id == 0:
187+ await fail(req, Http400, "id required")
188+ return
189+ if param("undo") == "1":
190+ r.db.markArticleUnread(LocalUser, id)
191+ else:
192+ r.db.markArticleRead(LocalUser, id)
193+ await jsonResponse(req, Http200, %*{"id": id})
194+
195+ of "read-all":
196+ r.db.markAllRead(LocalUser, param("feed"))
197+ await jsonResponse(req, Http200,
198+ %*{"unread": r.db.getUnreadCount(LocalUser, "", "")})
199+
200+ of "fetch-content":
201+ # Most feeds ship an excerpt; this pulls the real article.
202+ let id = intParam("id", 0)
203+ let a = r.db.getArticle(LocalUser, id)
204+ if a.isNone or a.get.url.len == 0:
205+ await fail(req, Http404, "no such article")
206+ return
207+ try:
208+ let client = newHttpClient(timeout = 20_000,
209+ userAgent = "glean/0.1")
210+ defer: client.close()
211+ let text = extractArticle(client.getContent(a.get.url))
212+ r.db.updateArticleFullContent(id, text)
213+ await jsonResponse(req, Http200, %*{"id": id, "content": text})
214+ except CatchableError as e:
215+ await fail(req, Http502, e.msg)
216+ else:
217+ await fail(req, Http404, "not found")
218+
219+ of HttpDelete:
220+ if segs[0] == "feeds" and param("url").len > 0:
221+ r.db.deleteSubscription(LocalUser, param("url"))
222+ await jsonResponse(req, Http200, %*{"ok": true})
223+ else:
224+ await fail(req, Http400, "url required")
225+
226+ else:
227+ await fail(req, Http405, "method not allowed")
228+
229+proc main() {.async.} =
230+ let dbPath = getEnv("GLEAN_DB", getHomeDir() / ".local/share/glean/glean")
231+ createDir(dbPath.parentDir)
232+ let port = try: parseInt(getEnv("GLEAN_PORT", $DefaultPort))
233+ except ValueError: DefaultPort
234+
235+ var db = gleandb.open(dbPath)
236+ db.migrate()
237+ db.db.run("INSERT OR IGNORE INTO users (did) VALUES (?)", p(LocalUser))
238+ let reader = Reader(db: db)
239+
240+ echo &"glean reading from {dbPath}"
241+ echo &"listening on http://127.0.0.1:{port}"
242+
243+ var server = newAsyncHttpServer()
244+ # Single-threaded server, single Reader: the cast is asserting that, not
245+ # claiming the Reader is safe to share across threads.
246+ # Single-threaded server with one Reader. The cast asserts that, rather
247+ # than claiming the modules underneath are thread-safe -- they are not, and
248+ # do not need to be.
249+ proc cb(req: Request) {.async, gcsafe.} =
250+ {.cast(gcsafe).}:
251+ await reader.handle(req)
252+ await server.serve(Port(port), cb, address = "127.0.0.1")
253+
254+when isMainModule:
255+ waitFor main()
added spike/nim/readme-reader.md +56 -0
new file mode 100644
@@ -0,0 +1,56 @@
1+# glean — a small RSS reader
2+
3+One binary, one SQLite database, no accounts.
4+
5+```
6+nim c -d:ssl glean.nim
7+./glean
8+```
9+
10+Listens on `127.0.0.1:8080` and stores its database under
11+`~/.local/share/glean/`. Override with `GLEAN_PORT` and `GLEAN_DB`.
12+
13+## API
14+
15+```
16+GET /feeds subscriptions, with unread counts
17+POST /feeds url=… subscribe and fetch immediately
18+DELETE /feeds?url=… unsubscribe
19+
20+GET /articles unread, newest first
21+GET /articles?status=all|read …or everything, or just the read ones
22+GET /articles?feed=… …scoped to one feed
23+GET /articles?q=… full-text search (FTS5)
24+GET /articles/:id one article
25+
26+POST /read id=… mark read (undo=1 to reverse)
27+POST /read-all [feed=…] mark a feed, or everything, read
28+POST /refresh fetch every subscribed feed
29+POST /fetch-content id=… scrape the full article text
30+
31+GET /unread unread count
32+```
33+
34+Parameters are read from the query string or a form body, whichever the
35+caller used.
36+
37+## What it does not do
38+
39+No accounts, no sessions, no CSRF, no ATProto. It runs on your machine and
40+reads your feeds, and that assumption is what keeps it a few hundred lines
41+rather than a few thousand.
42+
43+The one visible consequence: **do not expose it to a network.** It binds
44+loopback deliberately. Anything that can reach the port can read and change
45+everything, because there is nobody to distinguish from anybody else.
46+
47+## What it is built on
48+
49+The modules under `atproto/` — fetching with a sane retry policy, parsing
50+RSS/RDF/Atom/JSON Feed, SQLite with FTS5, and a readability-style extractor
51+that sanitises what it returns. Those came out of a larger port and carry
52+their own tests (`nimble test`); this file is a few hundred lines of routing
53+on top.
54+
55+Storage keeps a single hard-coded user, because the schema came from a
56+multi-user server and it was cheaper to satisfy that than to rewrite it.
new file mode 100644
@@ -0,0 +1,56 @@
1+# glean — a small RSS reader
2+
3+One binary, one SQLite database, no accounts.
4+
5+```
6+nim c -d:ssl glean.nim
7+./glean
8+```
9+
10+Listens on `127.0.0.1:8080` and stores its database under
11+`~/.local/share/glean/`. Override with `GLEAN_PORT` and `GLEAN_DB`.
12+
13+## API
14+
15+```
16+GET /feeds subscriptions, with unread counts
17+POST /feeds url=… subscribe and fetch immediately
18+DELETE /feeds?url=… unsubscribe
19+
20+GET /articles unread, newest first
21+GET /articles?status=all|read …or everything, or just the read ones
22+GET /articles?feed=… …scoped to one feed
23+GET /articles?q=… full-text search (FTS5)
24+GET /articles/:id one article
25+
26+POST /read id=… mark read (undo=1 to reverse)
27+POST /read-all [feed=…] mark a feed, or everything, read
28+POST /refresh fetch every subscribed feed
29+POST /fetch-content id=… scrape the full article text
30+
31+GET /unread unread count
32+```
33+
34+Parameters are read from the query string or a form body, whichever the
35+caller used.
36+
37+## What it does not do
38+
39+No accounts, no sessions, no CSRF, no ATProto. It runs on your machine and
40+reads your feeds, and that assumption is what keeps it a few hundred lines
41+rather than a few thousand.
42+
43+The one visible consequence: **do not expose it to a network.** It binds
44+loopback deliberately. Anything that can reach the port can read and change
45+everything, because there is nobody to distinguish from anybody else.
46+
47+## What it is built on
48+
49+The modules under `atproto/` — fetching with a sane retry policy, parsing
50+RSS/RDF/Atom/JSON Feed, SQLite with FTS5, and a readability-style extractor
51+that sanitises what it returns. Those came out of a larger port and carry
52+their own tests (`nimble test`); this file is a few hundred lines of routing
53+on top.
54+
55+Storage keeps a single hard-coded user, because the schema came from a
56+multi-user server and it was cheaper to satisfy that than to rewrite it.