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

Implement article recommendations and feed management featuresUnverified

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.
Julien Robert committed 2026-04-20T11:43:14+02:00 Browse files
c613ecc parent: cef27d7
modified go.mod +2 -0
@@ -72,6 +72,7 @@ require (
7272 github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
7373 github.com/gobwas/glob v0.2.3 // indirect
7474 github.com/gofrs/flock v0.12.1 // indirect
75+ github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
7576 github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
7677 github.com/golangci/go-printf-func-name v0.1.0 // indirect
7778 github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect
@@ -81,6 +82,7 @@ require (
8182 github.com/golangci/revgrep v0.8.0 // indirect
8283 github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed // indirect
8384 github.com/google/go-cmp v0.7.0 // indirect
85+ github.com/google/go-querystring v1.1.0 // indirect
8486 github.com/google/uuid v1.6.0 // indirect
8587 github.com/gordonklaus/ineffassign v0.1.0 // indirect
8688 github.com/gostaticanalysis/analysisutil v0.7.1 // indirect
@@ -72,6 +72,7 @@ require (
72 github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect72 github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
73 github.com/gobwas/glob v0.2.3 // indirect73 github.com/gobwas/glob v0.2.3 // indirect
74 github.com/gofrs/flock v0.12.1 // indirect74 github.com/gofrs/flock v0.12.1 // indirect
75+ github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
75 github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect76 github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
76 github.com/golangci/go-printf-func-name v0.1.0 // indirect77 github.com/golangci/go-printf-func-name v0.1.0 // indirect
77 github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect78 github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect
@@ -81,6 +82,7 @@ require (
81 github.com/golangci/revgrep v0.8.0 // indirect82 github.com/golangci/revgrep v0.8.0 // indirect
82 github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed // indirect83 github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed // indirect
83 github.com/google/go-cmp v0.7.0 // indirect84 github.com/google/go-cmp v0.7.0 // indirect
85+ github.com/google/go-querystring v1.1.0 // indirect
84 github.com/google/uuid v1.6.0 // indirect86 github.com/google/uuid v1.6.0 // indirect
85 github.com/gordonklaus/ineffassign v0.1.0 // indirect87 github.com/gordonklaus/ineffassign v0.1.0 // indirect
86 github.com/gostaticanalysis/analysisutil v0.7.1 // indirect88 github.com/gostaticanalysis/analysisutil v0.7.1 // indirect
modified go.sum +5 -0
@@ -143,6 +143,9 @@ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
143143 github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
144144 github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
145145 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=
146149 github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 h1:WUvBfQL6EW/40l6OmeSBYQJNSif4O11+bmWEz+C7FYw=
147150 github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32/go.mod h1:NUw9Zr2Sy7+HxzdjIULge71wI6yEg1lWQr7Evcu8K0E=
148151 github.com/golangci/go-printf-func-name v0.1.0 h1:dVokQP+NMTO7jwO4bwsRwLWeudOVUPPyAKJuzv8pEJU=
@@ -166,6 +169,8 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
166169 github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
167170 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
168171 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=
169174 github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
170175 github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
171176 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -143,6 +143,9 @@ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
143 github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=143 github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
144 github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=144 github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
145 github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=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 github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 h1:WUvBfQL6EW/40l6OmeSBYQJNSif4O11+bmWEz+C7FYw=149 github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 h1:WUvBfQL6EW/40l6OmeSBYQJNSif4O11+bmWEz+C7FYw=
147 github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32/go.mod h1:NUw9Zr2Sy7+HxzdjIULge71wI6yEg1lWQr7Evcu8K0E=150 github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32/go.mod h1:NUw9Zr2Sy7+HxzdjIULge71wI6yEg1lWQr7Evcu8K0E=
148 github.com/golangci/go-printf-func-name v0.1.0 h1:dVokQP+NMTO7jwO4bwsRwLWeudOVUPPyAKJuzv8pEJU=151 github.com/golangci/go-printf-func-name v0.1.0 h1:dVokQP+NMTO7jwO4bwsRwLWeudOVUPPyAKJuzv8pEJU=
@@ -166,6 +169,8 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
166 github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=169 github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
167 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=170 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
168 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=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 github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=174 github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
170 github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=175 github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
171 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=176 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
modified internal/atproto/auth.go +0 -13
@@ -70,16 +70,3 @@ func ResolvePDSEndpoint(ctx context.Context, did string) (string, error) {
7070
7171 return pds, nil
7272 }
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-}
@@ -70,16 +70,3 @@ func ResolvePDSEndpoint(ctx context.Context, did string) (string, error) {
70 70
71 return pds, nil71 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-}
modified internal/atproto/client.go +29 -0
@@ -7,6 +7,7 @@ import (
77 "fmt"
88 "net/http"
99
10+ "github.com/bluesky-social/indigo/atproto/atclient"
1011 "github.com/bluesky-social/indigo/atproto/syntax"
1112 )
1213
@@ -14,6 +15,7 @@ type Client struct {
1415 httpClient *http.Client
1516 pdsURL string
1617 accessToken string
18+ APIClient *atclient.APIClient
1719 }
1820
1921 func NewClient(pdsURL, accessToken string) *Client {
@@ -25,6 +27,10 @@ func NewClient(pdsURL, accessToken string) *Client {
2527 }
2628
2729 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+
2834 nsid, err := syntax.ParseNSID(collection)
2935 if err != nil {
3036 return "", "", fmt.Errorf("parsing collection NSID: %w", err)
@@ -70,6 +76,29 @@ func (c *Client) CreateRecord(ctx context.Context, did, collection string, recor
7076 return result.URI, result.CID, nil
7177 }
7278
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
100+}
101+
73102 func (c *Client) DeleteRecord(ctx context.Context, did, collection, rkey string) error {
74103 body := map[string]any{
75104 "repo": did,
@@ -7,6 +7,7 @@ import (
7 "fmt"7 "fmt"
8 "net/http"8 "net/http"
9 9
10+ "github.com/bluesky-social/indigo/atproto/atclient"
10 "github.com/bluesky-social/indigo/atproto/syntax"11 "github.com/bluesky-social/indigo/atproto/syntax"
11 )12 )
12 13
@@ -14,6 +15,7 @@ type Client struct {
14 httpClient *http.Client15 httpClient *http.Client
15 pdsURL string16 pdsURL string
16 accessToken string17 accessToken string
18+ APIClient *atclient.APIClient
17 }19 }
18 20
19 func NewClient(pdsURL, accessToken string) *Client {21 func NewClient(pdsURL, accessToken string) *Client {
@@ -25,6 +27,10 @@ func NewClient(pdsURL, accessToken string) *Client {
25 }27 }
26 28
27 func (c *Client) CreateRecord(ctx context.Context, did, collection string, record any) (string, string, error) {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 nsid, err := syntax.ParseNSID(collection)34 nsid, err := syntax.ParseNSID(collection)
29 if err != nil {35 if err != nil {
30 return "", "", fmt.Errorf("parsing collection NSID: %w", err)36 return "", "", fmt.Errorf("parsing collection NSID: %w", err)
@@ -70,6 +76,29 @@ func (c *Client) CreateRecord(ctx context.Context, did, collection string, recor
70 return result.URI, result.CID, nil76 return result.URI, result.CID, nil
71 }77 }
72 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
100+}
101+
73 func (c *Client) DeleteRecord(ctx context.Context, did, collection, rkey string) error {102 func (c *Client) DeleteRecord(ctx context.Context, did, collection, rkey string) error {
74 body := map[string]any{103 body := map[string]any{
75 "repo": did,104 "repo": did,
modified internal/cluster/jaccard.go +50 -1
@@ -11,6 +11,50 @@ type Engine struct {
1111 logger *slog.Logger
1212 }
1313
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+
1458 func NewEngine(db *sql.DB, logger *slog.Logger) *Engine {
1559 return &Engine{db: db, logger: logger}
1660 }
@@ -119,5 +163,10 @@ func (e *Engine) ComputeRecommendations(ctx context.Context) error {
119163 }
120164
121165 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)
123172 }
@@ -11,6 +11,50 @@ type Engine struct {
11 logger *slog.Logger11 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 func NewEngine(db *sql.DB, logger *slog.Logger) *Engine {58 func NewEngine(db *sql.DB, logger *slog.Logger) *Engine {
15 return &Engine{db: db, logger: logger}59 return &Engine{db: db, logger: logger}
16 }60 }
@@ -119,5 +163,10 @@ func (e *Engine) ComputeRecommendations(ctx context.Context) error {
119 }163 }
120 164
121 e.logger.Info("feed recommendations computed")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 }
modified internal/db/cluster.go +42 -2
@@ -280,7 +280,7 @@ func (db *DB) GetSimilarFeeds(ctx context.Context, feedURL string, limit int) ([
280280 rows, err := db.QueryContext(ctx, `
281281 SELECT f.feed_url, f.title, f.site_url, f.description, f.feed_type,
282282 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
284284 FROM feed_similarity fs
285285 JOIN feeds f ON f.feed_url = CASE WHEN fs.feed_a = ? THEN fs.feed_b ELSE fs.feed_a END
286286 WHERE fs.feed_a = ? OR fs.feed_b = ?
@@ -297,10 +297,50 @@ func (db *DB) GetSimilarFeeds(ctx context.Context, feedURL string, limit int) ([
297297 f := &Feed{}
298298 if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,
299299 &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 {
301301 return nil, err
302302 }
303303 feeds = append(feeds, f)
304304 }
305305 return feeds, rows.Err()
306306 }
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+}
@@ -280,7 +280,7 @@ func (db *DB) GetSimilarFeeds(ctx context.Context, feedURL string, limit int) ([
280 rows, err := db.QueryContext(ctx, `280 rows, err := db.QueryContext(ctx, `
281 SELECT f.feed_url, f.title, f.site_url, f.description, f.feed_type,281 SELECT f.feed_url, f.title, f.site_url, f.description, f.feed_type,
282 f.last_fetched_at, f.last_error, f.subscriber_count, f.etag, f.last_modified,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_count283+ f.fetch_interval_minutes, f.next_fetch_at, f.consecutive_empty_fetches, f.error_count, f.favicon_url
284 FROM feed_similarity fs284 FROM feed_similarity fs
285 JOIN feeds f ON f.feed_url = CASE WHEN fs.feed_a = ? THEN fs.feed_b ELSE fs.feed_a END285 JOIN feeds f ON f.feed_url = CASE WHEN fs.feed_a = ? THEN fs.feed_b ELSE fs.feed_a END
286 WHERE fs.feed_a = ? OR fs.feed_b = ?286 WHERE fs.feed_a = ? OR fs.feed_b = ?
@@ -297,10 +297,50 @@ func (db *DB) GetSimilarFeeds(ctx context.Context, feedURL string, limit int) ([
297 f := &Feed{}297 f := &Feed{}
298 if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,298 if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,
299 &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified,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 return nil, err301 return nil, err
302 }302 }
303 feeds = append(feeds, f)303 feeds = append(feeds, f)
304 }304 }
305 return feeds, rows.Err()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+}
modified internal/db/db.go +9 -1
@@ -55,7 +55,8 @@ func migrate(db *sql.DB) error {
5555 fetch_interval_minutes INTEGER NOT NULL DEFAULT 30,
5656 next_fetch_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
5757 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
5960 )`,
6061 `CREATE TABLE IF NOT EXISTS subscriptions (
6162 id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -160,5 +161,12 @@ func migrate(db *sql.DB) error {
160161 }
161162 }
162163
164+ migrations := []string{
165+ `ALTER TABLE feeds ADD COLUMN favicon_url TEXT`,
166+ }
167+ for _, m := range migrations {
168+ tx.Exec(m)
169+ }
170+
163171 return tx.Commit()
164172 }
@@ -55,7 +55,8 @@ func migrate(db *sql.DB) error {
55 fetch_interval_minutes INTEGER NOT NULL DEFAULT 30,55 fetch_interval_minutes INTEGER NOT NULL DEFAULT 30,
56 next_fetch_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,56 next_fetch_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
57 consecutive_empty_fetches INTEGER NOT NULL DEFAULT 0,57 consecutive_empty_fetches INTEGER NOT NULL DEFAULT 0,
58- error_count INTEGER NOT NULL DEFAULT 058+ error_count INTEGER NOT NULL DEFAULT 0,
59+ favicon_url TEXT
59 )`,60 )`,
60 `CREATE TABLE IF NOT EXISTS subscriptions (61 `CREATE TABLE IF NOT EXISTS subscriptions (
61 id INTEGER PRIMARY KEY AUTOINCREMENT,62 id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -160,5 +161,12 @@ func migrate(db *sql.DB) error {
160 }161 }
161 }162 }
162 163
164+ migrations := []string{
165+ `ALTER TABLE feeds ADD COLUMN favicon_url TEXT`,
166+ }
167+ for _, m := range migrations {
168+ tx.Exec(m)
169+ }
170+
163 return tx.Commit()171 return tx.Commit()
164 }172 }
modified internal/db/feed.go +90 -15
@@ -22,16 +22,18 @@ type Feed struct {
2222 NextFetchAt sql.NullTime
2323 ConsecutiveEmptyFetches int
2424 ErrorCount int
25+ FaviconURL sql.NullString
2526 }
2627
2728 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
3537 }
3638
3739 func (db *DB) UpsertFeed(ctx context.Context, feed *Feed) error {
@@ -52,11 +54,11 @@ func (db *DB) GetFeed(ctx context.Context, feedURL string) (*Feed, error) {
5254 err := db.QueryRowContext(ctx, `
5355 SELECT feed_url, title, site_url, description, feed_type,
5456 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
5658 FROM feeds WHERE feed_url = ?
5759 `, feedURL).Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,
5860 &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)
6062 if err != nil {
6163 return nil, err
6264 }
@@ -67,7 +69,7 @@ func (db *DB) GetFeedsToFetch(ctx context.Context, limit int) ([]*Feed, error) {
6769 rows, err := db.QueryContext(ctx, `
6870 SELECT feed_url, title, site_url, description, feed_type,
6971 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
7173 FROM feeds
7274 WHERE next_fetch_at <= CURRENT_TIMESTAMP
7375 ORDER BY next_fetch_at
@@ -83,7 +85,7 @@ func (db *DB) GetFeedsToFetch(ctx context.Context, limit int) ([]*Feed, error) {
8385 f := &Feed{}
8486 if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,
8587 &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 {
8789 return nil, err
8890 }
8991 feeds = append(feeds, f)
@@ -144,7 +146,8 @@ func (db *DB) DeleteSubscription(ctx context.Context, userDID, feedURL string) e
144146 }
145147
146148 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)
148151 FROM subscriptions s
149152 LEFT JOIN feeds f ON s.feed_url = f.feed_url
150153 WHERE s.user_did = ?`
@@ -166,7 +169,7 @@ func (db *DB) ListSubscriptions(ctx context.Context, userDID, category string, l
166169 var subs []*Subscription
167170 for rows.Next() {
168171 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 {
170173 return nil, err
171174 }
172175 subs = append(subs, s)
@@ -182,11 +185,55 @@ func (db *DB) GetSubscriptionCount(ctx context.Context, userDID string) (int, er
182185 return count, err
183186 }
184187
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+
185232 func (db *DB) ListAllFeeds(ctx context.Context, limit, offset int) ([]*Feed, error) {
186233 rows, err := db.QueryContext(ctx, fmt.Sprintf(`
187234 SELECT feed_url, title, site_url, description, feed_type,
188235 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
190237 FROM feeds
191238 ORDER BY subscriber_count DESC
192239 LIMIT %d OFFSET %d
@@ -201,7 +248,35 @@ func (db *DB) ListAllFeeds(ctx context.Context, limit, offset int) ([]*Feed, err
201248 f := &Feed{}
202249 if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,
203250 &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 {
205280 return nil, err
206281 }
207282 feeds = append(feeds, f)
@@ -22,16 +22,18 @@ type Feed struct {
22 NextFetchAt sql.NullTime22 NextFetchAt sql.NullTime
23 ConsecutiveEmptyFetches int23 ConsecutiveEmptyFetches int
24 ErrorCount int24 ErrorCount int
25+ FaviconURL sql.NullString
25 }26 }
26 27
27 type Subscription struct {28 type Subscription struct {
28- ID int6429+ ID int64
29- UserDID string30+ UserDID string
30- FeedURL string31+ FeedURL string
31- FeedTitle string32+ FeedTitle string
32- Category sql.NullString33+ Category sql.NullString
33- AddedAt sql.NullTime34+ AddedAt sql.NullTime
34- UnreadCount int35+ UnreadCount int
36+ FetchInterval int
35 }37 }
36 38
37 func (db *DB) UpsertFeed(ctx context.Context, feed *Feed) error {39 func (db *DB) UpsertFeed(ctx context.Context, feed *Feed) error {
@@ -52,11 +54,11 @@ func (db *DB) GetFeed(ctx context.Context, feedURL string) (*Feed, error) {
52 err := db.QueryRowContext(ctx, `54 err := db.QueryRowContext(ctx, `
53 SELECT feed_url, title, site_url, description, feed_type,55 SELECT feed_url, title, site_url, description, feed_type,
54 last_fetched_at, last_error, subscriber_count, etag, last_modified,56 last_fetched_at, last_error, subscriber_count, etag, last_modified,
55- fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count57+ fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count, favicon_url
56 FROM feeds WHERE feed_url = ?58 FROM feeds WHERE feed_url = ?
57 `, feedURL).Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,59 `, feedURL).Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,
58 &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified,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 if err != nil {62 if err != nil {
61 return nil, err63 return nil, err
62 }64 }
@@ -67,7 +69,7 @@ func (db *DB) GetFeedsToFetch(ctx context.Context, limit int) ([]*Feed, error) {
67 rows, err := db.QueryContext(ctx, `69 rows, err := db.QueryContext(ctx, `
68 SELECT feed_url, title, site_url, description, feed_type,70 SELECT feed_url, title, site_url, description, feed_type,
69 last_fetched_at, last_error, subscriber_count, etag, last_modified,71 last_fetched_at, last_error, subscriber_count, etag, last_modified,
70- fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count72+ fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count, favicon_url
71 FROM feeds73 FROM feeds
72 WHERE next_fetch_at <= CURRENT_TIMESTAMP74 WHERE next_fetch_at <= CURRENT_TIMESTAMP
73 ORDER BY next_fetch_at75 ORDER BY next_fetch_at
@@ -83,7 +85,7 @@ func (db *DB) GetFeedsToFetch(ctx context.Context, limit int) ([]*Feed, error) {
83 f := &Feed{}85 f := &Feed{}
84 if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,86 if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,
85 &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified,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 return nil, err89 return nil, err
88 }90 }
89 feeds = append(feeds, f)91 feeds = append(feeds, f)
@@ -144,7 +146,8 @@ func (db *DB) DeleteSubscription(ctx context.Context, userDID, feedURL string) e
144 }146 }
145 147
146 func (db *DB) ListSubscriptions(ctx context.Context, userDID, category string, limit, offset int) ([]*Subscription, error) {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_at149+ 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 FROM subscriptions s151 FROM subscriptions s
149 LEFT JOIN feeds f ON s.feed_url = f.feed_url152 LEFT JOIN feeds f ON s.feed_url = f.feed_url
150 WHERE s.user_did = ?`153 WHERE s.user_did = ?`
@@ -166,7 +169,7 @@ func (db *DB) ListSubscriptions(ctx context.Context, userDID, category string, l
166 var subs []*Subscription169 var subs []*Subscription
167 for rows.Next() {170 for rows.Next() {
168 s := &Subscription{}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 return nil, err173 return nil, err
171 }174 }
172 subs = append(subs, s)175 subs = append(subs, s)
@@ -182,11 +185,55 @@ func (db *DB) GetSubscriptionCount(ctx context.Context, userDID string) (int, er
182 return count, err185 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 func (db *DB) ListAllFeeds(ctx context.Context, limit, offset int) ([]*Feed, error) {232 func (db *DB) ListAllFeeds(ctx context.Context, limit, offset int) ([]*Feed, error) {
186 rows, err := db.QueryContext(ctx, fmt.Sprintf(`233 rows, err := db.QueryContext(ctx, fmt.Sprintf(`
187 SELECT feed_url, title, site_url, description, feed_type,234 SELECT feed_url, title, site_url, description, feed_type,
188 last_fetched_at, last_error, subscriber_count, etag, last_modified,235 last_fetched_at, last_error, subscriber_count, etag, last_modified,
189- fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count236+ fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count, favicon_url
190 FROM feeds237 FROM feeds
191 ORDER BY subscriber_count DESC238 ORDER BY subscriber_count DESC
192 LIMIT %d OFFSET %d239 LIMIT %d OFFSET %d
@@ -201,7 +248,35 @@ func (db *DB) ListAllFeeds(ctx context.Context, limit, offset int) ([]*Feed, err
201 f := &Feed{}248 f := &Feed{}
202 if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,249 if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,
203 &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified,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 return nil, err280 return nil, err
206 }281 }
207 feeds = append(feeds, f)282 feeds = append(feeds, f)
added internal/db/oauth_store.go +114 -0
new file mode 100644
@@ -0,0 +1,114 @@
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+}
new file mode 100644
@@ -0,0 +1,114 @@
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+}
added internal/feed/discover.go +198 -0
new file mode 100644
@@ -0,0 +1,198 @@
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+}
new file mode 100644
@@ -0,0 +1,198 @@
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+}
modified internal/server/articles_handler.go +0 -48
@@ -13,17 +13,6 @@ import (
1313 "pkg.rbrt.fr/glean/internal/db"
1414 )
1515
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-
2716 func writeLikeButton(w http.ResponseWriter, articleID int64, liked bool, count int) {
2817 cls := "text-gray-300 hover:text-red-500"
2918 if liked {
@@ -127,43 +116,6 @@ func (s *Server) handleMarkUnread(w http.ResponseWriter, r *http.Request) {
127116 writeReadButton(w, id, false)
128117 }
129118
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-}
166-
167119 func (s *Server) handleLikeArticle(w http.ResponseWriter, r *http.Request) {
168120 user := currentUser(r)
169121 id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
@@ -13,17 +13,6 @@ import (
13 "pkg.rbrt.fr/glean/internal/db"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 func writeLikeButton(w http.ResponseWriter, articleID int64, liked bool, count int) {16 func writeLikeButton(w http.ResponseWriter, articleID int64, liked bool, count int) {
28 cls := "text-gray-300 hover:text-red-500"17 cls := "text-gray-300 hover:text-red-500"
29 if liked {18 if liked {
@@ -127,43 +116,6 @@ func (s *Server) handleMarkUnread(w http.ResponseWriter, r *http.Request) {
127 writeReadButton(w, id, false)116 writeReadButton(w, id, false)
128 }117 }
129 118
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-}
166-
167 func (s *Server) handleLikeArticle(w http.ResponseWriter, r *http.Request) {119 func (s *Server) handleLikeArticle(w http.ResponseWriter, r *http.Request) {
168 user := currentUser(r)120 user := currentUser(r)
169 id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)121 id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
modified internal/server/auth_handler.go +159 -1
@@ -1,7 +1,14 @@
11 package server
22
33 import (
4+ "encoding/json"
5+ "fmt"
46 "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"
512
613 "pkg.rbrt.fr/glean/internal/atproto"
714 )
@@ -10,8 +17,44 @@ func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
1017 s.render(w, r, "login.html", map[string]any{})
1118 }
1219
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+
1349 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")
1558 if handle == "" {
1659 http.Error(w, "handle required", http.StatusBadRequest)
1760 return
@@ -35,7 +78,122 @@ func (s *Server) handleAuthCallback(w http.ResponseWriter, r *http.Request) {
3578 http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
3679 }
3780
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+
38161 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+ }
39169 s.clearUserSession(w)
40170 http.Redirect(w, r, "/", http.StatusSeeOther)
41171 }
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+}
@@ -1,7 +1,14 @@
1 package server1 package server
2 2
3 import (3 import (
4+ "encoding/json"
5+ "fmt"
4 "net/http"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 "pkg.rbrt.fr/glean/internal/atproto"13 "pkg.rbrt.fr/glean/internal/atproto"
7 )14 )
@@ -10,8 +17,44 @@ func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
10 s.render(w, r, "login.html", map[string]any{})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 func (s *Server) handleAuthCallback(w http.ResponseWriter, r *http.Request) {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 if handle == "" {58 if handle == "" {
16 http.Error(w, "handle required", http.StatusBadRequest)59 http.Error(w, "handle required", http.StatusBadRequest)
17 return60 return
@@ -35,7 +78,122 @@ func (s *Server) handleAuthCallback(w http.ResponseWriter, r *http.Request) {
35 http.Redirect(w, r, "/dashboard", http.StatusSeeOther)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 func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) {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 s.clearUserSession(w)169 s.clearUserSession(w)
40 http.Redirect(w, r, "/", http.StatusSeeOther)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+}
modified internal/server/discover_handler.go +7 -5
@@ -6,12 +6,14 @@ func (s *Server) handleDiscover(w http.ResponseWriter, r *http.Request) {
66 user := currentUser(r)
77 feedRecs, _ := s.db.GetFeedRecommendations(r.Context(), user.DID, 20)
88 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)
1011 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,
1517 })
1618 }
1719
@@ -6,12 +6,14 @@ func (s *Server) handleDiscover(w http.ResponseWriter, r *http.Request) {
6 user := currentUser(r)6 user := currentUser(r)
7 feedRecs, _ := s.db.GetFeedRecommendations(r.Context(), user.DID, 20)7 feedRecs, _ := s.db.GetFeedRecommendations(r.Context(), user.DID, 20)
8 people, _ := s.db.GetPeopleRecommendations(r.Context(), user.DID, 20)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 s.render(w, r, "discover.html", map[string]any{11 s.render(w, r, "discover.html", map[string]any{
11- "User": user,12+ "User": user,
12- "FeedRecommendations": feedRecs,13+ "FeedRecommendations": feedRecs,
13- "PeopleRecommendations": people,14+ "PeopleRecommendations": people,
14- "PopularFeeds": popular,15+ "PopularFeeds": popular,
16+ "ArticleRecommendations": articleRecs,
15 })17 })
16 }18 }
17 19
modified internal/server/feeds_handler.go +67 -0
@@ -1,9 +1,12 @@
11 package server
22
33 import (
4+ "context"
45 "database/sql"
6+ "encoding/json"
57 "log/slog"
68 "net/http"
9+ "strconv"
710 "time"
811
912 "pkg.rbrt.fr/glean/internal/atproto"
@@ -18,6 +21,7 @@ func (s *Server) handleFeeds(w http.ResponseWriter, r *http.Request) {
1821 allSubs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 100, 0)
1922 feedRecs, _ := s.db.GetFeedRecommendations(r.Context(), user.DID, 10)
2023 peopleRecs, _ := s.db.GetPeopleRecommendations(r.Context(), user.DID, 5)
24+ deadFeeds, _ := s.db.ListDeadFeeds(r.Context(), user.DID, 7)
2125
2226 seen := make(map[string]bool)
2327 var categories []string
@@ -35,6 +39,7 @@ func (s *Server) handleFeeds(w http.ResponseWriter, r *http.Request) {
3539 "Category": category,
3640 "FeedRecommendations": feedRecs,
3741 "PeopleRecommendations": peopleRecs,
42+ "DeadFeeds": deadFeeds,
3843 })
3944 }
4045
@@ -68,6 +73,16 @@ func (s *Server) handleAddFeed(w http.ResponseWriter, r *http.Request) {
6873 http.Error(w, err.Error(), http.StatusInternalServerError)
6974 return
7075 }
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+ }()
85+ }
7186 }
7287
7388 if err := s.db.CreateSubscription(r.Context(), user.DID, feedURL, category); err != nil {
@@ -233,6 +248,58 @@ func (s *Server) handleRefreshFeeds(w http.ResponseWriter, r *http.Request) {
233248 })
234249 }
235250
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
301+}
302+
236303 func nullString(s string) sql.NullString {
237304 return sql.NullString{String: s, Valid: s != ""}
238305 }
@@ -1,9 +1,12 @@
1 package server1 package server
2 2
3 import (3 import (
4+ "context"
4 "database/sql"5 "database/sql"
6+ "encoding/json"
5 "log/slog"7 "log/slog"
6 "net/http"8 "net/http"
9+ "strconv"
7 "time"10 "time"
8 11
9 "pkg.rbrt.fr/glean/internal/atproto"12 "pkg.rbrt.fr/glean/internal/atproto"
@@ -18,6 +21,7 @@ func (s *Server) handleFeeds(w http.ResponseWriter, r *http.Request) {
18 allSubs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 100, 0)21 allSubs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 100, 0)
19 feedRecs, _ := s.db.GetFeedRecommendations(r.Context(), user.DID, 10)22 feedRecs, _ := s.db.GetFeedRecommendations(r.Context(), user.DID, 10)
20 peopleRecs, _ := s.db.GetPeopleRecommendations(r.Context(), user.DID, 5)23 peopleRecs, _ := s.db.GetPeopleRecommendations(r.Context(), user.DID, 5)
24+ deadFeeds, _ := s.db.ListDeadFeeds(r.Context(), user.DID, 7)
21 25
22 seen := make(map[string]bool)26 seen := make(map[string]bool)
23 var categories []string27 var categories []string
@@ -35,6 +39,7 @@ func (s *Server) handleFeeds(w http.ResponseWriter, r *http.Request) {
35 "Category": category,39 "Category": category,
36 "FeedRecommendations": feedRecs,40 "FeedRecommendations": feedRecs,
37 "PeopleRecommendations": peopleRecs,41 "PeopleRecommendations": peopleRecs,
42+ "DeadFeeds": deadFeeds,
38 })43 })
39 }44 }
40 45
@@ -68,6 +73,16 @@ func (s *Server) handleAddFeed(w http.ResponseWriter, r *http.Request) {
68 http.Error(w, err.Error(), http.StatusInternalServerError)73 http.Error(w, err.Error(), http.StatusInternalServerError)
69 return74 return
70 }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+ }()
85+ }
71 }86 }
72 87
73 if err := s.db.CreateSubscription(r.Context(), user.DID, feedURL, category); err != nil {88 if err := s.db.CreateSubscription(r.Context(), user.DID, feedURL, category); err != nil {
@@ -233,6 +248,58 @@ func (s *Server) handleRefreshFeeds(w http.ResponseWriter, r *http.Request) {
233 })248 })
234 }249 }
235 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
301+}
302+
236 func nullString(s string) sql.NullString {303 func nullString(s string) sql.NullString {
237 return sql.NullString{String: s, Valid: s != ""}304 return sql.NullString{String: s, Valid: s != ""}
238 }305 }
modified internal/server/server.go +54 -16
@@ -2,6 +2,7 @@ package server
22
33 import (
44 "bytes"
5+ "context"
56 "html/template"
67 "log/slog"
78 "net/http"
@@ -13,6 +14,9 @@ import (
1314 "github.com/go-chi/chi/v5/middleware"
1415 "github.com/go-chi/cors"
1516
17+ oauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
18+ "github.com/bluesky-social/indigo/atproto/syntax"
19+
1620 "pkg.rbrt.fr/glean/internal/atproto"
1721 "pkg.rbrt.fr/glean/internal/db"
1822 "pkg.rbrt.fr/glean/internal/feed"
@@ -24,21 +28,35 @@ func splitString(s, sep string) []string {
2428 }
2529
2630 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
3340 }
3441
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+
3651 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,
4260 }
4361
4462 s.setupMiddleware()
@@ -79,6 +97,8 @@ func (s *Server) setupRoutes() {
7997 r.Get("/opml/download", s.handleOPMLDownload)
8098 r.Post("/refresh", s.handleRefreshFeeds)
8199 r.Get("/list", s.handleFeedList)
100+ r.Post("/set-interval", s.handleUpdateFeedInterval)
101+ r.Get("/discover-url", s.handleDiscoverFeedURL)
82102 })
83103
84104 s.router.Route("/articles", func(r chi.Router) {
@@ -87,8 +107,6 @@ func (s *Server) setupRoutes() {
87107 r.Get("/{id}", s.handleArticleDetail)
88108 r.Post("/{id}/read", s.handleMarkRead)
89109 r.Post("/{id}/unread", s.handleMarkUnread)
90- r.Post("/{id}/star", s.handleStar)
91- r.Post("/{id}/unstar", s.handleUnstar)
92110 r.Post("/{id}/like", s.handleLikeArticle)
93111 r.Post("/mark-all-read", s.handleMarkAllRead)
94112 })
@@ -114,8 +132,10 @@ func (s *Server) setupRoutes() {
114132 })
115133
116134 s.router.Get("/auth/login", s.handleAuthLogin)
135+ s.router.Post("/auth/start", s.handleAuthStart)
117136 s.router.Get("/auth/callback", s.handleAuthCallback)
118137 s.router.Post("/auth/logout", s.handleAuthLogout)
138+ s.router.Get("/oauth/client-metadata", s.handleOAuthClientMetadata)
119139
120140 xrpc := atproto.NewXRPCHandler(s.db.DB)
121141 s.router.Get("/xrpc/at.glean.listSubscriptions", xrpc.ListSubscriptions)
@@ -200,10 +220,28 @@ func (s *Server) loadTemplates() {
200220
201221 func (s *Server) pdsClientForUser(r *http.Request) *atproto.Client {
202222 session := s.getSessionData(r)
203- if session == nil || session.AccessToken == "" || session.PDSURL == "" {
223+ if session == nil {
204224 return nil
205225 }
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
207245 }
208246
209247 func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -2,6 +2,7 @@ package server
2 2
3 import (3 import (
4 "bytes"4 "bytes"
5+ "context"
5 "html/template"6 "html/template"
6 "log/slog"7 "log/slog"
7 "net/http"8 "net/http"
@@ -13,6 +14,9 @@ import (
13 "github.com/go-chi/chi/v5/middleware"14 "github.com/go-chi/chi/v5/middleware"
14 "github.com/go-chi/cors"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 "pkg.rbrt.fr/glean/internal/atproto"20 "pkg.rbrt.fr/glean/internal/atproto"
17 "pkg.rbrt.fr/glean/internal/db"21 "pkg.rbrt.fr/glean/internal/db"
18 "pkg.rbrt.fr/glean/internal/feed"22 "pkg.rbrt.fr/glean/internal/feed"
@@ -24,21 +28,35 @@ func splitString(s, sep string) []string {
24 }28 }
25 29
26 type Server struct {30 type Server struct {
27- db *db.DB31+ db *db.DB
28- router *chi.Mux32+ router *chi.Mux
29- templates *template.Template33+ templates *template.Template
30- logger *slog.Logger34+ logger *slog.Logger
31- oauth *atproto.OAuthConfig35+ oauth *oauth.ClientApp
32- fetcher *feed.Fetcher36+ 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 s := &Server{51 s := &Server{
37- db: database,52+ db: database,
38- router: chi.NewRouter(),53+ router: chi.NewRouter(),
39- logger: logger,54+ logger: logger,
40- oauth: oauth,55+ oauth: oauthClient,
41- fetcher: feed.NewFetcher(),56+ oauthStore: oauthStore,
57+ fetcher: feed.NewFetcher(),
58+ clientID: clientID,
59+ callbackURL: callbackURL,
42 }60 }
43 61
44 s.setupMiddleware()62 s.setupMiddleware()
@@ -79,6 +97,8 @@ func (s *Server) setupRoutes() {
79 r.Get("/opml/download", s.handleOPMLDownload)97 r.Get("/opml/download", s.handleOPMLDownload)
80 r.Post("/refresh", s.handleRefreshFeeds)98 r.Post("/refresh", s.handleRefreshFeeds)
81 r.Get("/list", s.handleFeedList)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 s.router.Route("/articles", func(r chi.Router) {104 s.router.Route("/articles", func(r chi.Router) {
@@ -87,8 +107,6 @@ func (s *Server) setupRoutes() {
87 r.Get("/{id}", s.handleArticleDetail)107 r.Get("/{id}", s.handleArticleDetail)
88 r.Post("/{id}/read", s.handleMarkRead)108 r.Post("/{id}/read", s.handleMarkRead)
89 r.Post("/{id}/unread", s.handleMarkUnread)109 r.Post("/{id}/unread", s.handleMarkUnread)
90- r.Post("/{id}/star", s.handleStar)
91- r.Post("/{id}/unstar", s.handleUnstar)
92 r.Post("/{id}/like", s.handleLikeArticle)110 r.Post("/{id}/like", s.handleLikeArticle)
93 r.Post("/mark-all-read", s.handleMarkAllRead)111 r.Post("/mark-all-read", s.handleMarkAllRead)
94 })112 })
@@ -114,8 +132,10 @@ func (s *Server) setupRoutes() {
114 })132 })
115 133
116 s.router.Get("/auth/login", s.handleAuthLogin)134 s.router.Get("/auth/login", s.handleAuthLogin)
135+ s.router.Post("/auth/start", s.handleAuthStart)
117 s.router.Get("/auth/callback", s.handleAuthCallback)136 s.router.Get("/auth/callback", s.handleAuthCallback)
118 s.router.Post("/auth/logout", s.handleAuthLogout)137 s.router.Post("/auth/logout", s.handleAuthLogout)
138+ s.router.Get("/oauth/client-metadata", s.handleOAuthClientMetadata)
119 139
120 xrpc := atproto.NewXRPCHandler(s.db.DB)140 xrpc := atproto.NewXRPCHandler(s.db.DB)
121 s.router.Get("/xrpc/at.glean.listSubscriptions", xrpc.ListSubscriptions)141 s.router.Get("/xrpc/at.glean.listSubscriptions", xrpc.ListSubscriptions)
@@ -200,10 +220,28 @@ func (s *Server) loadTemplates() {
200 220
201 func (s *Server) pdsClientForUser(r *http.Request) *atproto.Client {221 func (s *Server) pdsClientForUser(r *http.Request) *atproto.Client {
202 session := s.getSessionData(r)222 session := s.getSessionData(r)
203- if session == nil || session.AccessToken == "" || session.PDSURL == "" {223+ if session == nil {
204 return nil224 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 func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {247 func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
modified internal/server/session.go +5 -3
@@ -72,9 +72,11 @@ func (s *Server) clearUserSession(w http.ResponseWriter) {
7272 }
7373
7474 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"`
7880 }
7981
8082 func (s *Server) getSessionData(r *http.Request) *sessionData {
@@ -72,9 +72,11 @@ func (s *Server) clearUserSession(w http.ResponseWriter) {
72 }72 }
73 73
74 type sessionData struct {74 type sessionData struct {
75- DID string `json:"did"`75+ DID string `json:"did"`
76- PDSURL string `json:"pds_url,omitempty"`76+ PDSURL string `json:"pds_url,omitempty"`
77- AccessToken string `json:"access_token,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 func (s *Server) getSessionData(r *http.Request) *sessionData {82 func (s *Server) getSessionData(r *http.Request) *sessionData {
modified internal/tmpl/annotations.html +1 -2
@@ -1,7 +1,6 @@
11 {{define "annotations.html"}}
2-<div class="mb-6">
2+<div class="flex items-center justify-between mb-6">
33 <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>
54 </div>
65
76 {{if .ArticleURL}}
@@ -1,7 +1,6 @@
1 {{define "annotations.html"}}1 {{define "annotations.html"}}
2-<div class="mb-6">2+<div class="flex items-center justify-between mb-6">
3 <h1 class="text-2xl font-bold font-title text-spot-text">Annotations</h1>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 </div>4 </div>
6 5
7 {{if .ArticleURL}}6 {{if .ArticleURL}}
modified internal/tmpl/article_detail.html +31 -11
@@ -12,7 +12,10 @@
1212 {{if .Article.Author.Valid}}<span>{{.Article.Author.String}}</span>{{end}}
1313 {{if .Article.Published.Valid}}<span>{{.Article.Published.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}}
1414 {{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>
1619 {{end}}
1720 </div>
1821
@@ -26,7 +29,6 @@
2629 <a href="https://bsky.app/intent/compose?text={{.Article.Title}}%20{{if .Article.URL.Valid}}{{.Article.URL.String}}{{end}}"
2730 target="_blank" rel="noopener noreferrer"
2831 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- 🦋
3032 Share
3133 </a>
3234
@@ -36,6 +38,11 @@
3638 class="text-xs border border-spot-outline text-spot-text rounded-pill px-3 py-1 hover:border-spot-text uppercase tracking-button transition">
3739 {{if .ReadState.IsRead}}Mark unread{{else}}Mark read{{end}}
3840 </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>
3946 </div>
4047
4148 <hr class="my-6 border-spot-divider-30">
@@ -88,15 +95,28 @@
8895 </div>
8996
9097 <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;
99103 }
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+})();
101121 </script>
102122 {{end}}
@@ -12,7 +12,10 @@
12 {{if .Article.Author.Valid}}<span>{{.Article.Author.String}}</span>{{end}}12 {{if .Article.Author.Valid}}<span>{{.Article.Author.String}}</span>{{end}}
13 {{if .Article.Published.Valid}}<span>{{.Article.Published.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}}13 {{if .Article.Published.Valid}}<span>{{.Article.Published.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}}
14 {{if .Feed}}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 {{end}}19 {{end}}
17 </div>20 </div>
18 21
@@ -26,7 +29,6 @@
26 <a href="https://bsky.app/intent/compose?text={{.Article.Title}}%20{{if .Article.URL.Valid}}{{.Article.URL.String}}{{end}}"29 <a href="https://bsky.app/intent/compose?text={{.Article.Title}}%20{{if .Article.URL.Valid}}{{.Article.URL.String}}{{end}}"
27 target="_blank" rel="noopener noreferrer"30 target="_blank" rel="noopener noreferrer"
28 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">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 Share32 Share
31 </a>33 </a>
32 34
@@ -36,6 +38,11 @@
36 class="text-xs border border-spot-outline text-spot-text rounded-pill px-3 py-1 hover:border-spot-text uppercase tracking-button transition">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 {{if .ReadState.IsRead}}Mark unread{{else}}Mark read{{end}}39 {{if .ReadState.IsRead}}Mark unread{{else}}Mark read{{end}}
38 </button>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 </div>46 </div>
40 47
41 <hr class="my-6 border-spot-divider-30">48 <hr class="my-6 border-spot-divider-30">
@@ -88,15 +95,28 @@
88 </div>95 </div>
89 96
90 <script>97 <script>
91-document.addEventListener('keydown', function(e) {98+(function() {
92- if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;99+ var readScrollPos = 0;
93- if (e.key === 'l') {100+ var articleBody = document.querySelector('.article-body');
94- document.getElementById('like-btn') && document.getElementById('like-btn').click();101+ if (articleBody) {
95- } else if (e.key === 'm') {102+ readScrollPos = window.scrollY;
96- document.getElementById('read-btn') && document.getElementById('read-btn').click();
97- } else if (e.key === 'Escape') {
98- window.location.href = '/articles';
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 </script>121 </script>
102 {{end}}122 {{end}}
modified internal/tmpl/articles.html +42 -0
@@ -26,4 +26,46 @@
2626 </button>
2727 </div>
2828 {{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>
2971 {{end}}
@@ -26,4 +26,46 @@
26 </button>26 </button>
27 </div>27 </div>
28 {{end}}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 {{end}}71 {{end}}
modified internal/tmpl/base.html +10 -10
@@ -163,18 +163,18 @@
163163 </a>
164164 </nav>
165165 <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">
167167 {{if .User.AvatarURL.Valid}}<img src="{{.User.AvatarURL.String}}" class="w-8 h-8 rounded-full">{{end}}
168168 <div class="min-w-0 flex-1">
169169 <div class="text-sm font-bold truncate text-spot-text">@{{.User.Handle}}</div>
170170 </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>
178178 </div>
179179 </aside>
180180
@@ -208,10 +208,10 @@
208208 {{if .User}}
209209 <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">
210210 <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">
212212 {{if .User.AvatarURL.Valid}}<img src="{{.User.AvatarURL.String}}" class="w-7 h-7 rounded-full">{{end}}
213213 <span class="text-xs text-spot-secondary">@{{.User.Handle}}</span>
214- </div>
214+ </a>
215215 </div>
216216 {{end}}
217217 <div class="max-w-6xl mx-auto px-4 lg:px-8 py-6 flex-1">
@@ -163,18 +163,18 @@
163 </a>163 </a>
164 </nav>164 </nav>
165 <div class="mt-auto pt-4 border-t border-spot-divider-30">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 {{if .User.AvatarURL.Valid}}<img src="{{.User.AvatarURL.String}}" class="w-8 h-8 rounded-full">{{end}}167 {{if .User.AvatarURL.Valid}}<img src="{{.User.AvatarURL.String}}" class="w-8 h-8 rounded-full">{{end}}
168 <div class="min-w-0 flex-1">168 <div class="min-w-0 flex-1">
169 <div class="text-sm font-bold truncate text-spot-text">@{{.User.Handle}}</div>169 <div class="text-sm font-bold truncate text-spot-text">@{{.User.Handle}}</div>
170 </div>170 </div>
171- <form method="POST" action="/auth/logout">171+ </a>
172- {{csrfInput .CSRFToken}}172+ <form method="POST" action="/auth/logout">
173- <button type="submit" class="text-spot-secondary hover:text-spot-red transition" title="Logout">173+ {{csrfInput .CSRFToken}}
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>174+ <button type="submit" class="text-spot-secondary hover:text-spot-red transition" title="Logout">
175- </button>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- </form>176+ </button>
177- </div>177+ </form>
178 </div>178 </div>
179 </aside>179 </aside>
180 180
@@ -208,10 +208,10 @@
208 {{if .User}}208 {{if .User}}
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">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 <a href="/" class="text-spot-purple font-bold text-lg font-title">Glean</a>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 {{if .User.AvatarURL.Valid}}<img src="{{.User.AvatarURL.String}}" class="w-7 h-7 rounded-full">{{end}}212 {{if .User.AvatarURL.Valid}}<img src="{{.User.AvatarURL.String}}" class="w-7 h-7 rounded-full">{{end}}
213 <span class="text-xs text-spot-secondary">@{{.User.Handle}}</span>213 <span class="text-xs text-spot-secondary">@{{.User.Handle}}</span>
214- </div>214+ </a>
215 </div>215 </div>
216 {{end}}216 {{end}}
217 <div class="max-w-6xl mx-auto px-4 lg:px-8 py-6 flex-1">217 <div class="max-w-6xl mx-auto px-4 lg:px-8 py-6 flex-1">
modified internal/tmpl/discover.html +20 -2
@@ -1,9 +1,27 @@
11 {{define "discover.html"}}
2-<div class="mb-6">
2+<div class="flex items-center justify-between mb-6">
33 <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>
54 </div>
65
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}}
24+
725 <div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
826 <div>
927 <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended feeds</h2>
@@ -1,9 +1,27 @@
1 {{define "discover.html"}}1 {{define "discover.html"}}
2-<div class="mb-6">2+<div class="flex items-center justify-between mb-6">
3 <h1 class="text-2xl font-bold font-title text-spot-text">Discover</h1>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 </div>4 </div>
6 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}}
24+
7 <div class="grid grid-cols-1 lg:grid-cols-2 gap-8">25 <div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
8 <div>26 <div>
9 <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended feeds</h2>27 <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended feeds</h2>
modified internal/tmpl/feeds.html +31 -0
@@ -11,6 +11,23 @@
1111 </div>
1212 </div>
1313
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+
1431 <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
1532 <div class="lg:col-span-2">
1633 {{if .Categories}}
@@ -31,6 +48,20 @@
3148 </div>
3249 <div class="flex items-center gap-3">
3350 {{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>
3465 <button hx-delete="/feeds/{{.FeedURL}}" hx-target="closest .px-5" hx-swap="outerHTML swap:0.3s"
3566 hx-confirm="Unsubscribe from this feed?"
3667 class="text-sm text-spot-secondary hover:text-spot-red transition">Unsubscribe</button>
@@ -11,6 +11,23 @@
11 </div>11 </div>
12 </div>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 <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">31 <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
15 <div class="lg:col-span-2">32 <div class="lg:col-span-2">
16 {{if .Categories}}33 {{if .Categories}}
@@ -31,6 +48,20 @@
31 </div>48 </div>
32 <div class="flex items-center gap-3">49 <div class="flex items-center gap-3">
33 {{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}}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 <button hx-delete="/feeds/{{.FeedURL}}" hx-target="closest .px-5" hx-swap="outerHTML swap:0.3s"65 <button hx-delete="/feeds/{{.FeedURL}}" hx-target="closest .px-5" hx-swap="outerHTML swap:0.3s"
35 hx-confirm="Unsubscribe from this feed?"66 hx-confirm="Unsubscribe from this feed?"
36 class="text-sm text-spot-secondary hover:text-spot-red transition">Unsubscribe</button>67 class="text-sm text-spot-secondary hover:text-spot-red transition">Unsubscribe</button>
modified internal/tmpl/login.html +3 -2
@@ -2,7 +2,8 @@
22 <div class="max-w-md mx-auto mt-20">
33 <div class="bg-spot-surface rounded-lg shadow-spot-heavy p-8">
44 <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}}
67 <label class="block text-sm font-bold text-spot-secondary mb-2 uppercase tracking-button">Bluesky Handle</label>
78 <input type="text" name="handle" placeholder="you.bsky.social"
89 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,7 +12,7 @@
1112 Sign in with Bluesky
1213 </button>
1314 </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>
1516 </div>
1617 </div>
1718 {{end}}
@@ -2,7 +2,8 @@
2 <div class="max-w-md mx-auto mt-20">2 <div class="max-w-md mx-auto mt-20">
3 <div class="bg-spot-surface rounded-lg shadow-spot-heavy p-8">3 <div class="bg-spot-surface rounded-lg shadow-spot-heavy p-8">
4 <h1 class="text-2xl font-bold font-title text-spot-text mb-6">Sign in to Glean</h1>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 <label class="block text-sm font-bold text-spot-secondary mb-2 uppercase tracking-button">Bluesky Handle</label>7 <label class="block text-sm font-bold text-spot-secondary mb-2 uppercase tracking-button">Bluesky Handle</label>
7 <input type="text" name="handle" placeholder="you.bsky.social"8 <input type="text" name="handle" placeholder="you.bsky.social"
8 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"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,7 +12,7 @@
11 Sign in with Bluesky12 Sign in with Bluesky
12 </button>13 </button>
13 </form>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 </div>16 </div>
16 </div>17 </div>
17 {{end}}18 {{end}}
modified internal/tmpl/partials/article-card.html +1 -1
@@ -1,5 +1,5 @@
11 {{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">
33 <div class="flex items-start justify-between gap-4">
44 <div class="min-w-0 flex-1">
55 <a href="/articles/{{.ID}}" class="font-bold text-spot-text hover:text-spot-purple transition text-lg leading-tight">{{.Title}}</a>
@@ -1,5 +1,5 @@
1 {{define "article-card.html"}}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 <div class="flex items-start justify-between gap-4">3 <div class="flex items-start justify-between gap-4">
4 <div class="min-w-0 flex-1">4 <div class="min-w-0 flex-1">
5 <a href="/articles/{{.ID}}" class="font-bold text-spot-text hover:text-spot-purple transition text-lg leading-tight">{{.Title}}</a>5 <a href="/articles/{{.ID}}" class="font-bold text-spot-text hover:text-spot-purple transition text-lg leading-tight">{{.Title}}</a>
modified main.go +3 -5
@@ -33,12 +33,10 @@ func main() {
3333 }
3434 defer database.Close()
3535
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", "")
4038
41- srv := server.New(database, oauth, logger)
39+ srv := server.New(database, clientID, callbackURL, logger)
4240
4341 storeAdapter := db.NewFeedStoreAdapter(database)
4442 scheduler := feed.NewScheduler(storeAdapter, logger)
@@ -33,12 +33,10 @@ func main() {
33 }33 }
34 defer database.Close()34 defer database.Close()
35 35
36- oauth := &atproto.OAuthConfig{36+ clientID := envOr("GLEAN_OAUTH_CLIENT_ID", "")
37- ClientID: envOr("GLEAN_OAUTH_CLIENT_ID", ""),37+ callbackURL := envOr("GLEAN_OAUTH_REDIRECT_URL", "")
38- RedirectURL: envOr("GLEAN_OAUTH_REDIRECT_URL", ""),
39- }
40 38
41- srv := server.New(database, oauth, logger)39+ srv := server.New(database, clientID, callbackURL, logger)
42 40
43 storeAdapter := db.NewFeedStoreAdapter(database)41 storeAdapter := db.NewFeedStoreAdapter(database)
44 scheduler := feed.NewScheduler(storeAdapter, logger)42 scheduler := feed.NewScheduler(storeAdapter, logger)