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