Add support for fetching user profile display name and avatarUnverified
5641d4d parent: c613ecc added
.env.example +6 -0 | new file mode 100644 | ||
| @@ -0,0 +1,6 @@ | ||
| 1 | +GLEAN_ADDR=:8080 | |
| 2 | +GLEAN_DB=glean.db | |
| 3 | +GLEAN_RELAY=wss://bsky.network | |
| 4 | +# Leave empty for localhost OAuth (development) | |
| 5 | +# GLEAN_OAUTH_CLIENT_ID=https://glean.at/oauth/client-metadata | |
| 6 | +# GLEAN_OAUTH_REDIRECT_URL=https://glean.at/auth/callback | |
| new file mode 100644 | |||
| @@ -0,0 +1,6 @@ | |||
| 1 | +GLEAN_ADDR=:8080 | ||
| 2 | +GLEAN_DB=glean.db | ||
| 3 | +GLEAN_RELAY=wss://bsky.network | ||
| 4 | +# Leave empty for localhost OAuth (development) | ||
| 5 | +# GLEAN_OAUTH_CLIENT_ID=https://glean.at/oauth/client-metadata | ||
| 6 | +# GLEAN_OAUTH_REDIRECT_URL=https://glean.at/auth/callback | ||
added
Makefile +13 -0 | new file mode 100644 | ||
| @@ -0,0 +1,13 @@ | ||
| 1 | +.PHONY: dev run build test clean | |
| 2 | + | |
| 3 | +dev: | |
| 4 | + @if [ -f .env ]; then set -a; . ./.env; set +a; fi && go run . | |
| 5 | + | |
| 6 | +build: | |
| 7 | + go build -o glean . | |
| 8 | + | |
| 9 | +test: | |
| 10 | + go test ./... | |
| 11 | + | |
| 12 | +clean: | |
| 13 | + rm -f glean glean.db | |
| new file mode 100644 | |||
| @@ -0,0 +1,13 @@ | |||
| 1 | +.PHONY: dev run build test clean | ||
| 2 | + | ||
| 3 | +dev: | ||
| 4 | + @if [ -f .env ]; then set -a; . ./.env; set +a; fi && go run . | ||
| 5 | + | ||
| 6 | +build: | ||
| 7 | + go build -o glean . | ||
| 8 | + | ||
| 9 | +test: | ||
| 10 | + go test ./... | ||
| 11 | + | ||
| 12 | +clean: | ||
| 13 | + rm -f glean glean.db | ||
modified
internal/db/social.go +17 -15 | @@ -8,17 +8,18 @@ import ( | ||
| 8 | 8 | ) |
| 9 | 9 | |
| 10 | 10 | type Annotation struct { |
| 11 | - ID int64 | |
| 12 | - URI string | |
| 13 | - AuthorDID string | |
| 14 | - FeedURL string | |
| 15 | - ArticleURL string | |
| 16 | - Quote sql.NullString | |
| 17 | - Note sql.NullString | |
| 18 | - Tags sql.NullString | |
| 19 | - Rating sql.NullInt64 | |
| 20 | - CreatedAt sql.NullTime | |
| 21 | - CID sql.NullString | |
| 11 | + ID int64 | |
| 12 | + URI string | |
| 13 | + AuthorDID string | |
| 14 | + AuthorHandle string | |
| 15 | + FeedURL string | |
| 16 | + ArticleURL string | |
| 17 | + Quote sql.NullString | |
| 18 | + Note sql.NullString | |
| 19 | + Tags sql.NullString | |
| 20 | + Rating sql.NullInt64 | |
| 21 | + CreatedAt sql.NullTime | |
| 22 | + CID sql.NullString | |
| 22 | 23 | } |
| 23 | 24 | |
| 24 | 25 | type Like struct { |
| @@ -61,12 +62,13 @@ func (db *DB) ListAnnotations(ctx context.Context, feedURL, articleURL, authorDI | ||
| 61 | 62 | args = append(args, authorDID) |
| 62 | 63 | } |
| 63 | 64 | |
| 64 | - query := `SELECT id, uri, author_did, feed_url, article_url, quote, note, tags, rating, created_at, cid | |
| 65 | - FROM annotations` | |
| 65 | + query := `SELECT a.id, a.uri, a.author_did, COALESCE(u.handle, ''), a.feed_url, a.article_url, a.quote, a.note, a.tags, a.rating, a.created_at, a.cid | |
| 66 | + FROM annotations a | |
| 67 | + LEFT JOIN users u ON a.author_did = u.did` | |
| 66 | 68 | if len(conds) > 0 { |
| 67 | 69 | query += ` WHERE ` + strings.Join(conds, " AND ") |
| 68 | 70 | } |
| 69 | - query += fmt.Sprintf(` ORDER BY created_at DESC LIMIT %d OFFSET %d`, limit, offset) | |
| 71 | + query += fmt.Sprintf(` ORDER BY a.created_at DESC LIMIT %d OFFSET %d`, limit, offset) | |
| 70 | 72 | |
| 71 | 73 | rows, err := db.QueryContext(ctx, query, args...) |
| 72 | 74 | if err != nil { |
| @@ -77,7 +79,7 @@ func (db *DB) ListAnnotations(ctx context.Context, feedURL, articleURL, authorDI | ||
| 77 | 79 | var annotations []*Annotation |
| 78 | 80 | for rows.Next() { |
| 79 | 81 | a := &Annotation{} |
| 80 | - if err := rows.Scan(&a.ID, &a.URI, &a.AuthorDID, &a.FeedURL, &a.ArticleURL, | |
| 82 | + if err := rows.Scan(&a.ID, &a.URI, &a.AuthorDID, &a.AuthorHandle, &a.FeedURL, &a.ArticleURL, | |
| 81 | 83 | &a.Quote, &a.Note, &a.Tags, &a.Rating, &a.CreatedAt, &a.CID); err != nil { |
| 82 | 84 | return nil, err |
| 83 | 85 | } |
| @@ -8,17 +8,18 @@ import ( | |||
| 8 | ) | 8 | ) |
| 9 | 9 | ||
| 10 | type Annotation struct { | 10 | type Annotation struct { |
| 11 | - ID int64 | 11 | + ID int64 |
| 12 | - URI string | 12 | + URI string |
| 13 | - AuthorDID string | 13 | + AuthorDID string |
| 14 | - FeedURL string | 14 | + AuthorHandle string |
| 15 | - ArticleURL string | 15 | + FeedURL string |
| 16 | - Quote sql.NullString | 16 | + ArticleURL string |
| 17 | - Note sql.NullString | 17 | + Quote sql.NullString |
| 18 | - Tags sql.NullString | 18 | + Note sql.NullString |
| 19 | - Rating sql.NullInt64 | 19 | + Tags sql.NullString |
| 20 | - CreatedAt sql.NullTime | 20 | + Rating sql.NullInt64 |
| 21 | - CID sql.NullString | 21 | + CreatedAt sql.NullTime |
| 22 | + CID sql.NullString | ||
| 22 | } | 23 | } |
| 23 | 24 | ||
| 24 | type Like struct { | 25 | type Like struct { |
| @@ -61,12 +62,13 @@ func (db *DB) ListAnnotations(ctx context.Context, feedURL, articleURL, authorDI | |||
| 61 | args = append(args, authorDID) | 62 | args = append(args, authorDID) |
| 62 | } | 63 | } |
| 63 | 64 | ||
| 64 | - query := `SELECT id, uri, author_did, feed_url, article_url, quote, note, tags, rating, created_at, cid | 65 | + query := `SELECT a.id, a.uri, a.author_did, COALESCE(u.handle, ''), a.feed_url, a.article_url, a.quote, a.note, a.tags, a.rating, a.created_at, a.cid |
| 65 | - FROM annotations` | 66 | + FROM annotations a |
| 67 | + LEFT JOIN users u ON a.author_did = u.did` | ||
| 66 | if len(conds) > 0 { | 68 | if len(conds) > 0 { |
| 67 | query += ` WHERE ` + strings.Join(conds, " AND ") | 69 | query += ` WHERE ` + strings.Join(conds, " AND ") |
| 68 | } | 70 | } |
| 69 | - query += fmt.Sprintf(` ORDER BY created_at DESC LIMIT %d OFFSET %d`, limit, offset) | 71 | + query += fmt.Sprintf(` ORDER BY a.created_at DESC LIMIT %d OFFSET %d`, limit, offset) |
| 70 | 72 | ||
| 71 | rows, err := db.QueryContext(ctx, query, args...) | 73 | rows, err := db.QueryContext(ctx, query, args...) |
| 72 | if err != nil { | 74 | if err != nil { |
| @@ -77,7 +79,7 @@ func (db *DB) ListAnnotations(ctx context.Context, feedURL, articleURL, authorDI | |||
| 77 | var annotations []*Annotation | 79 | var annotations []*Annotation |
| 78 | for rows.Next() { | 80 | for rows.Next() { |
| 79 | a := &Annotation{} | 81 | a := &Annotation{} |
| 80 | - if err := rows.Scan(&a.ID, &a.URI, &a.AuthorDID, &a.FeedURL, &a.ArticleURL, | 82 | + if err := rows.Scan(&a.ID, &a.URI, &a.AuthorDID, &a.AuthorHandle, &a.FeedURL, &a.ArticleURL, |
| 81 | &a.Quote, &a.Note, &a.Tags, &a.Rating, &a.CreatedAt, &a.CID); err != nil { | 83 | &a.Quote, &a.Note, &a.Tags, &a.Rating, &a.CreatedAt, &a.CID); err != nil { |
| 82 | return nil, err | 84 | return nil, err |
| 83 | } | 85 | } |
modified
internal/server/auth_handler.go +31 -24 | @@ -1,15 +1,17 @@ | ||
| 1 | 1 | package server |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | + "context" | |
| 4 | 5 | "encoding/json" |
| 5 | 6 | "fmt" |
| 6 | 7 | "net/http" |
| 7 | 8 | "net/url" |
| 8 | 9 | "os" |
| 9 | 10 | |
| 10 | - oauth "github.com/bluesky-social/indigo/atproto/auth/oauth" | |
| 11 | 11 | "github.com/bluesky-social/indigo/atproto/syntax" |
| 12 | 12 | |
| 13 | + oauth "github.com/bluesky-social/indigo/atproto/auth/oauth" | |
| 14 | + | |
| 13 | 15 | "pkg.rbrt.fr/glean/internal/atproto" |
| 14 | 16 | ) |
| 15 | 17 | |
| @@ -92,7 +94,9 @@ func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) { | ||
| 92 | 94 | handle = ident.Handle.String() |
| 93 | 95 | } |
| 94 | 96 | |
| 95 | - user, err := s.db.CreateUser(r.Context(), did, handle, "", "") | |
| 97 | + displayName, avatarURL := s.fetchUserProfile(r.Context(), sessData) | |
| 98 | + | |
| 99 | + user, err := s.db.CreateUser(r.Context(), did, handle, displayName, avatarURL) | |
| 96 | 100 | if err != nil { |
| 97 | 101 | s.logger.Error("failed to create user", "error", err) |
| 98 | 102 | http.Error(w, err.Error(), http.StatusInternalServerError) |
| @@ -123,35 +127,38 @@ func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) { | ||
| 123 | 127 | http.Redirect(w, r, "/dashboard", http.StatusSeeOther) |
| 124 | 128 | } |
| 125 | 129 | |
| 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) | |
| 130 | +func (s *Server) fetchUserProfile(ctx context.Context, sessData *oauth.ClientSessionData) (string, string) { | |
| 131 | + did := sessData.AccountDID.String() | |
| 132 | + | |
| 133 | + session, err := s.oauth.ResumeSession(ctx, sessData.AccountDID, sessData.SessionID) | |
| 134 | + if err != nil { | |
| 135 | + s.logger.Warn("failed to resume session for profile fetch", "error", err) | |
| 136 | + return "", "" | |
| 134 | 137 | } |
| 135 | 138 | |
| 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) | |
| 139 | + var profile struct { | |
| 140 | + DisplayName string `json:"displayName"` | |
| 141 | + Avatar string `json:"avatar"` | |
| 143 | 142 | } |
| 144 | 143 | |
| 145 | - config := oauth.NewPublicConfig(clientID, redirectURL, []string{"atproto"}) | |
| 146 | - meta := config.ClientMetadata() | |
| 144 | + nsid, _ := syntax.ParseNSID("app.bsky.actor.getProfile") | |
| 145 | + if err := session.APIClient().Get(ctx, nsid, map[string]any{"actor": did}, &profile); err != nil { | |
| 146 | + s.logger.Warn("failed to fetch profile from PDS", "error", err, "did", did) | |
| 147 | + return "", "" | |
| 148 | + } | |
| 149 | + return profile.DisplayName, profile.Avatar | |
| 150 | +} | |
| 147 | 151 | |
| 152 | +func (s *Server) handleOAuthClientMetadata(w http.ResponseWriter, r *http.Request) { | |
| 153 | + if s.clientID == "" { | |
| 154 | + http.Error(w, "localhost client", http.StatusNotFound) | |
| 155 | + return | |
| 156 | + } | |
| 157 | + | |
| 158 | + meta := s.oauth.Config.ClientMetadata() | |
| 148 | 159 | name := "Glean" |
| 149 | 160 | meta.ClientName = &name |
| 150 | - | |
| 151 | - uri := clientID | |
| 152 | - if idx := len(uri); idx > 0 && uri[idx-1] == '/' { | |
| 153 | - uri = uri[:idx-1] | |
| 154 | - } | |
| 161 | + uri := s.clientID | |
| 155 | 162 | meta.ClientURI = &uri |
| 156 | 163 | |
| 157 | 164 | w.Header().Set("Content-Type", "application/json") |
| @@ -1,15 +1,17 @@ | |||
| 1 | package server | 1 | package server |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | + "context" | ||
| 4 | "encoding/json" | 5 | "encoding/json" |
| 5 | "fmt" | 6 | "fmt" |
| 6 | "net/http" | 7 | "net/http" |
| 7 | "net/url" | 8 | "net/url" |
| 8 | "os" | 9 | "os" |
| 9 | 10 | ||
| 10 | - oauth "github.com/bluesky-social/indigo/atproto/auth/oauth" | ||
| 11 | "github.com/bluesky-social/indigo/atproto/syntax" | 11 | "github.com/bluesky-social/indigo/atproto/syntax" |
| 12 | 12 | ||
| 13 | + oauth "github.com/bluesky-social/indigo/atproto/auth/oauth" | ||
| 14 | + | ||
| 13 | "pkg.rbrt.fr/glean/internal/atproto" | 15 | "pkg.rbrt.fr/glean/internal/atproto" |
| 14 | ) | 16 | ) |
| 15 | 17 | ||
| @@ -92,7 +94,9 @@ func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) { | |||
| 92 | handle = ident.Handle.String() | 94 | handle = ident.Handle.String() |
| 93 | } | 95 | } |
| 94 | 96 | ||
| 95 | - user, err := s.db.CreateUser(r.Context(), did, handle, "", "") | 97 | + displayName, avatarURL := s.fetchUserProfile(r.Context(), sessData) |
| 98 | + | ||
| 99 | + user, err := s.db.CreateUser(r.Context(), did, handle, displayName, avatarURL) | ||
| 96 | if err != nil { | 100 | if err != nil { |
| 97 | s.logger.Error("failed to create user", "error", err) | 101 | s.logger.Error("failed to create user", "error", err) |
| 98 | http.Error(w, err.Error(), http.StatusInternalServerError) | 102 | http.Error(w, err.Error(), http.StatusInternalServerError) |
| @@ -123,35 +127,38 @@ func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) { | |||
| 123 | http.Redirect(w, r, "/dashboard", http.StatusSeeOther) | 127 | http.Redirect(w, r, "/dashboard", http.StatusSeeOther) |
| 124 | } | 128 | } |
| 125 | 129 | ||
| 126 | -func (s *Server) handleOAuthClientMetadata(w http.ResponseWriter, r *http.Request) { | 130 | +func (s *Server) fetchUserProfile(ctx context.Context, sessData *oauth.ClientSessionData) (string, string) { |
| 127 | - clientID := s.clientID | 131 | + did := sessData.AccountDID.String() |
| 128 | - if clientID == "" { | 132 | + |
| 129 | - scheme := "http" | 133 | + session, err := s.oauth.ResumeSession(ctx, sessData.AccountDID, sessData.SessionID) |
| 130 | - if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { | 134 | + if err != nil { |
| 131 | - scheme = "https" | 135 | + s.logger.Warn("failed to resume session for profile fetch", "error", err) |
| 132 | - } | 136 | + return "", "" |
| 133 | - clientID = fmt.Sprintf("%s://%s/oauth/client-metadata", scheme, r.Host) | ||
| 134 | } | 137 | } |
| 135 | 138 | ||
| 136 | - redirectURL := s.callbackURL | 139 | + var profile struct { |
| 137 | - if redirectURL == "" { | 140 | + DisplayName string `json:"displayName"` |
| 138 | - scheme := "http" | 141 | + Avatar string `json:"avatar"` |
| 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 | } | 142 | } |
| 144 | 143 | ||
| 145 | - config := oauth.NewPublicConfig(clientID, redirectURL, []string{"atproto"}) | 144 | + nsid, _ := syntax.ParseNSID("app.bsky.actor.getProfile") |
| 146 | - meta := config.ClientMetadata() | 145 | + if err := session.APIClient().Get(ctx, nsid, map[string]any{"actor": did}, &profile); err != nil { |
| 146 | + s.logger.Warn("failed to fetch profile from PDS", "error", err, "did", did) | ||
| 147 | + return "", "" | ||
| 148 | + } | ||
| 149 | + return profile.DisplayName, profile.Avatar | ||
| 150 | +} | ||
| 147 | 151 | ||
| 152 | +func (s *Server) handleOAuthClientMetadata(w http.ResponseWriter, r *http.Request) { | ||
| 153 | + if s.clientID == "" { | ||
| 154 | + http.Error(w, "localhost client", http.StatusNotFound) | ||
| 155 | + return | ||
| 156 | + } | ||
| 157 | + | ||
| 158 | + meta := s.oauth.Config.ClientMetadata() | ||
| 148 | name := "Glean" | 159 | name := "Glean" |
| 149 | meta.ClientName = &name | 160 | meta.ClientName = &name |
| 150 | - | 161 | + uri := s.clientID |
| 151 | - uri := clientID | ||
| 152 | - if idx := len(uri); idx > 0 && uri[idx-1] == '/' { | ||
| 153 | - uri = uri[:idx-1] | ||
| 154 | - } | ||
| 155 | meta.ClientURI = &uri | 162 | meta.ClientURI = &uri |
| 156 | 163 | ||
| 157 | w.Header().Set("Content-Type", "application/json") | 164 | w.Header().Set("Content-Type", "application/json") |
modified
internal/server/profile_handler.go +2 -2 | @@ -19,8 +19,8 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) { | ||
| 19 | 19 | subCount, _ := s.db.GetSubscriptionCount(r.Context(), did) |
| 20 | 20 | |
| 21 | 21 | s.render(w, r, "profile.html", map[string]any{ |
| 22 | - "User": s.getUserFromSession(r), | |
| 23 | - "ProfileUser": profileUser, | |
| 22 | + "User": currentUser(r), | |
| 23 | + "ProfileUser": profileUser, | |
| 24 | 24 | "Subscriptions": subs, |
| 25 | 25 | "Annotations": annotations, |
| 26 | 26 | "SubscriptionCount": subCount, |
| @@ -19,8 +19,8 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) { | |||
| 19 | subCount, _ := s.db.GetSubscriptionCount(r.Context(), did) | 19 | subCount, _ := s.db.GetSubscriptionCount(r.Context(), did) |
| 20 | 20 | ||
| 21 | s.render(w, r, "profile.html", map[string]any{ | 21 | s.render(w, r, "profile.html", map[string]any{ |
| 22 | - "User": s.getUserFromSession(r), | 22 | + "User": currentUser(r), |
| 23 | - "ProfileUser": profileUser, | 23 | + "ProfileUser": profileUser, |
| 24 | "Subscriptions": subs, | 24 | "Subscriptions": subs, |
| 25 | "Annotations": annotations, | 25 | "Annotations": annotations, |
| 26 | "SubscriptionCount": subCount, | 26 | "SubscriptionCount": subCount, |
modified
internal/server/server.go +17 -3 | @@ -3,6 +3,7 @@ package server | ||
| 3 | 3 | import ( |
| 4 | 4 | "bytes" |
| 5 | 5 | "context" |
| 6 | + "fmt" | |
| 6 | 7 | "html/template" |
| 7 | 8 | "log/slog" |
| 8 | 9 | "net/http" |
| @@ -39,13 +40,23 @@ type Server struct { | ||
| 39 | 40 | callbackURL string |
| 40 | 41 | } |
| 41 | 42 | |
| 42 | -func New(database *db.DB, clientID, callbackURL string, logger *slog.Logger) *Server { | |
| 43 | +func New(database *db.DB, clientID, callbackURL, addr string, logger *slog.Logger) *Server { | |
| 43 | 44 | oauthStore := db.NewOAuthStore(database) |
| 44 | 45 | if err := oauthStore.Init(context.Background()); err != nil { |
| 45 | 46 | logger.Error("failed to init oauth store", "error", err) |
| 46 | 47 | } |
| 47 | 48 | |
| 48 | - config := oauth.NewPublicConfig(clientID, callbackURL, []string{"atproto"}) | |
| 49 | + var config oauth.ClientConfig | |
| 50 | + if clientID == "" { | |
| 51 | + host := addr | |
| 52 | + if strings.HasPrefix(host, ":") { | |
| 53 | + host = "127.0.0.1" + host | |
| 54 | + } | |
| 55 | + cbURL := fmt.Sprintf("http://%s/auth/callback", host) | |
| 56 | + config = oauth.NewLocalhostConfig(cbURL, []string{"atproto", "transition:generic"}) | |
| 57 | + } else { | |
| 58 | + config = oauth.NewPublicConfig(clientID, callbackURL, []string{"atproto", "transition:generic"}) | |
| 59 | + } | |
| 49 | 60 | oauthClient := oauth.NewClientApp(&config, oauthStore) |
| 50 | 61 | |
| 51 | 62 | s := &Server{ |
| @@ -123,7 +134,10 @@ func (s *Server) setupRoutes() { | ||
| 123 | 134 | r.Get("/people", s.handleDiscoverPeople) |
| 124 | 135 | }) |
| 125 | 136 | |
| 126 | - s.router.Get("/profile/{did}", s.handleProfile) | |
| 137 | + s.router.Route("/profile", func(r chi.Router) { | |
| 138 | + r.Use(s.requireAuth) | |
| 139 | + r.Get("/{did}", s.handleProfile) | |
| 140 | + }) | |
| 127 | 141 | |
| 128 | 142 | s.router.Route("/annotations", func(r chi.Router) { |
| 129 | 143 | r.Use(s.requireAuth) |
| @@ -3,6 +3,7 @@ package server | |||
| 3 | import ( | 3 | import ( |
| 4 | "bytes" | 4 | "bytes" |
| 5 | "context" | 5 | "context" |
| 6 | + "fmt" | ||
| 6 | "html/template" | 7 | "html/template" |
| 7 | "log/slog" | 8 | "log/slog" |
| 8 | "net/http" | 9 | "net/http" |
| @@ -39,13 +40,23 @@ type Server struct { | |||
| 39 | callbackURL string | 40 | callbackURL string |
| 40 | } | 41 | } |
| 41 | 42 | ||
| 42 | -func New(database *db.DB, clientID, callbackURL string, logger *slog.Logger) *Server { | 43 | +func New(database *db.DB, clientID, callbackURL, addr string, logger *slog.Logger) *Server { |
| 43 | oauthStore := db.NewOAuthStore(database) | 44 | oauthStore := db.NewOAuthStore(database) |
| 44 | if err := oauthStore.Init(context.Background()); err != nil { | 45 | if err := oauthStore.Init(context.Background()); err != nil { |
| 45 | logger.Error("failed to init oauth store", "error", err) | 46 | logger.Error("failed to init oauth store", "error", err) |
| 46 | } | 47 | } |
| 47 | 48 | ||
| 48 | - config := oauth.NewPublicConfig(clientID, callbackURL, []string{"atproto"}) | 49 | + var config oauth.ClientConfig |
| 50 | + if clientID == "" { | ||
| 51 | + host := addr | ||
| 52 | + if strings.HasPrefix(host, ":") { | ||
| 53 | + host = "127.0.0.1" + host | ||
| 54 | + } | ||
| 55 | + cbURL := fmt.Sprintf("http://%s/auth/callback", host) | ||
| 56 | + config = oauth.NewLocalhostConfig(cbURL, []string{"atproto", "transition:generic"}) | ||
| 57 | + } else { | ||
| 58 | + config = oauth.NewPublicConfig(clientID, callbackURL, []string{"atproto", "transition:generic"}) | ||
| 59 | + } | ||
| 49 | oauthClient := oauth.NewClientApp(&config, oauthStore) | 60 | oauthClient := oauth.NewClientApp(&config, oauthStore) |
| 50 | 61 | ||
| 51 | s := &Server{ | 62 | s := &Server{ |
| @@ -123,7 +134,10 @@ func (s *Server) setupRoutes() { | |||
| 123 | r.Get("/people", s.handleDiscoverPeople) | 134 | r.Get("/people", s.handleDiscoverPeople) |
| 124 | }) | 135 | }) |
| 125 | 136 | ||
| 126 | - s.router.Get("/profile/{did}", s.handleProfile) | 137 | + s.router.Route("/profile", func(r chi.Router) { |
| 138 | + r.Use(s.requireAuth) | ||
| 139 | + r.Get("/{did}", s.handleProfile) | ||
| 140 | + }) | ||
| 127 | 141 | ||
| 128 | s.router.Route("/annotations", func(r chi.Router) { | 142 | s.router.Route("/annotations", func(r chi.Router) { |
| 129 | r.Use(s.requireAuth) | 143 | r.Use(s.requireAuth) |
modified
internal/tmpl/article_detail.html +1 -0 | @@ -29,6 +29,7 @@ | ||
| 29 | 29 | <a href="https://bsky.app/intent/compose?text={{.Article.Title}}%20{{if .Article.URL.Valid}}{{.Article.URL.String}}{{end}}" |
| 30 | 30 | target="_blank" rel="noopener noreferrer" |
| 31 | 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"> |
| 32 | + <span class="w-3.5 h-3.5 inline-flex">{{template "icon-bluesky"}}</span> | |
| 32 | 33 | Share |
| 33 | 34 | </a> |
| 34 | 35 | |
| @@ -29,6 +29,7 @@ | |||
| 29 | <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}}" |
| 30 | target="_blank" rel="noopener noreferrer" | 30 | target="_blank" rel="noopener noreferrer" |
| 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"> | 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"> |
| 32 | + <span class="w-3.5 h-3.5 inline-flex">{{template "icon-bluesky"}}</span> | ||
| 32 | Share | 33 | Share |
| 33 | </a> | 34 | </a> |
| 34 | 35 | ||
modified
internal/tmpl/index.html +3 -1 | @@ -3,7 +3,9 @@ | ||
| 3 | 3 | <h1 class="text-5xl font-bold font-title mb-6 text-spot-text">Glean</h1> |
| 4 | 4 | <p class="text-lg text-spot-secondary mb-10 max-w-lg mx-auto leading-relaxed">A social RSS reader. Follow feeds, share what you read, discover new sources through your network.</p> |
| 5 | 5 | <div class="flex flex-col sm:flex-row justify-center gap-3"> |
| 6 | - <a href="/auth/login" class="bg-spot-purple text-spot-bg rounded-pill px-8 py-3 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">Sign in with Bluesky</a> | |
| 6 | + <a href="/auth/login" class="bg-spot-purple text-spot-bg rounded-pill px-8 py-3 text-sm font-bold uppercase tracking-button hover:brightness-110 transition inline-flex items-center gap-2"> | |
| 7 | + Sign in | |
| 8 | + </a> | |
| 7 | 9 | <a href="/trending" class="border border-spot-outline text-spot-text rounded-pill px-8 py-3 text-sm font-bold uppercase tracking-button hover:border-spot-text transition">Browse trending</a> |
| 8 | 10 | </div> |
| 9 | 11 | <div class="mt-24 grid grid-cols-1 sm:grid-cols-3 gap-4 text-left"> |
| @@ -3,7 +3,9 @@ | |||
| 3 | <h1 class="text-5xl font-bold font-title mb-6 text-spot-text">Glean</h1> | 3 | <h1 class="text-5xl font-bold font-title mb-6 text-spot-text">Glean</h1> |
| 4 | <p class="text-lg text-spot-secondary mb-10 max-w-lg mx-auto leading-relaxed">A social RSS reader. Follow feeds, share what you read, discover new sources through your network.</p> | 4 | <p class="text-lg text-spot-secondary mb-10 max-w-lg mx-auto leading-relaxed">A social RSS reader. Follow feeds, share what you read, discover new sources through your network.</p> |
| 5 | <div class="flex flex-col sm:flex-row justify-center gap-3"> | 5 | <div class="flex flex-col sm:flex-row justify-center gap-3"> |
| 6 | - <a href="/auth/login" class="bg-spot-purple text-spot-bg rounded-pill px-8 py-3 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">Sign in with Bluesky</a> | 6 | + <a href="/auth/login" class="bg-spot-purple text-spot-bg rounded-pill px-8 py-3 text-sm font-bold uppercase tracking-button hover:brightness-110 transition inline-flex items-center gap-2"> |
| 7 | + Sign in | ||
| 8 | + </a> | ||
| 7 | <a href="/trending" class="border border-spot-outline text-spot-text rounded-pill px-8 py-3 text-sm font-bold uppercase tracking-button hover:border-spot-text transition">Browse trending</a> | 9 | <a href="/trending" class="border border-spot-outline text-spot-text rounded-pill px-8 py-3 text-sm font-bold uppercase tracking-button hover:border-spot-text transition">Browse trending</a> |
| 8 | </div> | 10 | </div> |
| 9 | <div class="mt-24 grid grid-cols-1 sm:grid-cols-3 gap-4 text-left"> | 11 | <div class="mt-24 grid grid-cols-1 sm:grid-cols-3 gap-4 text-left"> |
modified
internal/tmpl/login.html +18 -7 | @@ -1,18 +1,29 @@ | ||
| 1 | 1 | {{define "login.html"}} |
| 2 | 2 | <div class="max-w-md mx-auto mt-20"> |
| 3 | 3 | <div class="bg-spot-surface rounded-lg shadow-spot-heavy p-8"> |
| 4 | - <h1 class="text-2xl font-bold font-title text-spot-text mb-6">Sign in to Glean</h1> | |
| 5 | - <form action="/auth/start" method="POST"> | |
| 4 | + <h1 class="text-2xl font-bold font-title text-spot-text mb-2">Sign in to Glean</h1> | |
| 5 | + <p class="text-spot-secondary text-sm mb-6">Enter your handle.</p> | |
| 6 | + | |
| 7 | + <form action="/auth/start" method="POST" id="login-form"> | |
| 6 | 8 | {{csrfInput .CSRFToken}} |
| 7 | - <label class="block text-sm font-bold text-spot-secondary mb-2 uppercase tracking-button">Bluesky Handle</label> | |
| 8 | 9 | <input type="text" name="handle" placeholder="you.bsky.social" |
| 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" | |
| 10 | + class="w-full bg-spot-hover text-spot-text rounded-pill px-5 py-3 mb-5 text-sm focus:outline-none focus:ring-2 focus:ring-spot-purple placeholder:text-spot-placeholder" | |
| 10 | 11 | required> |
| 11 | - <button type="submit" class="w-full bg-spot-purple text-spot-bg rounded-pill px-4 py-3 text-sm font-bold uppercase tracking-button hover:brightness-110 transition"> | |
| 12 | + </form> | |
| 13 | + | |
| 14 | + <div class="space-y-3"> | |
| 15 | + <button type="submit" form="login-form" | |
| 16 | + class="w-full flex items-center justify-center gap-3 bg-[#0284FF] text-white rounded-pill px-4 py-3 text-sm font-bold uppercase tracking-button hover:brightness-110 transition"> | |
| 17 | + {{template "icon-bluesky"}} | |
| 12 | 18 | Sign in with Bluesky |
| 13 | 19 | </button> |
| 14 | - </form> | |
| 15 | - <p class="mt-5 text-sm text-spot-secondary">We'll authenticate you via your Bluesky account using AT Protocol OAuth.</p> | |
| 20 | + | |
| 21 | + <button type="submit" form="login-form" | |
| 22 | + class="w-full flex items-center justify-center gap-3 border border-spot-outline text-spot-text rounded-pill px-4 py-3 text-sm font-bold uppercase tracking-button hover:bg-spot-hover-50 transition"> | |
| 23 | + {{template "icon-globe"}} | |
| 24 | + Sign in with Atmosphere | |
| 25 | + </button> | |
| 26 | + </div> | |
| 16 | 27 | </div> |
| 17 | 28 | </div> |
| 18 | 29 | {{end}} |
| @@ -1,18 +1,29 @@ | |||
| 1 | {{define "login.html"}} | 1 | {{define "login.html"}} |
| 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-2">Sign in to Glean</h1> |
| 5 | - <form action="/auth/start" method="POST"> | 5 | + <p class="text-spot-secondary text-sm mb-6">Enter your handle.</p> |
| 6 | + | ||
| 7 | + <form action="/auth/start" method="POST" id="login-form"> | ||
| 6 | {{csrfInput .CSRFToken}} | 8 | {{csrfInput .CSRFToken}} |
| 7 | - <label class="block text-sm font-bold text-spot-secondary mb-2 uppercase tracking-button">Bluesky Handle</label> | ||
| 8 | <input type="text" name="handle" placeholder="you.bsky.social" | 9 | <input type="text" name="handle" placeholder="you.bsky.social" |
| 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" | 10 | + class="w-full bg-spot-hover text-spot-text rounded-pill px-5 py-3 mb-5 text-sm focus:outline-none focus:ring-2 focus:ring-spot-purple placeholder:text-spot-placeholder" |
| 10 | required> | 11 | required> |
| 11 | - <button type="submit" class="w-full bg-spot-purple text-spot-bg rounded-pill px-4 py-3 text-sm font-bold uppercase tracking-button hover:brightness-110 transition"> | 12 | + </form> |
| 13 | + | ||
| 14 | + <div class="space-y-3"> | ||
| 15 | + <button type="submit" form="login-form" | ||
| 16 | + class="w-full flex items-center justify-center gap-3 bg-[#0284FF] text-white rounded-pill px-4 py-3 text-sm font-bold uppercase tracking-button hover:brightness-110 transition"> | ||
| 17 | + {{template "icon-bluesky"}} | ||
| 12 | Sign in with Bluesky | 18 | Sign in with Bluesky |
| 13 | </button> | 19 | </button> |
| 14 | - </form> | 20 | + |
| 15 | - <p class="mt-5 text-sm text-spot-secondary">We'll authenticate you via your Bluesky account using AT Protocol OAuth.</p> | 21 | + <button type="submit" form="login-form" |
| 22 | + class="w-full flex items-center justify-center gap-3 border border-spot-outline text-spot-text rounded-pill px-4 py-3 text-sm font-bold uppercase tracking-button hover:bg-spot-hover-50 transition"> | ||
| 23 | + {{template "icon-globe"}} | ||
| 24 | + Sign in with Atmosphere | ||
| 25 | + </button> | ||
| 26 | + </div> | ||
| 16 | </div> | 27 | </div> |
| 17 | </div> | 28 | </div> |
| 18 | {{end}} | 29 | {{end}} |
modified
internal/tmpl/partials/annotation-card.html +1 -1 | @@ -15,7 +15,7 @@ | ||
| 15 | 15 | <div class="text-sm text-spot-orange mt-1">{{repeat "★" (int .Rating.Int64)}}</div> |
| 16 | 16 | {{end}} |
| 17 | 17 | <div class="text-xs text-spot-muted mt-2"> |
| 18 | - <a href="/profile/{{.AuthorDID}}" class="hover:text-spot-purple transition">{{.AuthorDID}}</a> | |
| 18 | + <a href="/profile/{{.AuthorDID}}" class="hover:text-spot-purple transition">{{.AuthorHandle}}</a> | |
| 19 | 19 | {{if .CreatedAt.Valid}}<span class="ml-2">{{.CreatedAt.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}} |
| 20 | 20 | </div> |
| 21 | 21 | </div> |
| @@ -15,7 +15,7 @@ | |||
| 15 | <div class="text-sm text-spot-orange mt-1">{{repeat "★" (int .Rating.Int64)}}</div> | 15 | <div class="text-sm text-spot-orange mt-1">{{repeat "★" (int .Rating.Int64)}}</div> |
| 16 | {{end}} | 16 | {{end}} |
| 17 | <div class="text-xs text-spot-muted mt-2"> | 17 | <div class="text-xs text-spot-muted mt-2"> |
| 18 | - <a href="/profile/{{.AuthorDID}}" class="hover:text-spot-purple transition">{{.AuthorDID}}</a> | 18 | + <a href="/profile/{{.AuthorDID}}" class="hover:text-spot-purple transition">{{.AuthorHandle}}</a> |
| 19 | {{if .CreatedAt.Valid}}<span class="ml-2">{{.CreatedAt.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}} | 19 | {{if .CreatedAt.Valid}}<span class="ml-2">{{.CreatedAt.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}} |
| 20 | </div> | 20 | </div> |
| 21 | </div> | 21 | </div> |
added
internal/tmpl/partials/icon-bluesky.html +1 -0 | new file mode 100644 | ||
| @@ -0,0 +1 @@ | ||
| 1 | +{{define "icon-bluesky"}}<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor"><path d="M5.202 2.857C7.954 4.922 10.913 9.11 12 11.358c1.087-2.247 4.046-6.436 6.798-8.501C20.783 1.366 24 .213 24 3.883c0 .732-.42 6.156-.667 7.037-.856 3.061-3.978 3.842-6.755 3.37 4.854.826 6.089 3.562 3.422 6.299-5.065 5.196-7.28-1.304-7.847-2.97-.104-.305-.152-.448-.153-.327 0-.121-.05.022-.153.327-.568 1.666-2.782 8.166-7.847 2.97-2.667-2.737-1.432-5.473 3.422-6.3-2.777.473-5.899-.308-6.755-3.369C.42 10.04 0 4.615 0 3.883c0-3.67 3.217-2.517 5.202-1.026"/></svg>{{end}} | |
| new file mode 100644 | |||
| @@ -0,0 +1 @@ | |||
| 1 | +{{define "icon-bluesky"}}<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor"><path d="M5.202 2.857C7.954 4.922 10.913 9.11 12 11.358c1.087-2.247 4.046-6.436 6.798-8.501C20.783 1.366 24 .213 24 3.883c0 .732-.42 6.156-.667 7.037-.856 3.061-3.978 3.842-6.755 3.37 4.854.826 6.089 3.562 3.422 6.299-5.065 5.196-7.28-1.304-7.847-2.97-.104-.305-.152-.448-.153-.327 0-.121-.05.022-.153.327-.568 1.666-2.782 8.166-7.847 2.97-2.667-2.737-1.432-5.473 3.422-6.3-2.777.473-5.899-.308-6.755-3.369C.42 10.04 0 4.615 0 3.883c0-3.67 3.217-2.517 5.202-1.026"/></svg>{{end}} | ||
added
internal/tmpl/partials/icon-globe.html +1 -0 | new file mode 100644 | ||
| @@ -0,0 +1 @@ | ||
| 1 | +{{define "icon-globe"}}<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10A15.3 15.3 0 0 1 12 2z"/><line x1="2" y1="12" x2="22" y2="12"/></svg>{{end}} | |
| new file mode 100644 | |||
| @@ -0,0 +1 @@ | |||
| 1 | +{{define "icon-globe"}}<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10A15.3 15.3 0 0 1 12 2z"/><line x1="2" y1="12" x2="22" y2="12"/></svg>{{end}} | ||
modified
main.go +1 -1 | @@ -36,7 +36,7 @@ func main() { | ||
| 36 | 36 | clientID := envOr("GLEAN_OAUTH_CLIENT_ID", "") |
| 37 | 37 | callbackURL := envOr("GLEAN_OAUTH_REDIRECT_URL", "") |
| 38 | 38 | |
| 39 | - srv := server.New(database, clientID, callbackURL, logger) | |
| 39 | + srv := server.New(database, clientID, callbackURL, *addr, logger) | |
| 40 | 40 | |
| 41 | 41 | storeAdapter := db.NewFeedStoreAdapter(database) |
| 42 | 42 | scheduler := feed.NewScheduler(storeAdapter, logger) |
| @@ -36,7 +36,7 @@ func main() { | |||
| 36 | clientID := envOr("GLEAN_OAUTH_CLIENT_ID", "") | 36 | clientID := envOr("GLEAN_OAUTH_CLIENT_ID", "") |
| 37 | callbackURL := envOr("GLEAN_OAUTH_REDIRECT_URL", "") | 37 | callbackURL := envOr("GLEAN_OAUTH_REDIRECT_URL", "") |
| 38 | 38 | ||
| 39 | - srv := server.New(database, clientID, callbackURL, logger) | 39 | + srv := server.New(database, clientID, callbackURL, *addr, logger) |
| 40 | 40 | ||
| 41 | storeAdapter := db.NewFeedStoreAdapter(database) | 41 | storeAdapter := db.NewFeedStoreAdapter(database) |
| 42 | scheduler := feed.NewScheduler(storeAdapter, logger) | 42 | scheduler := feed.NewScheduler(storeAdapter, logger) |