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

Configure Feed

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

Add support for fetching user profile display name and avatar

+112 -54
+6
.env.example
··· 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
+13
Makefile
··· 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
+17 -15
internal/db/social.go
··· 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 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 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 }
+31 -24
internal/server/auth_handler.go
··· 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 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + 10 13 oauth "github.com/bluesky-social/indigo/atproto/auth/oauth" 11 - "github.com/bluesky-social/indigo/atproto/syntax" 12 14 13 15 "pkg.rbrt.fr/glean/internal/atproto" 14 16 ) ··· 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 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"` 142 + } 143 + 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 "", "" 143 148 } 149 + return profile.DisplayName, profile.Avatar 150 + } 144 151 145 - config := oauth.NewPublicConfig(clientID, redirectURL, []string{"atproto"}) 146 - meta := config.ClientMetadata() 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 + } 147 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")
+2 -2
internal/server/profile_handler.go
··· 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,
+17 -3
internal/server/server.go
··· 3 3 import ( 4 4 "bytes" 5 5 "context" 6 + "fmt" 6 7 "html/template" 7 8 "log/slog" 8 9 "net/http" ··· 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 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)
+1
internal/tmpl/article_detail.html
··· 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
+3 -1
internal/tmpl/index.html
··· 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">
+18 -7
internal/tmpl/login.html
··· 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 -1
internal/tmpl/partials/annotation-card.html
··· 15 15 <div class="text-sm text-spot-orange mt-1">{{repeat "&#9733;" (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>
+1
internal/tmpl/partials/icon-bluesky.html
··· 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}}
+1
internal/tmpl/partials/icon-globe.html
··· 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}}
+1 -1
main.go
··· 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)