A social RSS reader built on the AT Protocol. glean.at
glean atproto atmosphere rss feed social app
14
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement article recommendations and feed management features

Add a Jaccard similarity-based algorithm to generate personalized
article recommendations for users. Introduce favicon support in the
feeds table and database layer, including async fetching via ATProto
discovery. Add endpoints to handle dead feed detection, subscription
management updates, and user-configurable feed fetch intervals.

+973 -138
+2
go.mod
··· 72 72 github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect 73 73 github.com/gobwas/glob v0.2.3 // indirect 74 74 github.com/gofrs/flock v0.12.1 // indirect 75 + github.com/golang-jwt/jwt/v5 v5.2.2 // indirect 75 76 github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect 76 77 github.com/golangci/go-printf-func-name v0.1.0 // indirect 77 78 github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect ··· 81 82 github.com/golangci/revgrep v0.8.0 // indirect 82 83 github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed // indirect 83 84 github.com/google/go-cmp v0.7.0 // indirect 85 + github.com/google/go-querystring v1.1.0 // indirect 84 86 github.com/google/uuid v1.6.0 // indirect 85 87 github.com/gordonklaus/ineffassign v0.1.0 // indirect 86 88 github.com/gostaticanalysis/analysisutil v0.7.1 // indirect
+5
go.sum
··· 143 143 github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= 144 144 github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= 145 145 github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= 146 + github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= 147 + github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= 148 + github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 146 149 github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 h1:WUvBfQL6EW/40l6OmeSBYQJNSif4O11+bmWEz+C7FYw= 147 150 github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32/go.mod h1:NUw9Zr2Sy7+HxzdjIULge71wI6yEg1lWQr7Evcu8K0E= 148 151 github.com/golangci/go-printf-func-name v0.1.0 h1:dVokQP+NMTO7jwO4bwsRwLWeudOVUPPyAKJuzv8pEJU= ··· 166 169 github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 167 170 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 168 171 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 172 + github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 173 + github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 169 174 github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= 170 175 github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 171 176 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
-13
internal/atproto/auth.go
··· 70 70 71 71 return pds, nil 72 72 } 73 - 74 - type OAuthConfig struct { 75 - ClientID string 76 - RedirectURL string 77 - Scopes []string 78 - } 79 - 80 - type OAuthTokens struct { 81 - AccessToken string 82 - RefreshToken string 83 - DID string 84 - Handle string 85 - }
+29
internal/atproto/client.go
··· 7 7 "fmt" 8 8 "net/http" 9 9 10 + "github.com/bluesky-social/indigo/atproto/atclient" 10 11 "github.com/bluesky-social/indigo/atproto/syntax" 11 12 ) 12 13 ··· 14 15 httpClient *http.Client 15 16 pdsURL string 16 17 accessToken string 18 + APIClient *atclient.APIClient 17 19 } 18 20 19 21 func NewClient(pdsURL, accessToken string) *Client { ··· 25 27 } 26 28 27 29 func (c *Client) CreateRecord(ctx context.Context, did, collection string, record any) (string, string, error) { 30 + if c.APIClient != nil { 31 + return c.createRecordWithAPI(ctx, did, collection, record) 32 + } 33 + 28 34 nsid, err := syntax.ParseNSID(collection) 29 35 if err != nil { 30 36 return "", "", fmt.Errorf("parsing collection NSID: %w", err) ··· 68 74 } 69 75 70 76 return result.URI, result.CID, nil 77 + } 78 + 79 + func (c *Client) createRecordWithAPI(ctx context.Context, did, collection string, record any) (string, string, error) { 80 + input := map[string]any{ 81 + "repo": did, 82 + "collection": collection, 83 + "record": record, 84 + } 85 + 86 + var out struct { 87 + URI string `json:"uri"` 88 + CID string `json:"cid"` 89 + } 90 + 91 + nsid, err := syntax.ParseNSID(collection) 92 + if err != nil { 93 + return "", "", fmt.Errorf("parsing collection NSID: %w", err) 94 + } 95 + 96 + if err := c.APIClient.Post(ctx, nsid, input, &out); err != nil { 97 + return "", "", err 98 + } 99 + return out.URI, out.CID, nil 71 100 } 72 101 73 102 func (c *Client) DeleteRecord(ctx context.Context, did, collection, rkey string) error {
+50 -1
internal/cluster/jaccard.go
··· 11 11 logger *slog.Logger 12 12 } 13 13 14 + func (e *Engine) ComputeArticleRecommendations(ctx context.Context) error { 15 + tx, err := e.db.BeginTx(ctx, nil) 16 + if err != nil { 17 + return err 18 + } 19 + defer func() { _ = tx.Rollback() }() 20 + 21 + if _, err := tx.ExecContext(ctx, `DELETE FROM user_article_recommendations`); err != nil { 22 + return err 23 + } 24 + 25 + _, err = tx.ExecContext(ctx, ` 26 + INSERT INTO user_article_recommendations (user_did, feed_url, article_url, score) 27 + SELECT target, l.feed_url, l.article_url, SUM(us.jaccard) AS score 28 + FROM ( 29 + SELECT us.user_a AS target, s.user_did AS peer 30 + FROM user_similarity us 31 + WHERE us.jaccard > 0.2 32 + UNION ALL 33 + SELECT us.user_b AS target, s.user_did AS peer 34 + FROM user_similarity us 35 + WHERE us.jaccard > 0.2 36 + ) targets 37 + JOIN likes l ON l.author_did = targets.peer 38 + WHERE l.article_url NOT IN ( 39 + SELECT a.url FROM articles a 40 + JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = targets.target 41 + LEFT JOIN read_state r ON r.user_did = targets.target AND r.article_id = a.id 42 + ) 43 + AND NOT EXISTS ( 44 + SELECT 1 FROM likes ul WHERE ul.author_did = targets.target AND ul.feed_url = l.feed_url AND ul.article_url = l.article_url 45 + ) 46 + GROUP BY targets.target, l.feed_url, l.article_url 47 + HAVING COUNT(*) > 0 48 + ORDER BY score DESC 49 + `) 50 + if err != nil { 51 + return err 52 + } 53 + 54 + e.logger.Info("article recommendations computed") 55 + return tx.Commit() 56 + } 57 + 14 58 func NewEngine(db *sql.DB, logger *slog.Logger) *Engine { 15 59 return &Engine{db: db, logger: logger} 16 60 } ··· 119 163 } 120 164 121 165 e.logger.Info("feed recommendations computed") 122 - return tx.Commit() 166 + 167 + if err := tx.Commit(); err != nil { 168 + return err 169 + } 170 + 171 + return e.ComputeArticleRecommendations(ctx) 123 172 }
+42 -2
internal/db/cluster.go
··· 280 280 rows, err := db.QueryContext(ctx, ` 281 281 SELECT f.feed_url, f.title, f.site_url, f.description, f.feed_type, 282 282 f.last_fetched_at, f.last_error, f.subscriber_count, f.etag, f.last_modified, 283 - f.fetch_interval_minutes, f.next_fetch_at, f.consecutive_empty_fetches, f.error_count 283 + f.fetch_interval_minutes, f.next_fetch_at, f.consecutive_empty_fetches, f.error_count, f.favicon_url 284 284 FROM feed_similarity fs 285 285 JOIN feeds f ON f.feed_url = CASE WHEN fs.feed_a = ? THEN fs.feed_b ELSE fs.feed_a END 286 286 WHERE fs.feed_a = ? OR fs.feed_b = ? ··· 297 297 f := &Feed{} 298 298 if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType, 299 299 &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified, 300 - &f.FetchIntervalMinutes, &f.NextFetchAt, &f.ConsecutiveEmptyFetches, &f.ErrorCount); err != nil { 300 + &f.FetchIntervalMinutes, &f.NextFetchAt, &f.ConsecutiveEmptyFetches, &f.ErrorCount, &f.FaviconURL); err != nil { 301 301 return nil, err 302 302 } 303 303 feeds = append(feeds, f) 304 304 } 305 305 return feeds, rows.Err() 306 306 } 307 + 308 + type ArticleRecommendation struct { 309 + ArticleID int64 310 + Title string 311 + URL string 312 + FeedURL string 313 + FeedTitle string 314 + Author string 315 + Summary string 316 + Published sql.NullTime 317 + Score float64 318 + } 319 + 320 + func (db *DB) GetArticleRecommendations(ctx context.Context, userDID string, limit int) ([]*ArticleRecommendation, error) { 321 + rows, err := db.QueryContext(ctx, ` 322 + SELECT a.id, a.title, COALESCE(a.url, ''), r.feed_url, COALESCE(f.title, ''), 323 + COALESCE(a.author, ''), COALESCE(a.summary, ''), a.published, r.score 324 + FROM user_article_recommendations r 325 + JOIN articles a ON a.feed_url = r.feed_url AND a.url = r.article_url 326 + LEFT JOIN feeds f ON f.feed_url = r.feed_url 327 + WHERE r.user_did = ? 328 + ORDER BY r.score DESC 329 + LIMIT ? 330 + `, userDID, limit) 331 + if err != nil { 332 + return nil, err 333 + } 334 + defer rows.Close() 335 + 336 + var recs []*ArticleRecommendation 337 + for rows.Next() { 338 + rec := &ArticleRecommendation{} 339 + if err := rows.Scan(&rec.ArticleID, &rec.Title, &rec.URL, &rec.FeedURL, &rec.FeedTitle, 340 + &rec.Author, &rec.Summary, &rec.Published, &rec.Score); err != nil { 341 + return nil, err 342 + } 343 + recs = append(recs, rec) 344 + } 345 + return recs, rows.Err() 346 + }
+9 -1
internal/db/db.go
··· 55 55 fetch_interval_minutes INTEGER NOT NULL DEFAULT 30, 56 56 next_fetch_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 57 57 consecutive_empty_fetches INTEGER NOT NULL DEFAULT 0, 58 - error_count INTEGER NOT NULL DEFAULT 0 58 + error_count INTEGER NOT NULL DEFAULT 0, 59 + favicon_url TEXT 59 60 )`, 60 61 `CREATE TABLE IF NOT EXISTS subscriptions ( 61 62 id INTEGER PRIMARY KEY AUTOINCREMENT, ··· 158 159 if _, err := tx.Exec(s); err != nil { 159 160 return err 160 161 } 162 + } 163 + 164 + migrations := []string{ 165 + `ALTER TABLE feeds ADD COLUMN favicon_url TEXT`, 166 + } 167 + for _, m := range migrations { 168 + tx.Exec(m) 161 169 } 162 170 163 171 return tx.Commit()
+90 -15
internal/db/feed.go
··· 22 22 NextFetchAt sql.NullTime 23 23 ConsecutiveEmptyFetches int 24 24 ErrorCount int 25 + FaviconURL sql.NullString 25 26 } 26 27 27 28 type Subscription struct { 28 - ID int64 29 - UserDID string 30 - FeedURL string 31 - FeedTitle string 32 - Category sql.NullString 33 - AddedAt sql.NullTime 34 - UnreadCount int 29 + ID int64 30 + UserDID string 31 + FeedURL string 32 + FeedTitle string 33 + Category sql.NullString 34 + AddedAt sql.NullTime 35 + UnreadCount int 36 + FetchInterval int 35 37 } 36 38 37 39 func (db *DB) UpsertFeed(ctx context.Context, feed *Feed) error { ··· 52 54 err := db.QueryRowContext(ctx, ` 53 55 SELECT feed_url, title, site_url, description, feed_type, 54 56 last_fetched_at, last_error, subscriber_count, etag, last_modified, 55 - fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count 57 + fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count, favicon_url 56 58 FROM feeds WHERE feed_url = ? 57 59 `, feedURL).Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType, 58 60 &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified, 59 - &f.FetchIntervalMinutes, &f.NextFetchAt, &f.ConsecutiveEmptyFetches, &f.ErrorCount) 61 + &f.FetchIntervalMinutes, &f.NextFetchAt, &f.ConsecutiveEmptyFetches, &f.ErrorCount, &f.FaviconURL) 60 62 if err != nil { 61 63 return nil, err 62 64 } ··· 67 69 rows, err := db.QueryContext(ctx, ` 68 70 SELECT feed_url, title, site_url, description, feed_type, 69 71 last_fetched_at, last_error, subscriber_count, etag, last_modified, 70 - fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count 72 + fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count, favicon_url 71 73 FROM feeds 72 74 WHERE next_fetch_at <= CURRENT_TIMESTAMP 73 75 ORDER BY next_fetch_at ··· 83 85 f := &Feed{} 84 86 if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType, 85 87 &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified, 86 - &f.FetchIntervalMinutes, &f.NextFetchAt, &f.ConsecutiveEmptyFetches, &f.ErrorCount); err != nil { 88 + &f.FetchIntervalMinutes, &f.NextFetchAt, &f.ConsecutiveEmptyFetches, &f.ErrorCount, &f.FaviconURL); err != nil { 87 89 return nil, err 88 90 } 89 91 feeds = append(feeds, f) ··· 144 146 } 145 147 146 148 func (db *DB) ListSubscriptions(ctx context.Context, userDID, category string, limit, offset int) ([]*Subscription, error) { 147 - query := `SELECT s.id, s.user_did, s.feed_url, COALESCE(f.title, ''), s.category, s.added_at 149 + query := `SELECT s.id, s.user_did, s.feed_url, COALESCE(f.title, ''), s.category, s.added_at, 150 + COALESCE(f.fetch_interval_minutes, 30) 148 151 FROM subscriptions s 149 152 LEFT JOIN feeds f ON s.feed_url = f.feed_url 150 153 WHERE s.user_did = ?` ··· 166 169 var subs []*Subscription 167 170 for rows.Next() { 168 171 s := &Subscription{} 169 - if err := rows.Scan(&s.ID, &s.UserDID, &s.FeedURL, &s.FeedTitle, &s.Category, &s.AddedAt); err != nil { 172 + if err := rows.Scan(&s.ID, &s.UserDID, &s.FeedURL, &s.FeedTitle, &s.Category, &s.AddedAt, &s.FetchInterval); err != nil { 170 173 return nil, err 171 174 } 172 175 subs = append(subs, s) ··· 182 185 return count, err 183 186 } 184 187 188 + func (db *DB) UpdateFeedFavicon(ctx context.Context, feedURL, faviconURL string) error { 189 + _, err := db.ExecContext(ctx, `UPDATE feeds SET favicon_url = ? WHERE feed_url = ?`, faviconURL, feedURL) 190 + return err 191 + } 192 + 193 + func (db *DB) UpdateFeedInterval(ctx context.Context, feedURL string, intervalMinutes int) error { 194 + nextFetch := time.Now().Add(time.Duration(intervalMinutes) * time.Minute) 195 + _, err := db.ExecContext(ctx, ` 196 + UPDATE feeds SET 197 + fetch_interval_minutes = ?, 198 + next_fetch_at = ? 199 + WHERE feed_url = ? 200 + `, intervalMinutes, nextFetch, feedURL) 201 + return err 202 + } 203 + 204 + func (db *DB) ListDeadFeeds(ctx context.Context, userDID string, threshold int) ([]*Feed, error) { 205 + rows, err := db.QueryContext(ctx, ` 206 + SELECT f.feed_url, f.title, f.site_url, f.description, f.feed_type, 207 + f.last_fetched_at, f.last_error, f.subscriber_count, f.etag, f.last_modified, 208 + f.fetch_interval_minutes, f.next_fetch_at, f.consecutive_empty_fetches, f.error_count, f.favicon_url 209 + FROM feeds f 210 + JOIN subscriptions s ON s.feed_url = f.feed_url AND s.user_did = ? 211 + WHERE f.error_count >= ? 212 + ORDER BY f.error_count DESC 213 + `, userDID, threshold) 214 + if err != nil { 215 + return nil, err 216 + } 217 + defer rows.Close() 218 + 219 + var feeds []*Feed 220 + for rows.Next() { 221 + f := &Feed{} 222 + if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType, 223 + &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified, 224 + &f.FetchIntervalMinutes, &f.NextFetchAt, &f.ConsecutiveEmptyFetches, &f.ErrorCount, &f.FaviconURL); err != nil { 225 + return nil, err 226 + } 227 + feeds = append(feeds, f) 228 + } 229 + return feeds, rows.Err() 230 + } 231 + 185 232 func (db *DB) ListAllFeeds(ctx context.Context, limit, offset int) ([]*Feed, error) { 186 233 rows, err := db.QueryContext(ctx, fmt.Sprintf(` 187 234 SELECT feed_url, title, site_url, description, feed_type, 188 235 last_fetched_at, last_error, subscriber_count, etag, last_modified, 189 - fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count 236 + fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count, favicon_url 190 237 FROM feeds 191 238 ORDER BY subscriber_count DESC 192 239 LIMIT %d OFFSET %d ··· 201 248 f := &Feed{} 202 249 if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType, 203 250 &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified, 204 - &f.FetchIntervalMinutes, &f.NextFetchAt, &f.ConsecutiveEmptyFetches, &f.ErrorCount); err != nil { 251 + &f.FetchIntervalMinutes, &f.NextFetchAt, &f.ConsecutiveEmptyFetches, &f.ErrorCount, &f.FaviconURL); err != nil { 252 + return nil, err 253 + } 254 + feeds = append(feeds, f) 255 + } 256 + return feeds, rows.Err() 257 + } 258 + 259 + func (db *DB) ListUnsubscribedFeeds(ctx context.Context, userDID string, limit, offset int) ([]*Feed, error) { 260 + rows, err := db.QueryContext(ctx, fmt.Sprintf(` 261 + SELECT feed_url, title, site_url, description, feed_type, 262 + last_fetched_at, last_error, subscriber_count, etag, last_modified, 263 + fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count, favicon_url 264 + FROM feeds 265 + WHERE feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = ?) 266 + ORDER BY subscriber_count DESC 267 + LIMIT %d OFFSET %d 268 + `, limit, offset), userDID) 269 + if err != nil { 270 + return nil, err 271 + } 272 + defer rows.Close() 273 + 274 + var feeds []*Feed 275 + for rows.Next() { 276 + f := &Feed{} 277 + if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType, 278 + &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified, 279 + &f.FetchIntervalMinutes, &f.NextFetchAt, &f.ConsecutiveEmptyFetches, &f.ErrorCount, &f.FaviconURL); err != nil { 205 280 return nil, err 206 281 } 207 282 feeds = append(feeds, f)
+114
internal/db/oauth_store.go
··· 1 + package db 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "encoding/json" 7 + "fmt" 8 + 9 + oauth "github.com/bluesky-social/indigo/atproto/auth/oauth" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + ) 12 + 13 + type OAuthStore struct { 14 + db *DB 15 + } 16 + 17 + func NewOAuthStore(db *DB) *OAuthStore { 18 + return &OAuthStore{db: db} 19 + } 20 + 21 + func (s *OAuthStore) Init(ctx context.Context) error { 22 + stmts := []string{ 23 + `CREATE TABLE IF NOT EXISTS oauth_auth_requests ( 24 + state TEXT PRIMARY KEY, 25 + data TEXT NOT NULL 26 + )`, 27 + `CREATE TABLE IF NOT EXISTS oauth_sessions ( 28 + account_did TEXT NOT NULL, 29 + session_id TEXT NOT NULL, 30 + data TEXT NOT NULL, 31 + PRIMARY KEY (account_did, session_id) 32 + )`, 33 + } 34 + for _, stmt := range stmts { 35 + if _, err := s.db.ExecContext(ctx, stmt); err != nil { 36 + return err 37 + } 38 + } 39 + return nil 40 + } 41 + 42 + func (s *OAuthStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { 43 + var data []byte 44 + err := s.db.QueryRowContext(ctx, ` 45 + SELECT data FROM oauth_sessions WHERE account_did = ? AND session_id = ? 46 + `, did.String(), sessionID).Scan(&data) 47 + if err == sql.ErrNoRows { 48 + return nil, fmt.Errorf("session not found: %s/%s", did, sessionID) 49 + } 50 + if err != nil { 51 + return nil, err 52 + } 53 + var sess oauth.ClientSessionData 54 + if err := json.Unmarshal(data, &sess); err != nil { 55 + return nil, err 56 + } 57 + return &sess, nil 58 + } 59 + 60 + func (s *OAuthStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error { 61 + data, err := json.Marshal(sess) 62 + if err != nil { 63 + return err 64 + } 65 + _, err = s.db.ExecContext(ctx, ` 66 + INSERT INTO oauth_sessions (account_did, session_id, data) 67 + VALUES (?, ?, ?) 68 + ON CONFLICT(account_did, session_id) DO UPDATE SET data = excluded.data 69 + `, sess.AccountDID.String(), sess.SessionID, data) 70 + return err 71 + } 72 + 73 + func (s *OAuthStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { 74 + _, err := s.db.ExecContext(ctx, ` 75 + DELETE FROM oauth_sessions WHERE account_did = ? AND session_id = ? 76 + `, did.String(), sessionID) 77 + return err 78 + } 79 + 80 + func (s *OAuthStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) { 81 + var data []byte 82 + err := s.db.QueryRowContext(ctx, ` 83 + SELECT data FROM oauth_auth_requests WHERE state = ? 84 + `, state).Scan(&data) 85 + if err == sql.ErrNoRows { 86 + return nil, fmt.Errorf("auth request not found: %s", state) 87 + } 88 + if err != nil { 89 + return nil, err 90 + } 91 + var info oauth.AuthRequestData 92 + if err := json.Unmarshal(data, &info); err != nil { 93 + return nil, err 94 + } 95 + return &info, nil 96 + } 97 + 98 + func (s *OAuthStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error { 99 + data, err := json.Marshal(info) 100 + if err != nil { 101 + return err 102 + } 103 + _, err = s.db.ExecContext(ctx, ` 104 + INSERT INTO oauth_auth_requests (state, data) VALUES (?, ?) 105 + `, info.State, data) 106 + return err 107 + } 108 + 109 + func (s *OAuthStore) DeleteAuthRequestInfo(ctx context.Context, state string) error { 110 + _, err := s.db.ExecContext(ctx, ` 111 + DELETE FROM oauth_auth_requests WHERE state = ? 112 + `, state) 113 + return err 114 + }
+198
internal/feed/discover.go
··· 1 + package feed 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "io" 7 + "net/http" 8 + "regexp" 9 + "strings" 10 + "time" 11 + ) 12 + 13 + type DiscoveryResult struct { 14 + FeedURLs []string 15 + Favicon string 16 + } 17 + 18 + var ( 19 + linkRe = regexp.MustCompile(`<link[^>]+>`) 20 + hrefRe = regexp.MustCompile(`href="([^"]*)"`) 21 + relFeedRe = regexp.MustCompile(`rel="(alternate|feed)"`) 22 + typeFeedRe = regexp.MustCompile(`type="([^"]*(?:rss|atom|feed|xml)[^"]*)"`) 23 + relIconRe = regexp.MustCompile(`rel="[^"]*icon[^"]*"`) 24 + faviconPaths = []string{"/favicon.ico", "/favicon.png", "/apple-touch-icon.png"} 25 + ) 26 + 27 + func Discover(ctx context.Context, siteURL string) (*DiscoveryResult, error) { 28 + client := &http.Client{Timeout: 15 * time.Second} 29 + 30 + result := &DiscoveryResult{} 31 + 32 + favicon := discoverFavicon(ctx, client, siteURL) 33 + if favicon != "" { 34 + result.Favicon = favicon 35 + } 36 + 37 + feeds := discoverFeedLinks(ctx, client, siteURL) 38 + result.FeedURLs = feeds 39 + 40 + return result, nil 41 + } 42 + 43 + func discoverFeedLinks(ctx context.Context, client *http.Client, siteURL string) []string { 44 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, siteURL, nil) 45 + if err != nil { 46 + return nil 47 + } 48 + req.Header.Set("Accept", "text/html") 49 + 50 + resp, err := client.Do(req) 51 + if err != nil { 52 + return nil 53 + } 54 + defer resp.Body.Close() 55 + 56 + if resp.StatusCode != http.StatusOK { 57 + return nil 58 + } 59 + 60 + body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*512)) 61 + if err != nil { 62 + return nil 63 + } 64 + 65 + html := string(body) 66 + baseURL := resolveBaseURL(siteURL, html) 67 + 68 + var feeds []string 69 + links := linkRe.FindAllString(html, -1) 70 + for _, link := range links { 71 + if !relFeedRe.MatchString(link) && !typeFeedRe.MatchString(link) { 72 + continue 73 + } 74 + 75 + hrefMatch := hrefRe.FindStringSubmatch(link) 76 + if len(hrefMatch) < 2 { 77 + continue 78 + } 79 + 80 + feedURL := resolveURL(baseURL, hrefMatch[1]) 81 + if feedURL != "" { 82 + feeds = append(feeds, feedURL) 83 + } 84 + } 85 + 86 + return feeds 87 + } 88 + 89 + func discoverFavicon(ctx context.Context, client *http.Client, siteURL string) string { 90 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, siteURL, nil) 91 + if err != nil { 92 + return tryDefaultFavicons(ctx, client, siteURL) 93 + } 94 + req.Header.Set("Accept", "text/html") 95 + 96 + resp, err := client.Do(req) 97 + if err != nil { 98 + return tryDefaultFavicons(ctx, client, siteURL) 99 + } 100 + defer resp.Body.Close() 101 + 102 + if resp.StatusCode != http.StatusOK { 103 + return tryDefaultFavicons(ctx, client, siteURL) 104 + } 105 + 106 + body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*512)) 107 + if err != nil { 108 + return tryDefaultFavicons(ctx, client, siteURL) 109 + } 110 + 111 + html := string(body) 112 + baseURL := resolveBaseURL(siteURL, html) 113 + 114 + links := linkRe.FindAllString(html, -1) 115 + for _, link := range links { 116 + if !relIconRe.MatchString(link) { 117 + continue 118 + } 119 + 120 + hrefMatch := hrefRe.FindStringSubmatch(link) 121 + if len(hrefMatch) < 2 { 122 + continue 123 + } 124 + 125 + iconURL := resolveURL(baseURL, hrefMatch[1]) 126 + if iconURL != "" { 127 + return iconURL 128 + } 129 + } 130 + 131 + return tryDefaultFavicons(ctx, client, siteURL) 132 + } 133 + 134 + func tryDefaultFavicons(ctx context.Context, client *http.Client, siteURL string) string { 135 + parsed := siteURL 136 + if !strings.HasPrefix(parsed, "http") { 137 + parsed = "https://" + parsed 138 + } 139 + 140 + base := parsed 141 + idx := strings.Index(base[8:], "/") 142 + if idx >= 0 { 143 + base = base[:8+idx] 144 + } 145 + 146 + for _, path := range faviconPaths { 147 + url := base + path 148 + req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil) 149 + if err != nil { 150 + continue 151 + } 152 + 153 + resp, err := client.Do(req) 154 + if err != nil { 155 + continue 156 + } 157 + resp.Body.Close() 158 + 159 + if resp.StatusCode == http.StatusOK { 160 + return url 161 + } 162 + } 163 + 164 + return "" 165 + } 166 + 167 + func resolveBaseURL(siteURL, html string) string { 168 + baseRe := regexp.MustCompile(`<base[^>]+href="([^"]*)"`) 169 + match := baseRe.FindStringSubmatch(html) 170 + if len(match) >= 2 && match[1] != "" { 171 + return resolveURL(siteURL, match[1]) 172 + } 173 + return siteURL 174 + } 175 + 176 + func resolveURL(base, ref string) string { 177 + if strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") { 178 + return ref 179 + } 180 + 181 + base = strings.TrimRight(base, "/") 182 + if strings.HasPrefix(ref, "//") { 183 + return "https:" + ref 184 + } 185 + if strings.HasPrefix(ref, "/") { 186 + idx := strings.Index(base[8:], "/") 187 + if idx >= 0 { 188 + return base[:8+idx] + ref 189 + } 190 + return base + ref 191 + } 192 + 193 + idx := strings.LastIndex(base, "/") 194 + if idx > 8 { 195 + return base[:idx+1] + ref 196 + } 197 + return fmt.Sprintf("%s/%s", base, ref) 198 + }
-48
internal/server/articles_handler.go
··· 13 13 "pkg.rbrt.fr/glean/internal/db" 14 14 ) 15 15 16 - func writeStarButton(w http.ResponseWriter, articleID int64, starred bool) { 17 - cls := "text-gray-300 hover:text-yellow-500" 18 - ch := "&#9734;" 19 - if starred { 20 - cls = "text-yellow-500" 21 - ch = "&#9733;" 22 - } 23 - w.Header().Set("Content-Type", "text/html") 24 - _, _ = fmt.Fprintf(w, `<button hx-post="/articles/%d/star" hx-target="#star-btn" hx-swap="outerHTML" id="star-btn" class="text-lg %s">%s</button>`, articleID, cls, ch) 25 - } 26 - 27 16 func writeLikeButton(w http.ResponseWriter, articleID int64, liked bool, count int) { 28 17 cls := "text-gray-300 hover:text-red-500" 29 18 if liked { ··· 125 114 return 126 115 } 127 116 writeReadButton(w, id, false) 128 - } 129 - 130 - func (s *Server) handleStar(w http.ResponseWriter, r *http.Request) { 131 - user := currentUser(r) 132 - id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) 133 - if err != nil { 134 - http.Error(w, "invalid id", http.StatusBadRequest) 135 - return 136 - } 137 - rs, _ := s.db.GetReadState(r.Context(), user.DID, id) 138 - if rs.IsStarred { 139 - if err := s.db.UnstarArticle(r.Context(), user.DID, id); err != nil { 140 - http.Error(w, err.Error(), http.StatusInternalServerError) 141 - return 142 - } 143 - writeStarButton(w, id, false) 144 - } else { 145 - if err := s.db.StarArticle(r.Context(), user.DID, id); err != nil { 146 - http.Error(w, err.Error(), http.StatusInternalServerError) 147 - return 148 - } 149 - writeStarButton(w, id, true) 150 - } 151 - } 152 - 153 - func (s *Server) handleUnstar(w http.ResponseWriter, r *http.Request) { 154 - user := currentUser(r) 155 - id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) 156 - if err != nil { 157 - http.Error(w, "invalid id", http.StatusBadRequest) 158 - return 159 - } 160 - if err := s.db.UnstarArticle(r.Context(), user.DID, id); err != nil { 161 - http.Error(w, err.Error(), http.StatusInternalServerError) 162 - return 163 - } 164 - writeStarButton(w, id, false) 165 117 } 166 118 167 119 func (s *Server) handleLikeArticle(w http.ResponseWriter, r *http.Request) {
+159 -1
internal/server/auth_handler.go
··· 1 1 package server 2 2 3 3 import ( 4 + "encoding/json" 5 + "fmt" 4 6 "net/http" 7 + "net/url" 8 + "os" 9 + 10 + oauth "github.com/bluesky-social/indigo/atproto/auth/oauth" 11 + "github.com/bluesky-social/indigo/atproto/syntax" 5 12 6 13 "pkg.rbrt.fr/glean/internal/atproto" 7 14 ) ··· 10 17 s.render(w, r, "login.html", map[string]any{}) 11 18 } 12 19 20 + func (s *Server) handleAuthStart(w http.ResponseWriter, r *http.Request) { 21 + handle := r.FormValue("handle") 22 + if handle == "" { 23 + http.Error(w, "handle required", http.StatusBadRequest) 24 + return 25 + } 26 + 27 + authURL, err := s.oauth.StartAuthFlow(r.Context(), handle) 28 + if err != nil { 29 + s.logger.Error("failed to start OAuth flow", "error", err) 30 + 31 + did, resolveErr := atproto.ResolveHandle(r.Context(), handle) 32 + if resolveErr != nil { 33 + http.Error(w, "could not resolve handle", http.StatusInternalServerError) 34 + return 35 + } 36 + user, createErr := s.db.CreateUser(r.Context(), did, handle, "", "") 37 + if createErr != nil { 38 + http.Error(w, createErr.Error(), http.StatusInternalServerError) 39 + return 40 + } 41 + s.setUserSession(w, user) 42 + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) 43 + return 44 + } 45 + 46 + http.Redirect(w, r, authURL, http.StatusSeeOther) 47 + } 48 + 13 49 func (s *Server) handleAuthCallback(w http.ResponseWriter, r *http.Request) { 14 - handle := r.URL.Query().Get("handle") 50 + params := r.URL.Query() 51 + 52 + if params.Get("code") != "" && params.Get("state") != "" { 53 + s.handleOAuthCallback(w, r) 54 + return 55 + } 56 + 57 + handle := params.Get("handle") 15 58 if handle == "" { 16 59 http.Error(w, "handle required", http.StatusBadRequest) 17 60 return ··· 35 78 http.Redirect(w, r, "/dashboard", http.StatusSeeOther) 36 79 } 37 80 81 + func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) { 82 + sessData, err := s.oauth.ProcessCallback(r.Context(), r.URL.Query()) 83 + if err != nil { 84 + s.logger.Error("OAuth callback failed", "error", err) 85 + http.Error(w, "authentication failed: "+err.Error(), http.StatusInternalServerError) 86 + return 87 + } 88 + 89 + did := sessData.AccountDID.String() 90 + handle := did 91 + if ident, err := s.oauth.Dir.LookupDID(r.Context(), sessData.AccountDID); err == nil { 92 + handle = ident.Handle.String() 93 + } 94 + 95 + user, err := s.db.CreateUser(r.Context(), did, handle, "", "") 96 + if err != nil { 97 + s.logger.Error("failed to create user", "error", err) 98 + http.Error(w, err.Error(), http.StatusInternalServerError) 99 + return 100 + } 101 + 102 + sessionData := sessionData{ 103 + DID: user.DID, 104 + PDSURL: sessData.HostURL, 105 + SessionID: sessData.SessionID, 106 + } 107 + encoded, err := encodeSession(sessionData) 108 + if err != nil { 109 + s.logger.Error("failed to encode session", "error", err) 110 + http.Error(w, "internal error", http.StatusInternalServerError) 111 + return 112 + } 113 + 114 + http.SetCookie(w, &http.Cookie{ 115 + Name: "glean_session", 116 + Value: encoded, 117 + Path: "/", 118 + MaxAge: 86400 * 30, 119 + HttpOnly: true, 120 + SameSite: http.SameSiteLaxMode, 121 + }) 122 + 123 + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) 124 + } 125 + 126 + func (s *Server) handleOAuthClientMetadata(w http.ResponseWriter, r *http.Request) { 127 + clientID := s.clientID 128 + if clientID == "" { 129 + scheme := "http" 130 + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { 131 + scheme = "https" 132 + } 133 + clientID = fmt.Sprintf("%s://%s/oauth/client-metadata", scheme, r.Host) 134 + } 135 + 136 + redirectURL := s.callbackURL 137 + if redirectURL == "" { 138 + scheme := "http" 139 + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { 140 + scheme = "https" 141 + } 142 + redirectURL = fmt.Sprintf("%s://%s/auth/callback", scheme, r.Host) 143 + } 144 + 145 + config := oauth.NewPublicConfig(clientID, redirectURL, []string{"atproto"}) 146 + meta := config.ClientMetadata() 147 + 148 + name := "Glean" 149 + meta.ClientName = &name 150 + 151 + uri := clientID 152 + if idx := len(uri); idx > 0 && uri[idx-1] == '/' { 153 + uri = uri[:idx-1] 154 + } 155 + meta.ClientURI = &uri 156 + 157 + w.Header().Set("Content-Type", "application/json") 158 + json.NewEncoder(w).Encode(meta) 159 + } 160 + 38 161 func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) { 162 + session := s.getSessionData(r) 163 + if session != nil && session.SessionID != "" { 164 + did, err := syntax.ParseDID(session.DID) 165 + if err == nil { 166 + _ = s.oauth.Logout(r.Context(), did, session.SessionID) 167 + } 168 + } 39 169 s.clearUserSession(w) 40 170 http.Redirect(w, r, "/", http.StatusSeeOther) 41 171 } 172 + 173 + func resolveCallbackURL(r *http.Request) string { 174 + scheme := "http" 175 + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { 176 + scheme = "https" 177 + } 178 + return fmt.Sprintf("%s://%s/auth/callback", scheme, r.Host) 179 + } 180 + 181 + func resolveClientID(r *http.Request) string { 182 + cid := os.Getenv("GLEAN_OAUTH_CLIENT_ID") 183 + if cid != "" { 184 + return cid 185 + } 186 + scheme := "http" 187 + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { 188 + scheme = "https" 189 + } 190 + return fmt.Sprintf("%s://%s/oauth/client-metadata", scheme, r.Host) 191 + } 192 + 193 + func resolveBaseURL(r *http.Request) *url.URL { 194 + scheme := "http" 195 + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { 196 + scheme = "https" 197 + } 198 + return &url.URL{Scheme: scheme, Host: r.Host} 199 + }
+7 -5
internal/server/discover_handler.go
··· 6 6 user := currentUser(r) 7 7 feedRecs, _ := s.db.GetFeedRecommendations(r.Context(), user.DID, 20) 8 8 people, _ := s.db.GetPeopleRecommendations(r.Context(), user.DID, 20) 9 - popular, _ := s.db.ListAllFeeds(r.Context(), 20, 0) 9 + popular, _ := s.db.ListUnsubscribedFeeds(r.Context(), user.DID, 20, 0) 10 + articleRecs, _ := s.db.GetArticleRecommendations(r.Context(), user.DID, 20) 10 11 s.render(w, r, "discover.html", map[string]any{ 11 - "User": user, 12 - "FeedRecommendations": feedRecs, 13 - "PeopleRecommendations": people, 14 - "PopularFeeds": popular, 12 + "User": user, 13 + "FeedRecommendations": feedRecs, 14 + "PeopleRecommendations": people, 15 + "PopularFeeds": popular, 16 + "ArticleRecommendations": articleRecs, 15 17 }) 16 18 } 17 19
+67
internal/server/feeds_handler.go
··· 1 1 package server 2 2 3 3 import ( 4 + "context" 4 5 "database/sql" 6 + "encoding/json" 5 7 "log/slog" 6 8 "net/http" 9 + "strconv" 7 10 "time" 8 11 9 12 "pkg.rbrt.fr/glean/internal/atproto" ··· 18 21 allSubs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 100, 0) 19 22 feedRecs, _ := s.db.GetFeedRecommendations(r.Context(), user.DID, 10) 20 23 peopleRecs, _ := s.db.GetPeopleRecommendations(r.Context(), user.DID, 5) 24 + deadFeeds, _ := s.db.ListDeadFeeds(r.Context(), user.DID, 7) 21 25 22 26 seen := make(map[string]bool) 23 27 var categories []string ··· 35 39 "Category": category, 36 40 "FeedRecommendations": feedRecs, 37 41 "PeopleRecommendations": peopleRecs, 42 + "DeadFeeds": deadFeeds, 38 43 }) 39 44 } 40 45 ··· 67 72 s.logger.Error("failed to upsert feed", "error", err) 68 73 http.Error(w, err.Error(), http.StatusInternalServerError) 69 74 return 75 + } 76 + 77 + siteURL := result.Feed.SiteURL 78 + if siteURL != "" { 79 + go func() { 80 + discResult, err := feed.Discover(context.Background(), siteURL) 81 + if err == nil && discResult.Favicon != "" { 82 + _ = s.db.UpdateFeedFavicon(context.Background(), feedURL, discResult.Favicon) 83 + } 84 + }() 70 85 } 71 86 } 72 87 ··· 231 246 "User": user, 232 247 "Subscriptions": subs, 233 248 }) 249 + } 250 + 251 + func (s *Server) handleDiscoverFeedURL(w http.ResponseWriter, r *http.Request) { 252 + siteURL := r.URL.Query().Get("url") 253 + if siteURL == "" { 254 + http.Error(w, "url required", http.StatusBadRequest) 255 + return 256 + } 257 + 258 + result, err := feed.Discover(r.Context(), siteURL) 259 + if err != nil { 260 + s.logger.Error("feed discovery failed", "error", err, "url", siteURL) 261 + http.Error(w, err.Error(), http.StatusInternalServerError) 262 + return 263 + } 264 + 265 + w.Header().Set("Content-Type", "application/json") 266 + type discoveryResponse struct { 267 + FeedURLs []string `json:"feed_urls"` 268 + Favicon string `json:"favicon"` 269 + } 270 + json.NewEncoder(w).Encode(discoveryResponse{ 271 + FeedURLs: result.FeedURLs, 272 + Favicon: result.Favicon, 273 + }) 274 + } 275 + 276 + func (s *Server) handleUpdateFeedInterval(w http.ResponseWriter, r *http.Request) { 277 + user := currentUser(r) 278 + feedURL := r.FormValue("feed_url") 279 + intervalStr := r.FormValue("interval") 280 + 281 + if feedURL == "" { 282 + http.Error(w, "feed_url required", http.StatusBadRequest) 283 + return 284 + } 285 + 286 + interval, err := strconv.Atoi(intervalStr) 287 + if err != nil || interval < 5 { 288 + http.Error(w, "interval must be >= 5 minutes", http.StatusBadRequest) 289 + return 290 + } 291 + 292 + if err := s.db.UpdateFeedInterval(r.Context(), feedURL, interval); err != nil { 293 + s.logger.Error("failed to update feed interval", "error", err) 294 + http.Error(w, err.Error(), http.StatusInternalServerError) 295 + return 296 + } 297 + 298 + w.Header().Set("HX-Refresh", "true") 299 + w.WriteHeader(http.StatusNoContent) 300 + _ = user 234 301 } 235 302 236 303 func nullString(s string) sql.NullString {
+54 -16
internal/server/server.go
··· 2 2 3 3 import ( 4 4 "bytes" 5 + "context" 5 6 "html/template" 6 7 "log/slog" 7 8 "net/http" ··· 13 14 "github.com/go-chi/chi/v5/middleware" 14 15 "github.com/go-chi/cors" 15 16 17 + oauth "github.com/bluesky-social/indigo/atproto/auth/oauth" 18 + "github.com/bluesky-social/indigo/atproto/syntax" 19 + 16 20 "pkg.rbrt.fr/glean/internal/atproto" 17 21 "pkg.rbrt.fr/glean/internal/db" 18 22 "pkg.rbrt.fr/glean/internal/feed" ··· 24 28 } 25 29 26 30 type Server struct { 27 - db *db.DB 28 - router *chi.Mux 29 - templates *template.Template 30 - logger *slog.Logger 31 - oauth *atproto.OAuthConfig 32 - fetcher *feed.Fetcher 31 + db *db.DB 32 + router *chi.Mux 33 + templates *template.Template 34 + logger *slog.Logger 35 + oauth *oauth.ClientApp 36 + oauthStore *db.OAuthStore 37 + fetcher *feed.Fetcher 38 + clientID string 39 + callbackURL string 33 40 } 34 41 35 - func New(database *db.DB, oauth *atproto.OAuthConfig, logger *slog.Logger) *Server { 42 + func New(database *db.DB, clientID, callbackURL string, logger *slog.Logger) *Server { 43 + oauthStore := db.NewOAuthStore(database) 44 + if err := oauthStore.Init(context.Background()); err != nil { 45 + logger.Error("failed to init oauth store", "error", err) 46 + } 47 + 48 + config := oauth.NewPublicConfig(clientID, callbackURL, []string{"atproto"}) 49 + oauthClient := oauth.NewClientApp(&config, oauthStore) 50 + 36 51 s := &Server{ 37 - db: database, 38 - router: chi.NewRouter(), 39 - logger: logger, 40 - oauth: oauth, 41 - fetcher: feed.NewFetcher(), 52 + db: database, 53 + router: chi.NewRouter(), 54 + logger: logger, 55 + oauth: oauthClient, 56 + oauthStore: oauthStore, 57 + fetcher: feed.NewFetcher(), 58 + clientID: clientID, 59 + callbackURL: callbackURL, 42 60 } 43 61 44 62 s.setupMiddleware() ··· 79 97 r.Get("/opml/download", s.handleOPMLDownload) 80 98 r.Post("/refresh", s.handleRefreshFeeds) 81 99 r.Get("/list", s.handleFeedList) 100 + r.Post("/set-interval", s.handleUpdateFeedInterval) 101 + r.Get("/discover-url", s.handleDiscoverFeedURL) 82 102 }) 83 103 84 104 s.router.Route("/articles", func(r chi.Router) { ··· 87 107 r.Get("/{id}", s.handleArticleDetail) 88 108 r.Post("/{id}/read", s.handleMarkRead) 89 109 r.Post("/{id}/unread", s.handleMarkUnread) 90 - r.Post("/{id}/star", s.handleStar) 91 - r.Post("/{id}/unstar", s.handleUnstar) 92 110 r.Post("/{id}/like", s.handleLikeArticle) 93 111 r.Post("/mark-all-read", s.handleMarkAllRead) 94 112 }) ··· 114 132 }) 115 133 116 134 s.router.Get("/auth/login", s.handleAuthLogin) 135 + s.router.Post("/auth/start", s.handleAuthStart) 117 136 s.router.Get("/auth/callback", s.handleAuthCallback) 118 137 s.router.Post("/auth/logout", s.handleAuthLogout) 138 + s.router.Get("/oauth/client-metadata", s.handleOAuthClientMetadata) 119 139 120 140 xrpc := atproto.NewXRPCHandler(s.db.DB) 121 141 s.router.Get("/xrpc/at.glean.listSubscriptions", xrpc.ListSubscriptions) ··· 200 220 201 221 func (s *Server) pdsClientForUser(r *http.Request) *atproto.Client { 202 222 session := s.getSessionData(r) 203 - if session == nil || session.AccessToken == "" || session.PDSURL == "" { 223 + if session == nil { 204 224 return nil 205 225 } 206 - return atproto.NewClient(session.PDSURL, session.AccessToken) 226 + 227 + if session.SessionID != "" { 228 + did, err := syntax.ParseDID(session.DID) 229 + if err != nil { 230 + return nil 231 + } 232 + sess, err := s.oauth.ResumeSession(r.Context(), did, session.SessionID) 233 + if err != nil { 234 + s.logger.Warn("failed to resume OAuth session", "error", err) 235 + return nil 236 + } 237 + apiClient := sess.APIClient() 238 + return &atproto.Client{APIClient: apiClient} 239 + } 240 + 241 + if session.AccessToken != "" && session.PDSURL != "" { 242 + return atproto.NewClient(session.PDSURL, session.AccessToken) 243 + } 244 + return nil 207 245 } 208 246 209 247 func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+5 -3
internal/server/session.go
··· 72 72 } 73 73 74 74 type sessionData struct { 75 - DID string `json:"did"` 76 - PDSURL string `json:"pds_url,omitempty"` 77 - AccessToken string `json:"access_token,omitempty"` 75 + DID string `json:"did"` 76 + PDSURL string `json:"pds_url,omitempty"` 77 + AccessToken string `json:"access_token,omitempty"` 78 + RefreshToken string `json:"refresh_token,omitempty"` 79 + SessionID string `json:"session_id,omitempty"` 78 80 } 79 81 80 82 func (s *Server) getSessionData(r *http.Request) *sessionData {
+1 -2
internal/tmpl/annotations.html
··· 1 1 {{define "annotations.html"}} 2 - <div class="mb-6"> 2 + <div class="flex items-center justify-between mb-6"> 3 3 <h1 class="text-2xl font-bold font-title text-spot-text">Annotations</h1> 4 - <p class="text-spot-secondary text-sm mt-1">Your notes and highlights on articles.</p> 5 4 </div> 6 5 7 6 {{if .ArticleURL}}
+31 -11
internal/tmpl/article_detail.html
··· 12 12 {{if .Article.Author.Valid}}<span>{{.Article.Author.String}}</span>{{end}} 13 13 {{if .Article.Published.Valid}}<span>{{.Article.Published.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}} 14 14 {{if .Feed}} 15 - <a href="/articles?feed={{.Feed.FeedURL}}" class="hover:text-spot-purple transition">{{if .Feed.Title.Valid}}{{.Feed.Title.String}}{{else}}{{.Feed.FeedURL}}{{end}}</a> 15 + <a href="/articles?feed={{.Feed.FeedURL}}" class="hover:text-spot-purple transition"> 16 + {{if .Feed.FaviconURL.Valid}}<img src="{{.Feed.FaviconURL.String}}" class="w-4 h-4 inline-block mr-1 align-text-bottom rounded-sm">{{end}} 17 + {{if .Feed.Title.Valid}}{{.Feed.Title.String}}{{else}}{{.Feed.FeedURL}}{{end}} 18 + </a> 16 19 {{end}} 17 20 </div> 18 21 ··· 26 29 <a href="https://bsky.app/intent/compose?text={{.Article.Title}}%20{{if .Article.URL.Valid}}{{.Article.URL.String}}{{end}}" 27 30 target="_blank" rel="noopener noreferrer" 28 31 class="text-xs border border-spot-outline text-spot-text rounded-pill px-3 py-1 hover:border-spot-text uppercase tracking-button transition inline-flex items-center gap-1"> 29 - 🦋 30 32 Share 31 33 </a> 32 34 ··· 36 38 class="text-xs border border-spot-outline text-spot-text rounded-pill px-3 py-1 hover:border-spot-text uppercase tracking-button transition"> 37 39 {{if .ReadState.IsRead}}Mark unread{{else}}Mark read{{end}} 38 40 </button> 41 + 42 + <a href="{{.Article.URL.String}}" target="_blank" rel="noopener noreferrer" 43 + class="text-xs border border-spot-outline text-spot-text rounded-pill px-3 py-1 hover:border-spot-text uppercase tracking-button transition"> 44 + Original 45 + </a> 39 46 </div> 40 47 41 48 <hr class="my-6 border-spot-divider-30"> ··· 88 95 </div> 89 96 90 97 <script> 91 - document.addEventListener('keydown', function(e) { 92 - if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; 93 - if (e.key === 'l') { 94 - document.getElementById('like-btn') && document.getElementById('like-btn').click(); 95 - } else if (e.key === 'm') { 96 - document.getElementById('read-btn') && document.getElementById('read-btn').click(); 97 - } else if (e.key === 'Escape') { 98 - window.location.href = '/articles'; 98 + (function() { 99 + var readScrollPos = 0; 100 + var articleBody = document.querySelector('.article-body'); 101 + if (articleBody) { 102 + readScrollPos = window.scrollY; 99 103 } 100 - }); 104 + 105 + document.addEventListener('keydown', function(e) { 106 + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; 107 + if (e.key === 'l') { 108 + var btn = document.getElementById('like-btn'); 109 + if (btn) btn.click(); 110 + } else if (e.key === 'm') { 111 + var rbtn = document.getElementById('read-btn'); 112 + if (rbtn) rbtn.click(); 113 + } else if (e.key === 'Escape') { 114 + window.location.href = '/articles'; 115 + } else if (e.key === 'o') { 116 + var origLink = document.querySelector('a[target="_blank"]'); 117 + if (origLink && origLink.href) window.open(origLink.href, '_blank'); 118 + } 119 + }); 120 + })(); 101 121 </script> 102 122 {{end}}
+42
internal/tmpl/articles.html
··· 26 26 </button> 27 27 </div> 28 28 {{end}} 29 + 30 + <script> 31 + (function() { 32 + var articles = document.querySelectorAll('#article-list article[data-article-id]'); 33 + var currentIdx = -1; 34 + 35 + function highlightArticle(idx) { 36 + articles.forEach(function(a) { a.classList.remove('ring-2', 'ring-spot-purple'); }); 37 + if (idx >= 0 && idx < articles.length) { 38 + currentIdx = idx; 39 + articles[idx].classList.add('ring-2', 'ring-spot-purple'); 40 + articles[idx].scrollIntoView({ behavior: 'smooth', block: 'nearest' }); 41 + } 42 + } 43 + 44 + function getReadBtn(idx) { 45 + if (idx < 0 || idx >= articles.length) return null; 46 + return articles[idx].querySelector('[hx-post*="/read"], [hx-post*="/unread"]'); 47 + } 48 + 49 + document.addEventListener('keydown', function(e) { 50 + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; 51 + if (e.ctrlKey || e.metaKey || e.altKey) return; 52 + 53 + if (e.key === 'j') { 54 + e.preventDefault(); 55 + highlightArticle(Math.min(currentIdx + 1, articles.length - 1)); 56 + } else if (e.key === 'k') { 57 + e.preventDefault(); 58 + highlightArticle(Math.max(currentIdx - 1, 0)); 59 + } else if (e.key === 'o' && currentIdx >= 0) { 60 + e.preventDefault(); 61 + var link = articles[currentIdx].querySelector('a[href^="/articles/"]'); 62 + if (link) link.click(); 63 + } else if (e.key === 'm' && currentIdx >= 0) { 64 + e.preventDefault(); 65 + var rbtn = getReadBtn(currentIdx); 66 + if (rbtn) rbtn.click(); 67 + } 68 + }); 69 + })(); 70 + </script> 29 71 {{end}}
+10 -10
internal/tmpl/base.html
··· 163 163 </a> 164 164 </nav> 165 165 <div class="mt-auto pt-4 border-t border-spot-divider-30"> 166 - <div class="flex items-center gap-3 px-3 py-2"> 166 + <a href="/profile/{{.User.DID}}" class="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-spot-hover-50 transition"> 167 167 {{if .User.AvatarURL.Valid}}<img src="{{.User.AvatarURL.String}}" class="w-8 h-8 rounded-full">{{end}} 168 168 <div class="min-w-0 flex-1"> 169 169 <div class="text-sm font-bold truncate text-spot-text">@{{.User.Handle}}</div> 170 170 </div> 171 - <form method="POST" action="/auth/logout"> 172 - {{csrfInput .CSRFToken}} 173 - <button type="submit" class="text-spot-secondary hover:text-spot-red transition" title="Logout"> 174 - <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg> 175 - </button> 176 - </form> 177 - </div> 171 + </a> 172 + <form method="POST" action="/auth/logout"> 173 + {{csrfInput .CSRFToken}} 174 + <button type="submit" class="text-spot-secondary hover:text-spot-red transition" title="Logout"> 175 + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg> 176 + </button> 177 + </form> 178 178 </div> 179 179 </aside> 180 180 ··· 208 208 {{if .User}} 209 209 <div class="lg:hidden bg-spot-surface border-b border-spot-divider px-4 py-3 flex items-center justify-between sticky top-0 z-20"> 210 210 <a href="/" class="text-spot-purple font-bold text-lg font-title">Glean</a> 211 - <div class="flex items-center gap-2"> 211 + <a href="/profile/{{.User.DID}}" class="flex items-center gap-2"> 212 212 {{if .User.AvatarURL.Valid}}<img src="{{.User.AvatarURL.String}}" class="w-7 h-7 rounded-full">{{end}} 213 213 <span class="text-xs text-spot-secondary">@{{.User.Handle}}</span> 214 - </div> 214 + </a> 215 215 </div> 216 216 {{end}} 217 217 <div class="max-w-6xl mx-auto px-4 lg:px-8 py-6 flex-1">
+20 -2
internal/tmpl/discover.html
··· 1 1 {{define "discover.html"}} 2 - <div class="mb-6"> 2 + <div class="flex items-center justify-between mb-6"> 3 3 <h1 class="text-2xl font-bold font-title text-spot-text">Discover</h1> 4 - <p class="text-spot-secondary text-sm mt-1">Feeds and readers similar to you.</p> 5 4 </div> 5 + 6 + {{if .ArticleRecommendations}} 7 + <div class="mb-8"> 8 + <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended articles</h2> 9 + <div class="space-y-3"> 10 + {{range .ArticleRecommendations}} 11 + <a href="/articles/{{.ArticleID}}" class="block bg-spot-surface rounded-lg p-4 hover:bg-spot-hover-50 transition"> 12 + <div class="font-bold text-sm text-spot-text leading-tight">{{.Title}}</div> 13 + <div class="flex items-center gap-2 mt-1 text-xs text-spot-secondary"> 14 + {{if .Author}}<span>{{.Author}}</span>{{end}} 15 + <span class="text-spot-muted">{{.FeedTitle}}</span> 16 + {{if .Published.Valid}}<span>{{.Published.Time.Format "Jan 2"}}</span>{{end}} 17 + </div> 18 + {{if .Summary}}<p class="text-xs text-spot-secondary mt-2 line-clamp-2">{{.Summary}}</p>{{end}} 19 + </a> 20 + {{end}} 21 + </div> 22 + </div> 23 + {{end}} 6 24 7 25 <div class="grid grid-cols-1 lg:grid-cols-2 gap-8"> 8 26 <div>
+31
internal/tmpl/feeds.html
··· 11 11 </div> 12 12 </div> 13 13 14 + {{if .DeadFeeds}} 15 + <div class="bg-spot-red/10 border border-spot-red/30 rounded-lg p-4 mb-6"> 16 + <h3 class="text-sm font-bold text-spot-red mb-2">Feeds with errors ({{len .DeadFeeds}})</h3> 17 + <div class="space-y-2"> 18 + {{range .DeadFeeds}} 19 + <div class="flex items-center justify-between text-sm"> 20 + <span class="text-spot-text">{{if .Title.Valid}}{{.Title.String}}{{else}}{{.FeedURL}}{{end}}</span> 21 + <div class="flex items-center gap-2"> 22 + <span class="text-spot-red text-xs">{{.ErrorCount}} errors</span> 23 + {{if .LastError.Valid}}<span class="text-spot-secondary text-xs truncate max-w-48" title="{{.LastError.String}}">{{.LastError.String}}</span>{{end}} 24 + </div> 25 + </div> 26 + {{end}} 27 + </div> 28 + </div> 29 + {{end}} 30 + 14 31 <div class="grid grid-cols-1 lg:grid-cols-3 gap-6"> 15 32 <div class="lg:col-span-2"> 16 33 {{if .Categories}} ··· 31 48 </div> 32 49 <div class="flex items-center gap-3"> 33 50 {{if .UnreadCount}}<span class="text-xs bg-spot-purple/20 text-spot-purple px-2.5 py-0.5 rounded-full font-bold">{{.UnreadCount}}</span>{{end}} 51 + <form hx-post="/feeds/set-interval" hx-swap="none" hx-vals='{"feed_url": "{{.FeedURL}}"}' class="inline-flex items-center gap-1"> 52 + {{csrfInput $.CSRFToken}} 53 + <input type="hidden" name="feed_url" value="{{.FeedURL}}"> 54 + <select name="interval" onchange="this.form.submit()" 55 + class="text-xs bg-spot-hover text-spot-secondary rounded px-2 py-1 border-none focus:outline-none focus:ring-1 focus:ring-spot-purple"> 56 + <option value="15" {{if eq .FetchInterval 15}}selected{{end}}>15m</option> 57 + <option value="30" {{if eq .FetchInterval 30}}selected{{end}}>30m</option> 58 + <option value="60" {{if eq .FetchInterval 60}}selected{{end}}>1h</option> 59 + <option value="120" {{if eq .FetchInterval 120}}selected{{end}}>2h</option> 60 + <option value="360" {{if eq .FetchInterval 360}}selected{{end}}>6h</option> 61 + <option value="720" {{if eq .FetchInterval 720}}selected{{end}}>12h</option> 62 + <option value="1440" {{if eq .FetchInterval 1440}}selected{{end}}>24h</option> 63 + </select> 64 + </form> 34 65 <button hx-delete="/feeds/{{.FeedURL}}" hx-target="closest .px-5" hx-swap="outerHTML swap:0.3s" 35 66 hx-confirm="Unsubscribe from this feed?" 36 67 class="text-sm text-spot-secondary hover:text-spot-red transition">Unsubscribe</button>
+3 -2
internal/tmpl/login.html
··· 2 2 <div class="max-w-md mx-auto mt-20"> 3 3 <div class="bg-spot-surface rounded-lg shadow-spot-heavy p-8"> 4 4 <h1 class="text-2xl font-bold font-title text-spot-text mb-6">Sign in to Glean</h1> 5 - <form action="/auth/callback" method="GET"> 5 + <form action="/auth/start" method="POST"> 6 + {{csrfInput .CSRFToken}} 6 7 <label class="block text-sm font-bold text-spot-secondary mb-2 uppercase tracking-button">Bluesky Handle</label> 7 8 <input type="text" name="handle" placeholder="you.bsky.social" 8 9 class="w-full bg-spot-hover text-spot-text rounded-pill px-5 py-3 mb-5 focus:outline-none focus:ring-2 focus:ring-spot-purple placeholder:text-spot-placeholder" ··· 11 12 Sign in with Bluesky 12 13 </button> 13 14 </form> 14 - <p class="mt-5 text-sm text-spot-secondary">We'll authenticate you via your Bluesky account. No password needed.</p> 15 + <p class="mt-5 text-sm text-spot-secondary">We'll authenticate you via your Bluesky account using AT Protocol OAuth.</p> 15 16 </div> 16 17 </div> 17 18 {{end}}
+1 -1
internal/tmpl/partials/article-card.html
··· 1 1 {{define "article-card.html"}} 2 - <article class="bg-spot-surface rounded-lg px-5 py-4 hover:bg-spot-hover-50 transition shadow-spot"> 2 + <article data-article-id="{{.ID}}" class="bg-spot-surface rounded-lg px-5 py-4 hover:bg-spot-hover-50 transition shadow-spot"> 3 3 <div class="flex items-start justify-between gap-4"> 4 4 <div class="min-w-0 flex-1"> 5 5 <a href="/articles/{{.ID}}" class="font-bold text-spot-text hover:text-spot-purple transition text-lg leading-tight">{{.Title}}</a>
+3 -5
main.go
··· 33 33 } 34 34 defer database.Close() 35 35 36 - oauth := &atproto.OAuthConfig{ 37 - ClientID: envOr("GLEAN_OAUTH_CLIENT_ID", ""), 38 - RedirectURL: envOr("GLEAN_OAUTH_REDIRECT_URL", ""), 39 - } 36 + clientID := envOr("GLEAN_OAUTH_CLIENT_ID", "") 37 + callbackURL := envOr("GLEAN_OAUTH_REDIRECT_URL", "") 40 38 41 - srv := server.New(database, oauth, logger) 39 + srv := server.New(database, clientID, callbackURL, logger) 42 40 43 41 storeAdapter := db.NewFeedStoreAdapter(database) 44 42 scheduler := feed.NewScheduler(storeAdapter, logger)