nandi/gleanpublic Fork 0
fa08b45
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.

Refactor database schema and sessions implementationUnverified

Julien Robert committed 2026-04-23T22:51:42+02:00 Browse files
fa08b45 parent: e82f9b0
modified internal/atproto/xrpc.go +9 -9
@@ -29,8 +29,8 @@ func (h *XRPCHandler) ListSubscriptions(w http.ResponseWriter, r *http.Request)
2929
3030 query := `
3131 SELECT s.id, f.feed_url, COALESCE(s.title, f.title), s.category, s.added_at
32- FROM subscriptions s
33- JOIN feeds f ON s.feed_url = f.feed_url
32+ FROM articles.subscriptions s
33+ JOIN articles.feeds f ON s.feed_url = f.feed_url
3434 WHERE s.user_did = ?`
3535 args := []any{repo}
3636
@@ -95,7 +95,7 @@ func (h *XRPCHandler) ListAnnotations(w http.ResponseWriter, r *http.Request) {
9595 query := `
9696 SELECT a.uri, a.cid, u.did, a.feed_url, a.article_url,
9797 a.quote, a.note, a.tags, a.rating, a.created_at
98- FROM annotations a
98+ FROM articles.annotations a
9999 JOIN users u ON a.author_did = u.did
100100 WHERE 1=1`
101101 args := []any{}
@@ -180,7 +180,7 @@ func (h *XRPCHandler) ListLikes(w http.ResponseWriter, r *http.Request) {
180180
181181 query := `
182182 SELECT l.uri, l.cid, u.did, l.feed_url, l.article_url, l.created_at
183- FROM likes l
183+ FROM articles.likes l
184184 JOIN users u ON l.author_did = u.did
185185 WHERE 1=1`
186186 args := []any{}
@@ -250,8 +250,8 @@ func (h *XRPCHandler) GetTrending(w http.ResponseWriter, r *http.Request) {
250250
251251 query := `
252252 SELECT l.feed_url, l.article_url, a.title, COUNT(*) as like_count
253- FROM likes l
254- LEFT JOIN articles a ON l.article_url = a.url
253+ FROM articles.likes l
254+ LEFT JOIN articles.articles a ON l.article_url = a.url
255255 WHERE 1=1`
256256 args := []any{}
257257
@@ -372,7 +372,7 @@ func (h *XRPCHandler) ListFeedLists(w http.ResponseWriter, r *http.Request) {
372372 query := `
373373 SELECT u.did, COUNT(s.id) as subscription_count
374374 FROM users u
375- LEFT JOIN subscriptions s ON u.did = s.user_did
375+ LEFT JOIN articles.subscriptions s ON u.did = s.user_did
376376 WHERE u.did IN (` + strings.Join(placeholders, ",") + `)`
377377
378378 if cursor != "" {
@@ -417,8 +417,8 @@ func (h *XRPCHandler) ListFeedLists(w http.ResponseWriter, r *http.Request) {
417417 }
418418 subRows, err := h.db.QueryContext(r.Context(), `
419419 SELECT s.user_did, s.feed_url, COALESCE(s.title, f.title), s.category
420- FROM subscriptions s
421- JOIN feeds f ON s.feed_url = f.feed_url
420+ FROM articles.subscriptions s
421+ JOIN articles.feeds f ON s.feed_url = f.feed_url
422422 WHERE s.user_did IN (`+strings.Join(ph, ",")+`)
423423 ORDER BY s.user_did, s.added_at DESC
424424 `, args...)
@@ -29,8 +29,8 @@ func (h *XRPCHandler) ListSubscriptions(w http.ResponseWriter, r *http.Request)
29 29
30 query := `30 query := `
31 SELECT s.id, f.feed_url, COALESCE(s.title, f.title), s.category, s.added_at31 SELECT s.id, f.feed_url, COALESCE(s.title, f.title), s.category, s.added_at
32- FROM subscriptions s32+ FROM articles.subscriptions s
33- JOIN feeds f ON s.feed_url = f.feed_url33+ JOIN articles.feeds f ON s.feed_url = f.feed_url
34 WHERE s.user_did = ?`34 WHERE s.user_did = ?`
35 args := []any{repo}35 args := []any{repo}
36 36
@@ -95,7 +95,7 @@ func (h *XRPCHandler) ListAnnotations(w http.ResponseWriter, r *http.Request) {
95 query := `95 query := `
96 SELECT a.uri, a.cid, u.did, a.feed_url, a.article_url,96 SELECT a.uri, a.cid, u.did, a.feed_url, a.article_url,
97 a.quote, a.note, a.tags, a.rating, a.created_at97 a.quote, a.note, a.tags, a.rating, a.created_at
98- FROM annotations a98+ FROM articles.annotations a
99 JOIN users u ON a.author_did = u.did99 JOIN users u ON a.author_did = u.did
100 WHERE 1=1`100 WHERE 1=1`
101 args := []any{}101 args := []any{}
@@ -180,7 +180,7 @@ func (h *XRPCHandler) ListLikes(w http.ResponseWriter, r *http.Request) {
180 180
181 query := `181 query := `
182 SELECT l.uri, l.cid, u.did, l.feed_url, l.article_url, l.created_at182 SELECT l.uri, l.cid, u.did, l.feed_url, l.article_url, l.created_at
183- FROM likes l183+ FROM articles.likes l
184 JOIN users u ON l.author_did = u.did184 JOIN users u ON l.author_did = u.did
185 WHERE 1=1`185 WHERE 1=1`
186 args := []any{}186 args := []any{}
@@ -250,8 +250,8 @@ func (h *XRPCHandler) GetTrending(w http.ResponseWriter, r *http.Request) {
250 250
251 query := `251 query := `
252 SELECT l.feed_url, l.article_url, a.title, COUNT(*) as like_count252 SELECT l.feed_url, l.article_url, a.title, COUNT(*) as like_count
253- FROM likes l253+ FROM articles.likes l
254- LEFT JOIN articles a ON l.article_url = a.url254+ LEFT JOIN articles.articles a ON l.article_url = a.url
255 WHERE 1=1`255 WHERE 1=1`
256 args := []any{}256 args := []any{}
257 257
@@ -372,7 +372,7 @@ func (h *XRPCHandler) ListFeedLists(w http.ResponseWriter, r *http.Request) {
372 query := `372 query := `
373 SELECT u.did, COUNT(s.id) as subscription_count373 SELECT u.did, COUNT(s.id) as subscription_count
374 FROM users u374 FROM users u
375- LEFT JOIN subscriptions s ON u.did = s.user_did375+ LEFT JOIN articles.subscriptions s ON u.did = s.user_did
376 WHERE u.did IN (` + strings.Join(placeholders, ",") + `)`376 WHERE u.did IN (` + strings.Join(placeholders, ",") + `)`
377 377
378 if cursor != "" {378 if cursor != "" {
@@ -417,8 +417,8 @@ func (h *XRPCHandler) ListFeedLists(w http.ResponseWriter, r *http.Request) {
417 }417 }
418 subRows, err := h.db.QueryContext(r.Context(), `418 subRows, err := h.db.QueryContext(r.Context(), `
419 SELECT s.user_did, s.feed_url, COALESCE(s.title, f.title), s.category419 SELECT s.user_did, s.feed_url, COALESCE(s.title, f.title), s.category
420- FROM subscriptions s420+ FROM articles.subscriptions s
421- JOIN feeds f ON s.feed_url = f.feed_url421+ JOIN articles.feeds f ON s.feed_url = f.feed_url
422 WHERE s.user_did IN (`+strings.Join(ph, ",")+`)422 WHERE s.user_did IN (`+strings.Join(ph, ",")+`)
423 ORDER BY s.user_did, s.added_at DESC423 ORDER BY s.user_did, s.added_at DESC
424 `, args...)424 `, args...)
modified internal/db/db.go +0 -226
@@ -57,229 +57,3 @@ func NullStrTags(tags []string) sql.NullString {
5757 type DB struct {
5858 *sql.DB
5959 }
60-
61-func (d *DB) Close() error {
62- return d.DB.Close()
63-}
64-
65-func Open(path string) (*DB, error) {
66- db, err := sql.Open("sqlite3_glean", path+"?cache=shared&"+DSN)
67- if err != nil {
68- return nil, err
69- }
70-
71- db.SetMaxOpenConns(10)
72- db.SetMaxIdleConns(5)
73- db.SetConnMaxLifetime(30 * time.Minute)
74-
75- wrapped := &DB{db}
76- if err := initSchema(wrapped); err != nil {
77- db.Close()
78- return nil, err
79- }
80-
81- return wrapped, nil
82-}
83-
84-func initSchema(db *DB) error {
85- for _, s := range schema {
86- if _, err := db.Exec(s); err != nil {
87- return err
88- }
89- }
90- return nil
91-}
92-
93-var schema = []string{
94- `CREATE TABLE IF NOT EXISTS users (
95- did TEXT PRIMARY KEY,
96- indexed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
97- updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
98- )`,
99- `CREATE TABLE IF NOT EXISTS feeds (
100- feed_url TEXT PRIMARY KEY,
101- title TEXT,
102- site_url TEXT,
103- description TEXT,
104- feed_type TEXT CHECK(feed_type IN ('rss', 'atom', 'json')),
105- last_fetched_at DATETIME,
106- last_error TEXT,
107- subscriber_count INTEGER NOT NULL DEFAULT 0,
108- etag TEXT,
109- last_modified TEXT,
110- consecutive_empty_fetches INTEGER NOT NULL DEFAULT 0,
111- error_count INTEGER NOT NULL DEFAULT 0,
112- favicon_url TEXT
113- )`,
114- `CREATE TABLE IF NOT EXISTS subscriptions (
115- id INTEGER PRIMARY KEY AUTOINCREMENT,
116- user_did TEXT NOT NULL,
117- feed_url TEXT NOT NULL,
118- title TEXT,
119- category TEXT,
120- added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
121- uri TEXT,
122- cid TEXT,
123- UNIQUE(user_did, feed_url)
124- )`,
125- `CREATE TABLE IF NOT EXISTS articles (
126- id INTEGER PRIMARY KEY AUTOINCREMENT,
127- feed_url TEXT NOT NULL,
128- guid TEXT NOT NULL,
129- title TEXT NOT NULL DEFAULT '',
130- url TEXT,
131- author TEXT,
132- summary TEXT,
133- content TEXT,
134- full_content TEXT,
135- published DATETIME,
136- updated DATETIME,
137- fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
138- UNIQUE(feed_url, guid)
139- )`,
140- `CREATE TABLE IF NOT EXISTS read_state (
141- user_did TEXT NOT NULL,
142- article_id INTEGER NOT NULL,
143- is_read BOOLEAN NOT NULL DEFAULT 0,
144- read_at DATETIME,
145- PRIMARY KEY (user_did, article_id)
146- )`,
147- `CREATE TABLE IF NOT EXISTS annotations (
148- id INTEGER PRIMARY KEY AUTOINCREMENT,
149- uri TEXT NOT NULL UNIQUE,
150- author_did TEXT NOT NULL,
151- feed_url TEXT NOT NULL,
152- article_url TEXT NOT NULL,
153- quote TEXT,
154- note TEXT,
155- tags TEXT,
156- rating INTEGER,
157- created_at DATETIME NOT NULL,
158- cid TEXT
159- )`,
160- `CREATE TABLE IF NOT EXISTS likes (
161- id INTEGER PRIMARY KEY AUTOINCREMENT,
162- uri TEXT NOT NULL UNIQUE,
163- author_did TEXT NOT NULL,
164- feed_url TEXT NOT NULL,
165- article_url TEXT NOT NULL,
166- created_at DATETIME NOT NULL,
167- cid TEXT,
168- UNIQUE(author_did, feed_url, article_url)
169- )`,
170- `CREATE TABLE IF NOT EXISTS feed_similarity (
171- feed_a TEXT NOT NULL,
172- feed_b TEXT NOT NULL,
173- jaccard REAL NOT NULL,
174- computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
175- PRIMARY KEY (feed_a, feed_b),
176- CHECK(feed_a < feed_b)
177- )`,
178- `CREATE TABLE IF NOT EXISTS user_similarity (
179- user_a TEXT NOT NULL,
180- user_b TEXT NOT NULL,
181- jaccard REAL NOT NULL,
182- common_feeds INTEGER NOT NULL,
183- common_likes INTEGER NOT NULL DEFAULT 0,
184- common_tags INTEGER NOT NULL DEFAULT 0,
185- computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
186- PRIMARY KEY (user_a, user_b),
187- CHECK(user_a < user_b)
188- )`,
189- `CREATE TABLE IF NOT EXISTS follows (
190- user_did TEXT NOT NULL,
191- target_did TEXT NOT NULL,
192- uri TEXT,
193- cid TEXT,
194- followed_at DATETIME,
195- PRIMARY KEY (user_did, target_did)
196- )`,
197- `CREATE TABLE IF NOT EXISTS oauth_auth_requests (
198- state TEXT PRIMARY KEY,
199- data TEXT NOT NULL
200- )`,
201- `CREATE TABLE IF NOT EXISTS oauth_sessions (
202- account_did TEXT NOT NULL,
203- session_id TEXT NOT NULL,
204- data TEXT NOT NULL,
205- PRIMARY KEY (account_did, session_id)
206- )`,
207- `CREATE TABLE IF NOT EXISTS dismissed_recommendations (
208- user_did TEXT NOT NULL,
209- target_type TEXT NOT NULL CHECK(target_type IN ('feed', 'article')),
210- target_id TEXT NOT NULL,
211- reason TEXT,
212- dismissed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
213- PRIMARY KEY (user_did, target_type, target_id)
214- )`,
215- `CREATE TABLE IF NOT EXISTS recommendation_impressions (
216- user_did TEXT NOT NULL,
217- target_type TEXT NOT NULL CHECK(target_type IN ('feed', 'article')),
218- target_id TEXT NOT NULL,
219- first_shown_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
220- last_shown_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
221- shown_count INTEGER NOT NULL DEFAULT 1,
222- acted BOOLEAN NOT NULL DEFAULT 0,
223- PRIMARY KEY (user_did, target_type, target_id)
224- )`,
225- `CREATE TABLE IF NOT EXISTS follow_distances (
226- user_a TEXT NOT NULL,
227- user_b TEXT NOT NULL,
228- distance INTEGER NOT NULL CHECK(distance IN (1, 2)),
229- PRIMARY KEY (user_a, user_b)
230- )`,
231- `CREATE TABLE IF NOT EXISTS user_signal_weights (
232- user_did TEXT PRIMARY KEY,
233- w_sub REAL NOT NULL DEFAULT 1.0,
234- w_like REAL NOT NULL DEFAULT 0.5,
235- w_tag REAL NOT NULL DEFAULT 0.3,
236- w_social REAL NOT NULL DEFAULT 0.7,
237- w_pop REAL NOT NULL DEFAULT 0.2,
238- w_category REAL NOT NULL DEFAULT 0.4,
239- updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
240- )`,
241- `CREATE TABLE IF NOT EXISTS user_signal_profiles (
242- user_did TEXT PRIMARY KEY,
243- total_likes INTEGER NOT NULL DEFAULT 0,
244- total_tags INTEGER NOT NULL DEFAULT 0,
245- top_categories TEXT,
246- updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
247- )`,
248- `CREATE INDEX IF NOT EXISTS idx_subscriptions_feed ON subscriptions(feed_url)`,
249- `CREATE INDEX IF NOT EXISTS idx_subscriptions_feed_user ON subscriptions(feed_url, user_did)`,
250- `CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_did)`,
251- `CREATE INDEX IF NOT EXISTS idx_subscriptions_uri ON subscriptions(uri)`,
252- `CREATE INDEX IF NOT EXISTS idx_likes_author_feed ON likes(author_did, feed_url, created_at)`,
253- `CREATE INDEX IF NOT EXISTS idx_articles_feed ON articles(feed_url)`,
254- `CREATE INDEX IF NOT EXISTS idx_articles_published ON articles(published DESC)`,
255- `CREATE INDEX IF NOT EXISTS idx_articles_url ON articles(url)`,
256- `CREATE INDEX IF NOT EXISTS idx_read_state_unread ON read_state(user_did, is_read) WHERE is_read = 0`,
257- `CREATE INDEX IF NOT EXISTS idx_annotations_article ON annotations(article_url)`,
258- `CREATE INDEX IF NOT EXISTS idx_annotations_author ON annotations(author_did)`,
259- `CREATE INDEX IF NOT EXISTS idx_annotations_created_at ON annotations(created_at DESC)`,
260- `CREATE INDEX IF NOT EXISTS idx_likes_article ON likes(feed_url, article_url)`,
261- `CREATE INDEX IF NOT EXISTS idx_likes_author ON likes(author_did)`,
262- `CREATE INDEX IF NOT EXISTS idx_likes_created_at ON likes(created_at DESC)`,
263- `CREATE INDEX IF NOT EXISTS idx_follows_user ON follows(user_did)`,
264- `CREATE INDEX IF NOT EXISTS idx_follows_target ON follows(target_did)`,
265- `CREATE INDEX IF NOT EXISTS idx_follows_uri ON follows(uri)`,
266- `CREATE INDEX IF NOT EXISTS idx_user_similarity_b ON user_similarity(user_b)`,
267- `CREATE INDEX IF NOT EXISTS idx_user_similarity_a ON user_similarity(user_a)`,
268- `CREATE INDEX IF NOT EXISTS idx_dismissed_user_type ON dismissed_recommendations(user_did, target_type)`,
269- `CREATE INDEX IF NOT EXISTS idx_impressions_user_unacted ON recommendation_impressions(user_did, acted, shown_count)`,
270- `CREATE INDEX IF NOT EXISTS idx_impressions_last_shown ON recommendation_impressions(last_shown_at)`,
271- `CREATE INDEX IF NOT EXISTS idx_follow_distances_b ON follow_distances(user_b)`,
272- `CREATE INDEX IF NOT EXISTS idx_follow_distances_a_dist ON follow_distances(user_a, distance)`,
273- `CREATE INDEX IF NOT EXISTS idx_follows_followed_at ON follows(followed_at)`,
274- `CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(title, summary, content, author, content=articles, content_rowid=id)`,
275- `CREATE TRIGGER IF NOT EXISTS articles_ai AFTER INSERT ON articles BEGIN
276- INSERT INTO articles_fts(rowid, title, summary, content, author) VALUES (new.id, new.title, new.summary, new.content, new.author);
277- END`,
278- `CREATE TRIGGER IF NOT EXISTS articles_ad AFTER DELETE ON articles BEGIN
279- INSERT INTO articles_fts(articles_fts, rowid, title, summary, content, author) VALUES('delete', old.id, old.title, old.summary, old.content, old.author);
280- END`,
281- `CREATE TRIGGER IF NOT EXISTS articles_au AFTER UPDATE ON articles BEGIN
282- INSERT INTO articles_fts(articles_fts, rowid, title, summary, content, author) VALUES('delete', old.id, old.title, old.summary, old.content, old.author);
283- INSERT INTO articles_fts(rowid, title, summary, content, author) VALUES (new.id, new.title, new.summary, new.content, new.author);
284- END`,
285-}
@@ -57,229 +57,3 @@ func NullStrTags(tags []string) sql.NullString {
57 type DB struct {57 type DB struct {
58 *sql.DB58 *sql.DB
59 }59 }
60-
61-func (d *DB) Close() error {
62- return d.DB.Close()
63-}
64-
65-func Open(path string) (*DB, error) {
66- db, err := sql.Open("sqlite3_glean", path+"?cache=shared&"+DSN)
67- if err != nil {
68- return nil, err
69- }
70-
71- db.SetMaxOpenConns(10)
72- db.SetMaxIdleConns(5)
73- db.SetConnMaxLifetime(30 * time.Minute)
74-
75- wrapped := &DB{db}
76- if err := initSchema(wrapped); err != nil {
77- db.Close()
78- return nil, err
79- }
80-
81- return wrapped, nil
82-}
83-
84-func initSchema(db *DB) error {
85- for _, s := range schema {
86- if _, err := db.Exec(s); err != nil {
87- return err
88- }
89- }
90- return nil
91-}
92-
93-var schema = []string{
94- `CREATE TABLE IF NOT EXISTS users (
95- did TEXT PRIMARY KEY,
96- indexed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
97- updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
98- )`,
99- `CREATE TABLE IF NOT EXISTS feeds (
100- feed_url TEXT PRIMARY KEY,
101- title TEXT,
102- site_url TEXT,
103- description TEXT,
104- feed_type TEXT CHECK(feed_type IN ('rss', 'atom', 'json')),
105- last_fetched_at DATETIME,
106- last_error TEXT,
107- subscriber_count INTEGER NOT NULL DEFAULT 0,
108- etag TEXT,
109- last_modified TEXT,
110- consecutive_empty_fetches INTEGER NOT NULL DEFAULT 0,
111- error_count INTEGER NOT NULL DEFAULT 0,
112- favicon_url TEXT
113- )`,
114- `CREATE TABLE IF NOT EXISTS subscriptions (
115- id INTEGER PRIMARY KEY AUTOINCREMENT,
116- user_did TEXT NOT NULL,
117- feed_url TEXT NOT NULL,
118- title TEXT,
119- category TEXT,
120- added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
121- uri TEXT,
122- cid TEXT,
123- UNIQUE(user_did, feed_url)
124- )`,
125- `CREATE TABLE IF NOT EXISTS articles (
126- id INTEGER PRIMARY KEY AUTOINCREMENT,
127- feed_url TEXT NOT NULL,
128- guid TEXT NOT NULL,
129- title TEXT NOT NULL DEFAULT '',
130- url TEXT,
131- author TEXT,
132- summary TEXT,
133- content TEXT,
134- full_content TEXT,
135- published DATETIME,
136- updated DATETIME,
137- fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
138- UNIQUE(feed_url, guid)
139- )`,
140- `CREATE TABLE IF NOT EXISTS read_state (
141- user_did TEXT NOT NULL,
142- article_id INTEGER NOT NULL,
143- is_read BOOLEAN NOT NULL DEFAULT 0,
144- read_at DATETIME,
145- PRIMARY KEY (user_did, article_id)
146- )`,
147- `CREATE TABLE IF NOT EXISTS annotations (
148- id INTEGER PRIMARY KEY AUTOINCREMENT,
149- uri TEXT NOT NULL UNIQUE,
150- author_did TEXT NOT NULL,
151- feed_url TEXT NOT NULL,
152- article_url TEXT NOT NULL,
153- quote TEXT,
154- note TEXT,
155- tags TEXT,
156- rating INTEGER,
157- created_at DATETIME NOT NULL,
158- cid TEXT
159- )`,
160- `CREATE TABLE IF NOT EXISTS likes (
161- id INTEGER PRIMARY KEY AUTOINCREMENT,
162- uri TEXT NOT NULL UNIQUE,
163- author_did TEXT NOT NULL,
164- feed_url TEXT NOT NULL,
165- article_url TEXT NOT NULL,
166- created_at DATETIME NOT NULL,
167- cid TEXT,
168- UNIQUE(author_did, feed_url, article_url)
169- )`,
170- `CREATE TABLE IF NOT EXISTS feed_similarity (
171- feed_a TEXT NOT NULL,
172- feed_b TEXT NOT NULL,
173- jaccard REAL NOT NULL,
174- computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
175- PRIMARY KEY (feed_a, feed_b),
176- CHECK(feed_a < feed_b)
177- )`,
178- `CREATE TABLE IF NOT EXISTS user_similarity (
179- user_a TEXT NOT NULL,
180- user_b TEXT NOT NULL,
181- jaccard REAL NOT NULL,
182- common_feeds INTEGER NOT NULL,
183- common_likes INTEGER NOT NULL DEFAULT 0,
184- common_tags INTEGER NOT NULL DEFAULT 0,
185- computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
186- PRIMARY KEY (user_a, user_b),
187- CHECK(user_a < user_b)
188- )`,
189- `CREATE TABLE IF NOT EXISTS follows (
190- user_did TEXT NOT NULL,
191- target_did TEXT NOT NULL,
192- uri TEXT,
193- cid TEXT,
194- followed_at DATETIME,
195- PRIMARY KEY (user_did, target_did)
196- )`,
197- `CREATE TABLE IF NOT EXISTS oauth_auth_requests (
198- state TEXT PRIMARY KEY,
199- data TEXT NOT NULL
200- )`,
201- `CREATE TABLE IF NOT EXISTS oauth_sessions (
202- account_did TEXT NOT NULL,
203- session_id TEXT NOT NULL,
204- data TEXT NOT NULL,
205- PRIMARY KEY (account_did, session_id)
206- )`,
207- `CREATE TABLE IF NOT EXISTS dismissed_recommendations (
208- user_did TEXT NOT NULL,
209- target_type TEXT NOT NULL CHECK(target_type IN ('feed', 'article')),
210- target_id TEXT NOT NULL,
211- reason TEXT,
212- dismissed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
213- PRIMARY KEY (user_did, target_type, target_id)
214- )`,
215- `CREATE TABLE IF NOT EXISTS recommendation_impressions (
216- user_did TEXT NOT NULL,
217- target_type TEXT NOT NULL CHECK(target_type IN ('feed', 'article')),
218- target_id TEXT NOT NULL,
219- first_shown_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
220- last_shown_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
221- shown_count INTEGER NOT NULL DEFAULT 1,
222- acted BOOLEAN NOT NULL DEFAULT 0,
223- PRIMARY KEY (user_did, target_type, target_id)
224- )`,
225- `CREATE TABLE IF NOT EXISTS follow_distances (
226- user_a TEXT NOT NULL,
227- user_b TEXT NOT NULL,
228- distance INTEGER NOT NULL CHECK(distance IN (1, 2)),
229- PRIMARY KEY (user_a, user_b)
230- )`,
231- `CREATE TABLE IF NOT EXISTS user_signal_weights (
232- user_did TEXT PRIMARY KEY,
233- w_sub REAL NOT NULL DEFAULT 1.0,
234- w_like REAL NOT NULL DEFAULT 0.5,
235- w_tag REAL NOT NULL DEFAULT 0.3,
236- w_social REAL NOT NULL DEFAULT 0.7,
237- w_pop REAL NOT NULL DEFAULT 0.2,
238- w_category REAL NOT NULL DEFAULT 0.4,
239- updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
240- )`,
241- `CREATE TABLE IF NOT EXISTS user_signal_profiles (
242- user_did TEXT PRIMARY KEY,
243- total_likes INTEGER NOT NULL DEFAULT 0,
244- total_tags INTEGER NOT NULL DEFAULT 0,
245- top_categories TEXT,
246- updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
247- )`,
248- `CREATE INDEX IF NOT EXISTS idx_subscriptions_feed ON subscriptions(feed_url)`,
249- `CREATE INDEX IF NOT EXISTS idx_subscriptions_feed_user ON subscriptions(feed_url, user_did)`,
250- `CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_did)`,
251- `CREATE INDEX IF NOT EXISTS idx_subscriptions_uri ON subscriptions(uri)`,
252- `CREATE INDEX IF NOT EXISTS idx_likes_author_feed ON likes(author_did, feed_url, created_at)`,
253- `CREATE INDEX IF NOT EXISTS idx_articles_feed ON articles(feed_url)`,
254- `CREATE INDEX IF NOT EXISTS idx_articles_published ON articles(published DESC)`,
255- `CREATE INDEX IF NOT EXISTS idx_articles_url ON articles(url)`,
256- `CREATE INDEX IF NOT EXISTS idx_read_state_unread ON read_state(user_did, is_read) WHERE is_read = 0`,
257- `CREATE INDEX IF NOT EXISTS idx_annotations_article ON annotations(article_url)`,
258- `CREATE INDEX IF NOT EXISTS idx_annotations_author ON annotations(author_did)`,
259- `CREATE INDEX IF NOT EXISTS idx_annotations_created_at ON annotations(created_at DESC)`,
260- `CREATE INDEX IF NOT EXISTS idx_likes_article ON likes(feed_url, article_url)`,
261- `CREATE INDEX IF NOT EXISTS idx_likes_author ON likes(author_did)`,
262- `CREATE INDEX IF NOT EXISTS idx_likes_created_at ON likes(created_at DESC)`,
263- `CREATE INDEX IF NOT EXISTS idx_follows_user ON follows(user_did)`,
264- `CREATE INDEX IF NOT EXISTS idx_follows_target ON follows(target_did)`,
265- `CREATE INDEX IF NOT EXISTS idx_follows_uri ON follows(uri)`,
266- `CREATE INDEX IF NOT EXISTS idx_user_similarity_b ON user_similarity(user_b)`,
267- `CREATE INDEX IF NOT EXISTS idx_user_similarity_a ON user_similarity(user_a)`,
268- `CREATE INDEX IF NOT EXISTS idx_dismissed_user_type ON dismissed_recommendations(user_did, target_type)`,
269- `CREATE INDEX IF NOT EXISTS idx_impressions_user_unacted ON recommendation_impressions(user_did, acted, shown_count)`,
270- `CREATE INDEX IF NOT EXISTS idx_impressions_last_shown ON recommendation_impressions(last_shown_at)`,
271- `CREATE INDEX IF NOT EXISTS idx_follow_distances_b ON follow_distances(user_b)`,
272- `CREATE INDEX IF NOT EXISTS idx_follow_distances_a_dist ON follow_distances(user_a, distance)`,
273- `CREATE INDEX IF NOT EXISTS idx_follows_followed_at ON follows(followed_at)`,
274- `CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(title, summary, content, author, content=articles, content_rowid=id)`,
275- `CREATE TRIGGER IF NOT EXISTS articles_ai AFTER INSERT ON articles BEGIN
276- INSERT INTO articles_fts(rowid, title, summary, content, author) VALUES (new.id, new.title, new.summary, new.content, new.author);
277- END`,
278- `CREATE TRIGGER IF NOT EXISTS articles_ad AFTER DELETE ON articles BEGIN
279- INSERT INTO articles_fts(articles_fts, rowid, title, summary, content, author) VALUES('delete', old.id, old.title, old.summary, old.content, old.author);
280- END`,
281- `CREATE TRIGGER IF NOT EXISTS articles_au AFTER UPDATE ON articles BEGIN
282- INSERT INTO articles_fts(articles_fts, rowid, title, summary, content, author) VALUES('delete', old.id, old.title, old.summary, old.content, old.author);
283- INSERT INTO articles_fts(rowid, title, summary, content, author) VALUES (new.id, new.title, new.summary, new.content, new.author);
284- END`,
285-}
modified internal/db/feed.go +47 -68
@@ -6,6 +6,8 @@ import (
66 "errors"
77 "strings"
88 "time"
9+
10+ "pkg.rbrt.fr/glean/internal/feed"
911 )
1012
1113 var ErrDuplicateSubscription = errors.New("already subscribed to this feed")
@@ -26,6 +28,19 @@ type Feed struct {
2628 FaviconURL sql.NullString
2729 }
2830
31+func (f *Feed) ToFeed() *feed.Feed {
32+ return &feed.Feed{
33+ URL: f.FeedURL,
34+ Title: f.Title.String,
35+ SiteURL: f.SiteURL.String,
36+ Description: f.Description.String,
37+ Type: f.FeedType.String,
38+ FaviconURL: f.FaviconURL.String,
39+ ETag: f.Etag.String,
40+ LastModified: f.LastModified.String,
41+ }
42+}
43+
2944 type Subscription struct {
3045 ID int64
3146 UserDID string
@@ -39,37 +54,34 @@ type Subscription struct {
3954 FaviconURL sql.NullString
4055 }
4156
42-func (s *ArticleStore) UpsertFeed(ctx context.Context, feed *Feed) error {
43- return s.BatchUpsertFeeds(ctx, []*Feed{feed})
44-}
57+const feedSelectCols = `feed_url, title, site_url, description, feed_type,
58+ last_fetched_at, last_error, subscriber_count, etag, last_modified,
59+ consecutive_empty_fetches, error_count, favicon_url`
4560
46-func (s *ArticleStore) GetFeed(ctx context.Context, feedURL string) (*Feed, error) {
61+func scanFeed(scanner interface{ Scan(...any) error }) (*Feed, error) {
4762 f := &Feed{}
48- err := s.db.QueryRowContext(ctx, `
49- SELECT feed_url, title, site_url, description, feed_type,
50- last_fetched_at, last_error, subscriber_count, etag, last_modified,
51- consecutive_empty_fetches, error_count, favicon_url
52- FROM articles.feeds WHERE feed_url = ?
53- `, feedURL).Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,
63+ if err := scanner.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,
5464 &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified,
55- &f.ConsecutiveEmptyFetches, &f.ErrorCount, &f.FaviconURL)
56- if err != nil {
65+ &f.ConsecutiveEmptyFetches, &f.ErrorCount, &f.FaviconURL); err != nil {
5766 return nil, err
5867 }
5968 return f, nil
6069 }
6170
71+func (s *ArticleStore) UpsertFeed(ctx context.Context, feed *Feed) error {
72+ return s.BatchUpsertFeeds(ctx, []*Feed{feed})
73+}
74+
75+func (s *ArticleStore) GetFeed(ctx context.Context, feedURL string) (*Feed, error) {
76+ row := s.db.QueryRowContext(ctx, `SELECT `+feedSelectCols+` FROM articles.feeds WHERE feed_url = ?`, feedURL)
77+ return scanFeed(row)
78+}
79+
6280 func (s *ArticleStore) GetFeedsToFetch(ctx context.Context, olderThan time.Duration, limit int) ([]*Feed, error) {
6381 cutoff := time.Now().Add(-olderThan)
64- rows, err := s.db.QueryContext(ctx, `
65- SELECT feed_url, title, site_url, description, feed_type,
66- last_fetched_at, last_error, subscriber_count, etag, last_modified,
67- consecutive_empty_fetches, error_count, favicon_url
68- FROM articles.feeds
82+ rows, err := s.db.QueryContext(ctx, `SELECT `+feedSelectCols+` FROM articles.feeds
6983 WHERE subscriber_count > 0 AND error_count < 25 AND (last_fetched_at IS NULL OR last_fetched_at <= ?)
70- ORDER BY last_fetched_at ASC NULLS FIRST
71- LIMIT ?
72- `, cutoff, limit)
84+ ORDER BY last_fetched_at ASC NULLS FIRST LIMIT ?`, cutoff, limit)
7385 if err != nil {
7486 return nil, err
7587 }
@@ -77,10 +89,8 @@ func (s *ArticleStore) GetFeedsToFetch(ctx context.Context, olderThan time.Durat
7789
7890 var feeds []*Feed
7991 for rows.Next() {
80- f := &Feed{}
81- if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,
82- &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified,
83- &f.ConsecutiveEmptyFetches, &f.ErrorCount, &f.FaviconURL); err != nil {
92+ f, err := scanFeed(rows)
93+ if err != nil {
8494 return nil, err
8595 }
8696 feeds = append(feeds, f)
@@ -137,13 +147,6 @@ func (s *ArticleStore) updateSubscriptionURI(ctx context.Context, userDID, feedU
137147 return err
138148 }
139149
140-func uriOrNil(v string) any {
141- if v == "" {
142- return nil
143- }
144- return v
145-}
146-
147150 func nilIfEmpty(v string) any {
148151 if v == "" {
149152 return nil
@@ -306,15 +309,9 @@ func (s *ArticleStore) UpdateFeedFavicon(ctx context.Context, feedURL, faviconUR
306309 }
307310
308311 func (s *ArticleStore) ListDeadFeeds(ctx context.Context, userDID string, threshold int) ([]*Feed, error) {
309- rows, err := s.db.QueryContext(ctx, `
310- SELECT f.feed_url, f.title, f.site_url, f.description, f.feed_type,
311- f.last_fetched_at, f.last_error, f.subscriber_count, f.etag, f.last_modified,
312- f.consecutive_empty_fetches, f.error_count, f.favicon_url
313- FROM articles.feeds f
312+ rows, err := s.db.QueryContext(ctx, `SELECT `+feedSelectCols+` FROM articles.feeds f
314313 JOIN articles.subscriptions s ON s.feed_url = f.feed_url AND s.user_did = ?
315- WHERE f.error_count >= ?
316- ORDER BY f.error_count DESC
317- `, userDID, threshold)
314+ WHERE f.error_count >= ? ORDER BY f.error_count DESC`, userDID, threshold)
318315 if err != nil {
319316 return nil, err
320317 }
@@ -322,10 +319,8 @@ func (s *ArticleStore) ListDeadFeeds(ctx context.Context, userDID string, thresh
322319
323320 var feeds []*Feed
324321 for rows.Next() {
325- f := &Feed{}
326- if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,
327- &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified,
328- &f.ConsecutiveEmptyFetches, &f.ErrorCount, &f.FaviconURL); err != nil {
322+ f, err := scanFeed(rows)
323+ if err != nil {
329324 return nil, err
330325 }
331326 feeds = append(feeds, f)
@@ -334,14 +329,8 @@ func (s *ArticleStore) ListDeadFeeds(ctx context.Context, userDID string, thresh
334329 }
335330
336331 func (s *ArticleStore) ListAllFeeds(ctx context.Context, limit, offset int) ([]*Feed, error) {
337- rows, err := s.db.QueryContext(ctx, `
338- SELECT feed_url, title, site_url, description, feed_type,
339- last_fetched_at, last_error, subscriber_count, etag, last_modified,
340- consecutive_empty_fetches, error_count, favicon_url
341- FROM articles.feeds
342- ORDER BY subscriber_count DESC
343- LIMIT ? OFFSET ?
344- `, limit, offset)
332+ rows, err := s.db.QueryContext(ctx, `SELECT `+feedSelectCols+` FROM articles.feeds
333+ ORDER BY subscriber_count DESC LIMIT ? OFFSET ?`, limit, offset)
345334 if err != nil {
346335 return nil, err
347336 }
@@ -349,10 +338,8 @@ func (s *ArticleStore) ListAllFeeds(ctx context.Context, limit, offset int) ([]*
349338
350339 var feeds []*Feed
351340 for rows.Next() {
352- f := &Feed{}
353- if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,
354- &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified,
355- &f.ConsecutiveEmptyFetches, &f.ErrorCount, &f.FaviconURL); err != nil {
341+ f, err := scanFeed(rows)
342+ if err != nil {
356343 return nil, err
357344 }
358345 feeds = append(feeds, f)
@@ -457,7 +444,7 @@ func (s *ArticleStore) BatchReconcileSubscriptions(ctx context.Context, userDID
457444 }
458445 continue
459446 }
460- result, err := insertStmt.ExecContext(ctx, userDID, sub.FeedURL, nilIfEmpty(sub.Title), sub.Category, uriOrNil(sub.URI), uriOrNil(sub.CID))
447+ result, err := insertStmt.ExecContext(ctx, userDID, sub.FeedURL, nilIfEmpty(sub.Title), sub.Category, nilIfEmpty(sub.URI), nilIfEmpty(sub.CID))
461448 if err != nil {
462449 return err
463450 }
@@ -472,15 +459,9 @@ func (s *ArticleStore) BatchReconcileSubscriptions(ctx context.Context, userDID
472459 }
473460
474461 func (s *ArticleStore) ListUnsubscribedFeeds(ctx context.Context, userDID string, limit, offset int) ([]*Feed, error) {
475- rows, err := s.db.QueryContext(ctx, `
476- SELECT feed_url, title, site_url, description, feed_type,
477- last_fetched_at, last_error, subscriber_count, etag, last_modified,
478- consecutive_empty_fetches, error_count, favicon_url
479- FROM articles.feeds
462+ rows, err := s.db.QueryContext(ctx, `SELECT `+feedSelectCols+` FROM articles.feeds
480463 WHERE feed_url NOT IN (SELECT feed_url FROM articles.subscriptions WHERE user_did = ?)
481- ORDER BY subscriber_count DESC
482- LIMIT ? OFFSET ?
483- `, userDID, limit, offset)
464+ ORDER BY subscriber_count DESC LIMIT ? OFFSET ?`, userDID, limit, offset)
484465 if err != nil {
485466 return nil, err
486467 }
@@ -488,10 +469,8 @@ func (s *ArticleStore) ListUnsubscribedFeeds(ctx context.Context, userDID string
488469
489470 var feeds []*Feed
490471 for rows.Next() {
491- f := &Feed{}
492- if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,
493- &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified,
494- &f.ConsecutiveEmptyFetches, &f.ErrorCount, &f.FaviconURL); err != nil {
472+ f, err := scanFeed(rows)
473+ if err != nil {
495474 return nil, err
496475 }
497476 feeds = append(feeds, f)
@@ -6,6 +6,8 @@ import (
6 "errors"6 "errors"
7 "strings"7 "strings"
8 "time"8 "time"
9+
10+ "pkg.rbrt.fr/glean/internal/feed"
9 )11 )
10 12
11 var ErrDuplicateSubscription = errors.New("already subscribed to this feed")13 var ErrDuplicateSubscription = errors.New("already subscribed to this feed")
@@ -26,6 +28,19 @@ type Feed struct {
26 FaviconURL sql.NullString28 FaviconURL sql.NullString
27 }29 }
28 30
31+func (f *Feed) ToFeed() *feed.Feed {
32+ return &feed.Feed{
33+ URL: f.FeedURL,
34+ Title: f.Title.String,
35+ SiteURL: f.SiteURL.String,
36+ Description: f.Description.String,
37+ Type: f.FeedType.String,
38+ FaviconURL: f.FaviconURL.String,
39+ ETag: f.Etag.String,
40+ LastModified: f.LastModified.String,
41+ }
42+}
43+
29 type Subscription struct {44 type Subscription struct {
30 ID int6445 ID int64
31 UserDID string46 UserDID string
@@ -39,37 +54,34 @@ type Subscription struct {
39 FaviconURL sql.NullString54 FaviconURL sql.NullString
40 }55 }
41 56
42-func (s *ArticleStore) UpsertFeed(ctx context.Context, feed *Feed) error {57+const feedSelectCols = `feed_url, title, site_url, description, feed_type,
43- return s.BatchUpsertFeeds(ctx, []*Feed{feed})58+ last_fetched_at, last_error, subscriber_count, etag, last_modified,
44-}59+ consecutive_empty_fetches, error_count, favicon_url`
45 60
46-func (s *ArticleStore) GetFeed(ctx context.Context, feedURL string) (*Feed, error) {61+func scanFeed(scanner interface{ Scan(...any) error }) (*Feed, error) {
47 f := &Feed{}62 f := &Feed{}
48- err := s.db.QueryRowContext(ctx, `63+ if err := scanner.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,
49- SELECT feed_url, title, site_url, description, feed_type,
50- last_fetched_at, last_error, subscriber_count, etag, last_modified,
51- consecutive_empty_fetches, error_count, favicon_url
52- FROM articles.feeds WHERE feed_url = ?
53- `, feedURL).Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,
54 &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified,64 &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified,
55- &f.ConsecutiveEmptyFetches, &f.ErrorCount, &f.FaviconURL)65+ &f.ConsecutiveEmptyFetches, &f.ErrorCount, &f.FaviconURL); err != nil {
56- if err != nil {
57 return nil, err66 return nil, err
58 }67 }
59 return f, nil68 return f, nil
60 }69 }
61 70
71+func (s *ArticleStore) UpsertFeed(ctx context.Context, feed *Feed) error {
72+ return s.BatchUpsertFeeds(ctx, []*Feed{feed})
73+}
74+
75+func (s *ArticleStore) GetFeed(ctx context.Context, feedURL string) (*Feed, error) {
76+ row := s.db.QueryRowContext(ctx, `SELECT `+feedSelectCols+` FROM articles.feeds WHERE feed_url = ?`, feedURL)
77+ return scanFeed(row)
78+}
79+
62 func (s *ArticleStore) GetFeedsToFetch(ctx context.Context, olderThan time.Duration, limit int) ([]*Feed, error) {80 func (s *ArticleStore) GetFeedsToFetch(ctx context.Context, olderThan time.Duration, limit int) ([]*Feed, error) {
63 cutoff := time.Now().Add(-olderThan)81 cutoff := time.Now().Add(-olderThan)
64- rows, err := s.db.QueryContext(ctx, `82+ rows, err := s.db.QueryContext(ctx, `SELECT `+feedSelectCols+` FROM articles.feeds
65- SELECT feed_url, title, site_url, description, feed_type,
66- last_fetched_at, last_error, subscriber_count, etag, last_modified,
67- consecutive_empty_fetches, error_count, favicon_url
68- FROM articles.feeds
69 WHERE subscriber_count > 0 AND error_count < 25 AND (last_fetched_at IS NULL OR last_fetched_at <= ?)83 WHERE subscriber_count > 0 AND error_count < 25 AND (last_fetched_at IS NULL OR last_fetched_at <= ?)
70- ORDER BY last_fetched_at ASC NULLS FIRST84+ ORDER BY last_fetched_at ASC NULLS FIRST LIMIT ?`, cutoff, limit)
71- LIMIT ?
72- `, cutoff, limit)
73 if err != nil {85 if err != nil {
74 return nil, err86 return nil, err
75 }87 }
@@ -77,10 +89,8 @@ func (s *ArticleStore) GetFeedsToFetch(ctx context.Context, olderThan time.Durat
77 89
78 var feeds []*Feed90 var feeds []*Feed
79 for rows.Next() {91 for rows.Next() {
80- f := &Feed{}92+ f, err := scanFeed(rows)
81- if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,93+ if err != nil {
82- &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified,
83- &f.ConsecutiveEmptyFetches, &f.ErrorCount, &f.FaviconURL); err != nil {
84 return nil, err94 return nil, err
85 }95 }
86 feeds = append(feeds, f)96 feeds = append(feeds, f)
@@ -137,13 +147,6 @@ func (s *ArticleStore) updateSubscriptionURI(ctx context.Context, userDID, feedU
137 return err147 return err
138 }148 }
139 149
140-func uriOrNil(v string) any {
141- if v == "" {
142- return nil
143- }
144- return v
145-}
146-
147 func nilIfEmpty(v string) any {150 func nilIfEmpty(v string) any {
148 if v == "" {151 if v == "" {
149 return nil152 return nil
@@ -306,15 +309,9 @@ func (s *ArticleStore) UpdateFeedFavicon(ctx context.Context, feedURL, faviconUR
306 }309 }
307 310
308 func (s *ArticleStore) ListDeadFeeds(ctx context.Context, userDID string, threshold int) ([]*Feed, error) {311 func (s *ArticleStore) ListDeadFeeds(ctx context.Context, userDID string, threshold int) ([]*Feed, error) {
309- rows, err := s.db.QueryContext(ctx, `312+ rows, err := s.db.QueryContext(ctx, `SELECT `+feedSelectCols+` FROM articles.feeds f
310- SELECT f.feed_url, f.title, f.site_url, f.description, f.feed_type,
311- f.last_fetched_at, f.last_error, f.subscriber_count, f.etag, f.last_modified,
312- f.consecutive_empty_fetches, f.error_count, f.favicon_url
313- FROM articles.feeds f
314 JOIN articles.subscriptions s ON s.feed_url = f.feed_url AND s.user_did = ?313 JOIN articles.subscriptions s ON s.feed_url = f.feed_url AND s.user_did = ?
315- WHERE f.error_count >= ?314+ WHERE f.error_count >= ? ORDER BY f.error_count DESC`, userDID, threshold)
316- ORDER BY f.error_count DESC
317- `, userDID, threshold)
318 if err != nil {315 if err != nil {
319 return nil, err316 return nil, err
320 }317 }
@@ -322,10 +319,8 @@ func (s *ArticleStore) ListDeadFeeds(ctx context.Context, userDID string, thresh
322 319
323 var feeds []*Feed320 var feeds []*Feed
324 for rows.Next() {321 for rows.Next() {
325- f := &Feed{}322+ f, err := scanFeed(rows)
326- if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,323+ if err != nil {
327- &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified,
328- &f.ConsecutiveEmptyFetches, &f.ErrorCount, &f.FaviconURL); err != nil {
329 return nil, err324 return nil, err
330 }325 }
331 feeds = append(feeds, f)326 feeds = append(feeds, f)
@@ -334,14 +329,8 @@ func (s *ArticleStore) ListDeadFeeds(ctx context.Context, userDID string, thresh
334 }329 }
335 330
336 func (s *ArticleStore) ListAllFeeds(ctx context.Context, limit, offset int) ([]*Feed, error) {331 func (s *ArticleStore) ListAllFeeds(ctx context.Context, limit, offset int) ([]*Feed, error) {
337- rows, err := s.db.QueryContext(ctx, `332+ rows, err := s.db.QueryContext(ctx, `SELECT `+feedSelectCols+` FROM articles.feeds
338- SELECT feed_url, title, site_url, description, feed_type,333+ ORDER BY subscriber_count DESC LIMIT ? OFFSET ?`, limit, offset)
339- last_fetched_at, last_error, subscriber_count, etag, last_modified,
340- consecutive_empty_fetches, error_count, favicon_url
341- FROM articles.feeds
342- ORDER BY subscriber_count DESC
343- LIMIT ? OFFSET ?
344- `, limit, offset)
345 if err != nil {334 if err != nil {
346 return nil, err335 return nil, err
347 }336 }
@@ -349,10 +338,8 @@ func (s *ArticleStore) ListAllFeeds(ctx context.Context, limit, offset int) ([]*
349 338
350 var feeds []*Feed339 var feeds []*Feed
351 for rows.Next() {340 for rows.Next() {
352- f := &Feed{}341+ f, err := scanFeed(rows)
353- if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,342+ if err != nil {
354- &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified,
355- &f.ConsecutiveEmptyFetches, &f.ErrorCount, &f.FaviconURL); err != nil {
356 return nil, err343 return nil, err
357 }344 }
358 feeds = append(feeds, f)345 feeds = append(feeds, f)
@@ -457,7 +444,7 @@ func (s *ArticleStore) BatchReconcileSubscriptions(ctx context.Context, userDID
457 }444 }
458 continue445 continue
459 }446 }
460- result, err := insertStmt.ExecContext(ctx, userDID, sub.FeedURL, nilIfEmpty(sub.Title), sub.Category, uriOrNil(sub.URI), uriOrNil(sub.CID))447+ result, err := insertStmt.ExecContext(ctx, userDID, sub.FeedURL, nilIfEmpty(sub.Title), sub.Category, nilIfEmpty(sub.URI), nilIfEmpty(sub.CID))
461 if err != nil {448 if err != nil {
462 return err449 return err
463 }450 }
@@ -472,15 +459,9 @@ func (s *ArticleStore) BatchReconcileSubscriptions(ctx context.Context, userDID
472 }459 }
473 460
474 func (s *ArticleStore) ListUnsubscribedFeeds(ctx context.Context, userDID string, limit, offset int) ([]*Feed, error) {461 func (s *ArticleStore) ListUnsubscribedFeeds(ctx context.Context, userDID string, limit, offset int) ([]*Feed, error) {
475- rows, err := s.db.QueryContext(ctx, `462+ rows, err := s.db.QueryContext(ctx, `SELECT `+feedSelectCols+` FROM articles.feeds
476- SELECT feed_url, title, site_url, description, feed_type,
477- last_fetched_at, last_error, subscriber_count, etag, last_modified,
478- consecutive_empty_fetches, error_count, favicon_url
479- FROM articles.feeds
480 WHERE feed_url NOT IN (SELECT feed_url FROM articles.subscriptions WHERE user_did = ?)463 WHERE feed_url NOT IN (SELECT feed_url FROM articles.subscriptions WHERE user_did = ?)
481- ORDER BY subscriber_count DESC464+ ORDER BY subscriber_count DESC LIMIT ? OFFSET ?`, userDID, limit, offset)
482- LIMIT ? OFFSET ?
483- `, userDID, limit, offset)
484 if err != nil {465 if err != nil {
485 return nil, err466 return nil, err
486 }467 }
@@ -488,10 +469,8 @@ func (s *ArticleStore) ListUnsubscribedFeeds(ctx context.Context, userDID string
488 469
489 var feeds []*Feed470 var feeds []*Feed
490 for rows.Next() {471 for rows.Next() {
491- f := &Feed{}472+ f, err := scanFeed(rows)
492- if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,473+ if err != nil {
493- &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified,
494- &f.ConsecutiveEmptyFetches, &f.ErrorCount, &f.FaviconURL); err != nil {
495 return nil, err474 return nil, err
496 }475 }
497 feeds = append(feeds, f)476 feeds = append(feeds, f)
modified internal/db/follow.go +1 -1
@@ -21,7 +21,7 @@ func (s *UserStore) UpsertFollow(ctx context.Context, userDID, targetDID, uri, c
2121 ON CONFLICT(user_did, target_did) DO UPDATE SET
2222 uri = excluded.uri,
2323 cid = excluded.cid
24- `, userDID, targetDID, uriOrNil(uri), uriOrNil(cid))
24+ `, userDID, targetDID, nilIfEmpty(uri), nilIfEmpty(cid))
2525 return err
2626 }
2727
@@ -21,7 +21,7 @@ func (s *UserStore) UpsertFollow(ctx context.Context, userDID, targetDID, uri, c
21 ON CONFLICT(user_did, target_did) DO UPDATE SET21 ON CONFLICT(user_did, target_did) DO UPDATE SET
22 uri = excluded.uri,22 uri = excluded.uri,
23 cid = excluded.cid23 cid = excluded.cid
24- `, userDID, targetDID, uriOrNil(uri), uriOrNil(cid))24+ `, userDID, targetDID, nilIfEmpty(uri), nilIfEmpty(cid))
25 return err25 return err
26 }26 }
27 27
modified internal/db/store.go +1 -10
@@ -22,16 +22,7 @@ func (a *FeedStoreAdapter) GetFeedsToFetch(ctx context.Context, olderThan time.D
2222 }
2323 var feeds []*feed.Feed
2424 for _, df := range dbFeeds {
25- feeds = append(feeds, &feed.Feed{
26- URL: df.FeedURL,
27- Title: df.Title.String,
28- SiteURL: df.SiteURL.String,
29- Description: df.Description.String,
30- Type: df.FeedType.String,
31- FaviconURL: df.FaviconURL.String,
32- ETag: df.Etag.String,
33- LastModified: df.LastModified.String,
34- })
25+ feeds = append(feeds, df.ToFeed())
3526 }
3627 return feeds, nil
3728 }
@@ -22,16 +22,7 @@ func (a *FeedStoreAdapter) GetFeedsToFetch(ctx context.Context, olderThan time.D
22 }22 }
23 var feeds []*feed.Feed23 var feeds []*feed.Feed
24 for _, df := range dbFeeds {24 for _, df := range dbFeeds {
25- feeds = append(feeds, &feed.Feed{25+ feeds = append(feeds, df.ToFeed())
26- URL: df.FeedURL,
27- Title: df.Title.String,
28- SiteURL: df.SiteURL.String,
29- Description: df.Description.String,
30- Type: df.FeedType.String,
31- FaviconURL: df.FaviconURL.String,
32- ETag: df.Etag.String,
33- LastModified: df.LastModified.String,
34- })
35 }26 }
36 return feeds, nil27 return feeds, nil
37 }28 }
modified internal/feed/discover.go +4 -13
@@ -12,19 +12,10 @@ import (
1212 "pkg.rbrt.fr/glean/internal/httpclient"
1313 )
1414
15-type imageContentTypePrefixes []string
16-
17-func (p imageContentTypePrefixes) matches(contentType string) bool {
18- for _, prefix := range p {
19- if strings.HasPrefix(contentType, prefix) {
20- return true
21- }
22- }
23- return false
15+func isImageContentType(ct string) bool {
16+ return strings.HasPrefix(ct, "image/")
2417 }
2518
26-var imageContentTypes = imageContentTypePrefixes{"image/"}
27-
2819 type DiscoveryResult struct {
2920 FeedURLs []string
3021 Favicon string
@@ -140,7 +131,7 @@ func findFavicon(ctx context.Context, base *url.URL, links []string) string {
140131 return
141132 }
142133 resp.Body.Close()
143- if resp.StatusCode == http.StatusOK && imageContentTypes.matches(resp.Header.Get("Content-Type")) {
134+ if resp.StatusCode == http.StatusOK && isImageContentType(resp.Header.Get("Content-Type")) {
144135 select {
145136 case found <- result{url: cleanFavicon(resolved.String()), found: true}:
146137 default:
@@ -170,7 +161,7 @@ func checkContentType(ctx context.Context, url string) bool {
170161 return false
171162 }
172163 resp.Body.Close()
173- return resp.StatusCode == http.StatusOK && imageContentTypes.matches(resp.Header.Get("Content-Type"))
164+ return resp.StatusCode == http.StatusOK && isImageContentType(resp.Header.Get("Content-Type"))
174165 }
175166
176167 func extractHref(link string) string {
@@ -12,19 +12,10 @@ import (
12 "pkg.rbrt.fr/glean/internal/httpclient"12 "pkg.rbrt.fr/glean/internal/httpclient"
13 )13 )
14 14
15-type imageContentTypePrefixes []string15+func isImageContentType(ct string) bool {
16-16+ return strings.HasPrefix(ct, "image/")
17-func (p imageContentTypePrefixes) matches(contentType string) bool {
18- for _, prefix := range p {
19- if strings.HasPrefix(contentType, prefix) {
20- return true
21- }
22- }
23- return false
24 }17 }
25 18
26-var imageContentTypes = imageContentTypePrefixes{"image/"}
27-
28 type DiscoveryResult struct {19 type DiscoveryResult struct {
29 FeedURLs []string20 FeedURLs []string
30 Favicon string21 Favicon string
@@ -140,7 +131,7 @@ func findFavicon(ctx context.Context, base *url.URL, links []string) string {
140 return131 return
141 }132 }
142 resp.Body.Close()133 resp.Body.Close()
143- if resp.StatusCode == http.StatusOK && imageContentTypes.matches(resp.Header.Get("Content-Type")) {134+ if resp.StatusCode == http.StatusOK && isImageContentType(resp.Header.Get("Content-Type")) {
144 select {135 select {
145 case found <- result{url: cleanFavicon(resolved.String()), found: true}:136 case found <- result{url: cleanFavicon(resolved.String()), found: true}:
146 default:137 default:
@@ -170,7 +161,7 @@ func checkContentType(ctx context.Context, url string) bool {
170 return false161 return false
171 }162 }
172 resp.Body.Close()163 resp.Body.Close()
173- return resp.StatusCode == http.StatusOK && imageContentTypes.matches(resp.Header.Get("Content-Type"))164+ return resp.StatusCode == http.StatusOK && isImageContentType(resp.Header.Get("Content-Type"))
174 }165 }
175 166
176 func extractHref(link string) string {167 func extractHref(link string) string {
modified internal/feed/fetcher.go +4 -1
@@ -203,7 +203,10 @@ func (s *Scheduler) fetchAll(ctx context.Context, olderThan time.Duration) {
203203 func (s *Scheduler) FetchFeed(ctx context.Context, feed *Feed) {
204204 call := &fetchCall{done: make(chan struct{})}
205205 if actual, loaded := s.inFlight.LoadOrStore(feed.URL, call); loaded {
206- <-actual.(*fetchCall).done
206+ select {
207+ case <-actual.(*fetchCall).done:
208+ case <-ctx.Done():
209+ }
207210 return
208211 }
209212 defer func() {
@@ -203,7 +203,10 @@ func (s *Scheduler) fetchAll(ctx context.Context, olderThan time.Duration) {
203 func (s *Scheduler) FetchFeed(ctx context.Context, feed *Feed) {203 func (s *Scheduler) FetchFeed(ctx context.Context, feed *Feed) {
204 call := &fetchCall{done: make(chan struct{})}204 call := &fetchCall{done: make(chan struct{})}
205 if actual, loaded := s.inFlight.LoadOrStore(feed.URL, call); loaded {205 if actual, loaded := s.inFlight.LoadOrStore(feed.URL, call); loaded {
206- <-actual.(*fetchCall).done206+ select {
207+ case <-actual.(*fetchCall).done:
208+ case <-ctx.Done():
209+ }
207 return210 return
208 }211 }
209 defer func() {212 defer func() {
modified internal/httpclient/httpclient.go +1 -1
@@ -26,7 +26,7 @@ var dnsResolver = &net.Resolver{
2626 }
2727
2828 var (
29- transportOnce sync.Once
29+ transportOnce sync.Once
3030 sharedTransport *http.Transport
3131 )
3232
@@ -26,7 +26,7 @@ var dnsResolver = &net.Resolver{
26 }26 }
27 27
28 var (28 var (
29- transportOnce sync.Once29+ transportOnce sync.Once
30 sharedTransport *http.Transport30 sharedTransport *http.Transport
31 )31 )
32 32
modified internal/server/annotations_handler.go +0 -1
@@ -173,7 +173,6 @@ func resolveAnnotationHandles(ctx context.Context, annotations []*db.Annotation)
173173 g, gCtx := errgroup.WithContext(ctx)
174174 g.SetLimit(5)
175175 for _, a := range annotations {
176- a := a
177176 g.Go(func() error {
178177 if a.AuthorDID != "" {
179178 a.AuthorHandle = atproto.ResolveProfile(gCtx, a.AuthorDID).Handle
@@ -173,7 +173,6 @@ func resolveAnnotationHandles(ctx context.Context, annotations []*db.Annotation)
173 g, gCtx := errgroup.WithContext(ctx)173 g, gCtx := errgroup.WithContext(ctx)
174 g.SetLimit(5)174 g.SetLimit(5)
175 for _, a := range annotations {175 for _, a := range annotations {
176- a := a
177 g.Go(func() error {176 g.Go(func() error {
178 if a.AuthorDID != "" {177 if a.AuthorDID != "" {
179 a.AuthorHandle = atproto.ResolveProfile(gCtx, a.AuthorDID).Handle178 a.AuthorHandle = atproto.ResolveProfile(gCtx, a.AuthorDID).Handle
modified internal/server/auth_handler.go +1 -1
@@ -102,7 +102,7 @@ func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
102102 PDSURL: sessData.HostURL,
103103 SessionID: sessData.SessionID,
104104 }
105- encoded, err := encodeSession(sessionData)
105+ encoded, err := encodeSession(s.sessionKey, sessionData)
106106 if err != nil {
107107 s.logger.Error("failed to encode session", "error", err)
108108 s.renderError(w, r, http.StatusInternalServerError, "Session error", "Could not create your session. Please try again.")
@@ -102,7 +102,7 @@ func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
102 PDSURL: sessData.HostURL,102 PDSURL: sessData.HostURL,
103 SessionID: sessData.SessionID,103 SessionID: sessData.SessionID,
104 }104 }
105- encoded, err := encodeSession(sessionData)105+ encoded, err := encodeSession(s.sessionKey, sessionData)
106 if err != nil {106 if err != nil {
107 s.logger.Error("failed to encode session", "error", err)107 s.logger.Error("failed to encode session", "error", err)
108 s.renderError(w, r, http.StatusInternalServerError, "Session error", "Could not create your session. Please try again.")108 s.renderError(w, r, http.StatusInternalServerError, "Session error", "Could not create your session. Please try again.")
modified internal/server/dashboard_handler.go +0 -1
@@ -120,7 +120,6 @@ func resolvePeopleHandles(ctx context.Context, people []*cluster.PersonRecommend
120120 g, gCtx := errgroup.WithContext(ctx)
121121 g.SetLimit(5)
122122 for _, p := range people {
123- p := p
124123 g.Go(func() error {
125124 prof := atproto.ResolveProfile(gCtx, p.DID)
126125 p.Handle = prof.Handle
@@ -120,7 +120,6 @@ func resolvePeopleHandles(ctx context.Context, people []*cluster.PersonRecommend
120 g, gCtx := errgroup.WithContext(ctx)120 g, gCtx := errgroup.WithContext(ctx)
121 g.SetLimit(5)121 g.SetLimit(5)
122 for _, p := range people {122 for _, p := range people {
123- p := p
124 g.Go(func() error {123 g.Go(func() error {
125 prof := atproto.ResolveProfile(gCtx, p.DID)124 prof := atproto.ResolveProfile(gCtx, p.DID)
126 p.Handle = prof.Handle125 p.Handle = prof.Handle
modified internal/server/feeds_handler.go +21 -25
@@ -8,6 +8,8 @@ import (
88 "net/http"
99 "time"
1010
11+ "golang.org/x/sync/errgroup"
12+
1113 "pkg.rbrt.fr/glean/internal/atproto"
1214 "pkg.rbrt.fr/glean/internal/cluster"
1315 "pkg.rbrt.fr/glean/internal/db"
@@ -259,6 +261,8 @@ func (s *Server) handleOPMLUpload(w http.ResponseWriter, r *http.Request) {
259261 feedURLs := feed.ExtractFeedURLs(opml)
260262 var added int
261263 client := s.pdsClientForUser(r)
264+
265+ var favGoroutines []struct{ feedURL, siteURL string }
262266 for _, fu := range feedURLs {
263267 f := &db.Feed{
264268 FeedURL: fu.URL,
@@ -271,13 +275,7 @@ func (s *Server) handleOPMLUpload(w http.ResponseWriter, r *http.Request) {
271275 continue
272276 }
273277
274- go func(feedURL, siteURL string) {
275- if fav := feed.ResolveFavicon(context.Background(), feedURL, siteURL); fav != "" {
276- if err := s.dbs.Articles.UpdateFeedFavicon(context.Background(), feedURL, fav); err != nil {
277- s.logger.Warn("failed to update favicon", "error", err, "feed", feedURL)
278- }
279- }
280- }(fu.URL, fu.SiteURL)
278+ favGoroutines = append(favGoroutines, struct{ feedURL, siteURL string }{fu.URL, fu.SiteURL})
281279
282280 var subURI, subCID string
283281 if client != nil {
@@ -305,6 +303,20 @@ func (s *Server) handleOPMLUpload(w http.ResponseWriter, r *http.Request) {
305303 added++
306304 }
307305
306+ go func() {
307+ g, ctx := errgroup.WithContext(context.Background())
308+ g.SetLimit(5)
309+ for _, fav := range favGoroutines {
310+ g.Go(func() error {
311+ if f := feed.ResolveFavicon(ctx, fav.feedURL, fav.siteURL); f != "" {
312+ _ = s.dbs.Articles.UpdateFeedFavicon(ctx, fav.feedURL, f)
313+ }
314+ return nil
315+ })
316+ }
317+ _ = g.Wait()
318+ }()
319+
308320 w.Header().Set("HX-Redirect", "/feeds")
309321 w.WriteHeader(http.StatusOK)
310322 }
@@ -385,15 +397,7 @@ func (s *Server) refreshUserFeeds(ctx context.Context, userDID string) {
385397 s.logger.Warn("failed to get feed", "error", err, "feed", sub.FeedURL)
386398 continue
387399 }
388- ff := &feed.Feed{
389- URL: f.FeedURL,
390- Title: f.Title.String,
391- SiteURL: f.SiteURL.String,
392- Description: f.Description.String,
393- Type: f.FeedType.String,
394- ETag: f.Etag.String,
395- LastModified: f.LastModified.String,
396- }
400+ ff := f.ToFeed()
397401 s.scheduler.FetchFeed(ctx, ff)
398402 }
399403 }
@@ -411,15 +415,7 @@ func (s *Server) handleRetryFeed(w http.ResponseWriter, r *http.Request) {
411415 return
412416 }
413417
414- ff := &feed.Feed{
415- URL: f.FeedURL,
416- Title: f.Title.String,
417- SiteURL: f.SiteURL.String,
418- Description: f.Description.String,
419- Type: f.FeedType.String,
420- ETag: f.Etag.String,
421- LastModified: f.LastModified.String,
422- }
418+ ff := f.ToFeed()
423419 s.scheduler.FetchFeed(r.Context(), ff)
424420
425421 user := currentUser(r)
@@ -8,6 +8,8 @@ import (
8 "net/http"8 "net/http"
9 "time"9 "time"
10 10
11+ "golang.org/x/sync/errgroup"
12+
11 "pkg.rbrt.fr/glean/internal/atproto"13 "pkg.rbrt.fr/glean/internal/atproto"
12 "pkg.rbrt.fr/glean/internal/cluster"14 "pkg.rbrt.fr/glean/internal/cluster"
13 "pkg.rbrt.fr/glean/internal/db"15 "pkg.rbrt.fr/glean/internal/db"
@@ -259,6 +261,8 @@ func (s *Server) handleOPMLUpload(w http.ResponseWriter, r *http.Request) {
259 feedURLs := feed.ExtractFeedURLs(opml)261 feedURLs := feed.ExtractFeedURLs(opml)
260 var added int262 var added int
261 client := s.pdsClientForUser(r)263 client := s.pdsClientForUser(r)
264+
265+ var favGoroutines []struct{ feedURL, siteURL string }
262 for _, fu := range feedURLs {266 for _, fu := range feedURLs {
263 f := &db.Feed{267 f := &db.Feed{
264 FeedURL: fu.URL,268 FeedURL: fu.URL,
@@ -271,13 +275,7 @@ func (s *Server) handleOPMLUpload(w http.ResponseWriter, r *http.Request) {
271 continue275 continue
272 }276 }
273 277
274- go func(feedURL, siteURL string) {278+ favGoroutines = append(favGoroutines, struct{ feedURL, siteURL string }{fu.URL, fu.SiteURL})
275- if fav := feed.ResolveFavicon(context.Background(), feedURL, siteURL); fav != "" {
276- if err := s.dbs.Articles.UpdateFeedFavicon(context.Background(), feedURL, fav); err != nil {
277- s.logger.Warn("failed to update favicon", "error", err, "feed", feedURL)
278- }
279- }
280- }(fu.URL, fu.SiteURL)
281 279
282 var subURI, subCID string280 var subURI, subCID string
283 if client != nil {281 if client != nil {
@@ -305,6 +303,20 @@ func (s *Server) handleOPMLUpload(w http.ResponseWriter, r *http.Request) {
305 added++303 added++
306 }304 }
307 305
306+ go func() {
307+ g, ctx := errgroup.WithContext(context.Background())
308+ g.SetLimit(5)
309+ for _, fav := range favGoroutines {
310+ g.Go(func() error {
311+ if f := feed.ResolveFavicon(ctx, fav.feedURL, fav.siteURL); f != "" {
312+ _ = s.dbs.Articles.UpdateFeedFavicon(ctx, fav.feedURL, f)
313+ }
314+ return nil
315+ })
316+ }
317+ _ = g.Wait()
318+ }()
319+
308 w.Header().Set("HX-Redirect", "/feeds")320 w.Header().Set("HX-Redirect", "/feeds")
309 w.WriteHeader(http.StatusOK)321 w.WriteHeader(http.StatusOK)
310 }322 }
@@ -385,15 +397,7 @@ func (s *Server) refreshUserFeeds(ctx context.Context, userDID string) {
385 s.logger.Warn("failed to get feed", "error", err, "feed", sub.FeedURL)397 s.logger.Warn("failed to get feed", "error", err, "feed", sub.FeedURL)
386 continue398 continue
387 }399 }
388- ff := &feed.Feed{400+ ff := f.ToFeed()
389- URL: f.FeedURL,
390- Title: f.Title.String,
391- SiteURL: f.SiteURL.String,
392- Description: f.Description.String,
393- Type: f.FeedType.String,
394- ETag: f.Etag.String,
395- LastModified: f.LastModified.String,
396- }
397 s.scheduler.FetchFeed(ctx, ff)401 s.scheduler.FetchFeed(ctx, ff)
398 }402 }
399 }403 }
@@ -411,15 +415,7 @@ func (s *Server) handleRetryFeed(w http.ResponseWriter, r *http.Request) {
411 return415 return
412 }416 }
413 417
414- ff := &feed.Feed{418+ ff := f.ToFeed()
415- URL: f.FeedURL,
416- Title: f.Title.String,
417- SiteURL: f.SiteURL.String,
418- Description: f.Description.String,
419- Type: f.FeedType.String,
420- ETag: f.Etag.String,
421- LastModified: f.LastModified.String,
422- }
423 s.scheduler.FetchFeed(r.Context(), ff)419 s.scheduler.FetchFeed(r.Context(), ff)
424 420
425 user := currentUser(r)421 user := currentUser(r)
modified internal/server/middleware.go +30 -8
@@ -4,10 +4,9 @@ import (
44 "crypto/rand"
55 "encoding/hex"
66 "net/http"
7+ "net/url"
78 "strings"
89 "time"
9-
10- "github.com/go-chi/chi/v5/middleware"
1110 )
1211
1312 func (s *Server) sessionMiddleware(next http.Handler) http.Handler {
@@ -33,7 +32,9 @@ func (s *Server) requireAuth(next http.Handler) http.Handler {
3332
3433 func csrfToken() string {
3534 b := make([]byte, 32)
36- rand.Read(b)
35+ if _, err := rand.Read(b); err != nil {
36+ return ""
37+ }
3738 return hex.EncodeToString(b)
3839 }
3940
@@ -82,15 +83,19 @@ func (s *Server) csrfMiddleware(next http.Handler) http.Handler {
8283 }
8384
8485 func sameOrigin(origin, host string) bool {
85- return strings.HasPrefix(origin, "http://"+host) || strings.HasPrefix(origin, "https://"+host)
86+ u, err := url.Parse(origin)
87+ if err != nil {
88+ return false
89+ }
90+ return u.Host == host
8691 }
8792
8893 func (s *Server) realIPLogger(next http.Handler) http.Handler {
8994 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
9095 start := time.Now()
91- ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
96+ sw := &statusWriter{ResponseWriter: w}
9297
93- next.ServeHTTP(ww, r)
98+ next.ServeHTTP(sw, r)
9499
95100 ip := r.RemoteAddr
96101 if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
@@ -106,9 +111,26 @@ func (s *Server) realIPLogger(next http.Handler) http.Handler {
106111 "method", r.Method,
107112 "url", scheme+"://"+r.Host+r.RequestURI,
108113 "from", ip,
109- "status", ww.Status(),
110- "bytes", ww.BytesWritten(),
114+ "status", sw.status,
115+ "bytes", sw.bytes,
111116 "duration", time.Since(start).Round(time.Microsecond),
112117 )
113118 })
114119 }
120+
121+type statusWriter struct {
122+ http.ResponseWriter
123+ status int
124+ bytes int
125+}
126+
127+func (w *statusWriter) WriteHeader(code int) {
128+ w.status = code
129+ w.ResponseWriter.WriteHeader(code)
130+}
131+
132+func (w *statusWriter) Write(b []byte) (int, error) {
133+ n, err := w.ResponseWriter.Write(b)
134+ w.bytes += n
135+ return n, err
136+}
@@ -4,10 +4,9 @@ import (
4 "crypto/rand"4 "crypto/rand"
5 "encoding/hex"5 "encoding/hex"
6 "net/http"6 "net/http"
7+ "net/url"
7 "strings"8 "strings"
8 "time"9 "time"
9-
10- "github.com/go-chi/chi/v5/middleware"
11 )10 )
12 11
13 func (s *Server) sessionMiddleware(next http.Handler) http.Handler {12 func (s *Server) sessionMiddleware(next http.Handler) http.Handler {
@@ -33,7 +32,9 @@ func (s *Server) requireAuth(next http.Handler) http.Handler {
33 32
34 func csrfToken() string {33 func csrfToken() string {
35 b := make([]byte, 32)34 b := make([]byte, 32)
36- rand.Read(b)35+ if _, err := rand.Read(b); err != nil {
36+ return ""
37+ }
37 return hex.EncodeToString(b)38 return hex.EncodeToString(b)
38 }39 }
39 40
@@ -82,15 +83,19 @@ func (s *Server) csrfMiddleware(next http.Handler) http.Handler {
82 }83 }
83 84
84 func sameOrigin(origin, host string) bool {85 func sameOrigin(origin, host string) bool {
85- return strings.HasPrefix(origin, "http://"+host) || strings.HasPrefix(origin, "https://"+host)86+ u, err := url.Parse(origin)
87+ if err != nil {
88+ return false
89+ }
90+ return u.Host == host
86 }91 }
87 92
88 func (s *Server) realIPLogger(next http.Handler) http.Handler {93 func (s *Server) realIPLogger(next http.Handler) http.Handler {
89 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {94 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
90 start := time.Now()95 start := time.Now()
91- ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)96+ sw := &statusWriter{ResponseWriter: w}
92 97
93- next.ServeHTTP(ww, r)98+ next.ServeHTTP(sw, r)
94 99
95 ip := r.RemoteAddr100 ip := r.RemoteAddr
96 if xff := r.Header.Get("X-Forwarded-For"); xff != "" {101 if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
@@ -106,9 +111,26 @@ func (s *Server) realIPLogger(next http.Handler) http.Handler {
106 "method", r.Method,111 "method", r.Method,
107 "url", scheme+"://"+r.Host+r.RequestURI,112 "url", scheme+"://"+r.Host+r.RequestURI,
108 "from", ip,113 "from", ip,
109- "status", ww.Status(),114+ "status", sw.status,
110- "bytes", ww.BytesWritten(),115+ "bytes", sw.bytes,
111 "duration", time.Since(start).Round(time.Microsecond),116 "duration", time.Since(start).Round(time.Microsecond),
112 )117 )
113 })118 })
114 }119 }
120+
121+type statusWriter struct {
122+ http.ResponseWriter
123+ status int
124+ bytes int
125+}
126+
127+func (w *statusWriter) WriteHeader(code int) {
128+ w.status = code
129+ w.ResponseWriter.WriteHeader(code)
130+}
131+
132+func (w *statusWriter) Write(b []byte) (int, error) {
133+ n, err := w.ResponseWriter.Write(b)
134+ w.bytes += n
135+ return n, err
136+}
modified internal/server/server.go +2 -0
@@ -68,6 +68,7 @@ type Server struct {
6868 scraper *scraper.Scraper
6969 clientID string
7070 callbackURL string
71+ sessionKey []byte
7172 }
7273
7374 func New(dbs *db.Databases, clientID, callbackURL, addr string, scheduler *feed.Scheduler, engine *cluster.Engine, logger *slog.Logger) *Server {
@@ -98,6 +99,7 @@ func New(dbs *db.Databases, clientID, callbackURL, addr string, scheduler *feed.
9899 scraper: scraper.New(logger),
99100 clientID: clientID,
100101 callbackURL: callbackURL,
102+ sessionKey: loadSessionKey(),
101103 }
102104
103105 s.setupMiddleware()
@@ -68,6 +68,7 @@ type Server struct {
68 scraper *scraper.Scraper68 scraper *scraper.Scraper
69 clientID string69 clientID string
70 callbackURL string70 callbackURL string
71+ sessionKey []byte
71 }72 }
72 73
73 func New(dbs *db.Databases, clientID, callbackURL, addr string, scheduler *feed.Scheduler, engine *cluster.Engine, logger *slog.Logger) *Server {74 func New(dbs *db.Databases, clientID, callbackURL, addr string, scheduler *feed.Scheduler, engine *cluster.Engine, logger *slog.Logger) *Server {
@@ -98,6 +99,7 @@ func New(dbs *db.Databases, clientID, callbackURL, addr string, scheduler *feed.
98 scraper: scraper.New(logger),99 scraper: scraper.New(logger),
99 clientID: clientID,100 clientID: clientID,
100 callbackURL: callbackURL,101 callbackURL: callbackURL,
102+ sessionKey: loadSessionKey(),
101 }103 }
102 104
103 s.setupMiddleware()105 s.setupMiddleware()
modified internal/server/session.go +8 -8
@@ -30,7 +30,7 @@ func (s *Server) getUserFromSession(r *http.Request) *db.User {
3030 return nil
3131 }
3232
33- data, err := decodeSession(cookie.Value)
33+ data, err := decodeSession(s.sessionKey, cookie.Value)
3434 if err != nil {
3535 return nil
3636 }
@@ -49,7 +49,7 @@ func (s *Server) getUserFromSession(r *http.Request) *db.User {
4949
5050 func (s *Server) setUserSession(w http.ResponseWriter, user *db.User) {
5151 data := sessionData{DID: user.DID}
52- encoded, err := encodeSession(data)
52+ encoded, err := encodeSession(s.sessionKey, data)
5353 if err != nil {
5454 s.logger.Error("failed to encode session", "error", err)
5555 return
@@ -89,14 +89,14 @@ func (s *Server) getSessionData(r *http.Request) *sessionData {
8989 if err != nil {
9090 return nil
9191 }
92- data, err := decodeSession(cookie.Value)
92+ data, err := decodeSession(s.sessionKey, cookie.Value)
9393 if err != nil {
9494 return nil
9595 }
9696 return data
9797 }
9898
99-func sessionKey() []byte {
99+func loadSessionKey() []byte {
100100 key := os.Getenv("GLEAN_SESSION_KEY")
101101 if key == "" {
102102 key = "default-dev-key-change-in-production"
@@ -104,13 +104,13 @@ func sessionKey() []byte {
104104 return []byte(key)
105105 }
106106
107-func encodeSession(data sessionData) (string, error) {
107+func encodeSession(key []byte, data sessionData) (string, error) {
108108 payload, err := json.Marshal(data)
109109 if err != nil {
110110 return "", err
111111 }
112112
113- mac := hmac.New(sha256.New, sessionKey())
113+ mac := hmac.New(sha256.New, key)
114114 mac.Write(payload)
115115 sig := mac.Sum(nil)
116116
@@ -118,7 +118,7 @@ func encodeSession(data sessionData) (string, error) {
118118 return base64.URLEncoding.EncodeToString(raw), nil
119119 }
120120
121-func decodeSession(encoded string) (*sessionData, error) {
121+func decodeSession(key []byte, encoded string) (*sessionData, error) {
122122 raw, err := base64.URLEncoding.DecodeString(encoded)
123123 if err != nil {
124124 return nil, errInvalidSession
@@ -131,7 +131,7 @@ func decodeSession(encoded string) (*sessionData, error) {
131131 payload := raw[:len(raw)-sha256.Size]
132132 sig := raw[len(raw)-sha256.Size:]
133133
134- mac := hmac.New(sha256.New, sessionKey())
134+ mac := hmac.New(sha256.New, key)
135135 mac.Write(payload)
136136 expectedSig := mac.Sum(nil)
137137
@@ -30,7 +30,7 @@ func (s *Server) getUserFromSession(r *http.Request) *db.User {
30 return nil30 return nil
31 }31 }
32 32
33- data, err := decodeSession(cookie.Value)33+ data, err := decodeSession(s.sessionKey, cookie.Value)
34 if err != nil {34 if err != nil {
35 return nil35 return nil
36 }36 }
@@ -49,7 +49,7 @@ func (s *Server) getUserFromSession(r *http.Request) *db.User {
49 49
50 func (s *Server) setUserSession(w http.ResponseWriter, user *db.User) {50 func (s *Server) setUserSession(w http.ResponseWriter, user *db.User) {
51 data := sessionData{DID: user.DID}51 data := sessionData{DID: user.DID}
52- encoded, err := encodeSession(data)52+ encoded, err := encodeSession(s.sessionKey, data)
53 if err != nil {53 if err != nil {
54 s.logger.Error("failed to encode session", "error", err)54 s.logger.Error("failed to encode session", "error", err)
55 return55 return
@@ -89,14 +89,14 @@ func (s *Server) getSessionData(r *http.Request) *sessionData {
89 if err != nil {89 if err != nil {
90 return nil90 return nil
91 }91 }
92- data, err := decodeSession(cookie.Value)92+ data, err := decodeSession(s.sessionKey, cookie.Value)
93 if err != nil {93 if err != nil {
94 return nil94 return nil
95 }95 }
96 return data96 return data
97 }97 }
98 98
99-func sessionKey() []byte {99+func loadSessionKey() []byte {
100 key := os.Getenv("GLEAN_SESSION_KEY")100 key := os.Getenv("GLEAN_SESSION_KEY")
101 if key == "" {101 if key == "" {
102 key = "default-dev-key-change-in-production"102 key = "default-dev-key-change-in-production"
@@ -104,13 +104,13 @@ func sessionKey() []byte {
104 return []byte(key)104 return []byte(key)
105 }105 }
106 106
107-func encodeSession(data sessionData) (string, error) {107+func encodeSession(key []byte, data sessionData) (string, error) {
108 payload, err := json.Marshal(data)108 payload, err := json.Marshal(data)
109 if err != nil {109 if err != nil {
110 return "", err110 return "", err
111 }111 }
112 112
113- mac := hmac.New(sha256.New, sessionKey())113+ mac := hmac.New(sha256.New, key)
114 mac.Write(payload)114 mac.Write(payload)
115 sig := mac.Sum(nil)115 sig := mac.Sum(nil)
116 116
@@ -118,7 +118,7 @@ func encodeSession(data sessionData) (string, error) {
118 return base64.URLEncoding.EncodeToString(raw), nil118 return base64.URLEncoding.EncodeToString(raw), nil
119 }119 }
120 120
121-func decodeSession(encoded string) (*sessionData, error) {121+func decodeSession(key []byte, encoded string) (*sessionData, error) {
122 raw, err := base64.URLEncoding.DecodeString(encoded)122 raw, err := base64.URLEncoding.DecodeString(encoded)
123 if err != nil {123 if err != nil {
124 return nil, errInvalidSession124 return nil, errInvalidSession
@@ -131,7 +131,7 @@ func decodeSession(encoded string) (*sessionData, error) {
131 payload := raw[:len(raw)-sha256.Size]131 payload := raw[:len(raw)-sha256.Size]
132 sig := raw[len(raw)-sha256.Size:]132 sig := raw[len(raw)-sha256.Size:]
133 133
134- mac := hmac.New(sha256.New, sessionKey())134+ mac := hmac.New(sha256.New, key)
135 mac.Write(payload)135 mac.Write(payload)
136 expectedSig := mac.Sum(nil)136 expectedSig := mac.Sum(nil)
137 137