nandi/gleanpublic Fork 0
eaf506a
Commits
Clone
git clone https://git.rickub.com/nandi/glean.git
git clone ssh://git@rickub.com/nandi/glean.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

fix: fix origin and oauth callback redirectsUnverified

Julien Robert committed 2026-07-02T23:06:07+02:00 Browse files
eaf506a parent: 0df9e53
modified .env.example +15 -17
@@ -2,15 +2,15 @@
22 #
33 # ─────────────────────────────── BACKEND (Go API) ──────────────────────────────
44
5-# Listen address for the Go HTTP server.
6-GLEAN_ADDR=:8080
7-# SQLite database path.
8-GLEAN_DB=glean.db
5+# Listen address for the Go HTTP server. In the Docker setup this is loopback
6+# only: the SvelteKit server proxies to it.
7+GLEAN_ADDR=127.0.0.1:8080
8+# SQLite database base path (_users, _articles, _recs suffixes are appended).
9+GLEAN_DB=/data/glean.db
910 # HMAC key for signing session cookies. Generate with: openssl rand -hex 32
1011 GLEAN_SESSION_KEY=change-me-to-a-random-string
11-# Public browser origin of the frontend. The backend uses this to validate the
12-# CSRF Origin header and to build the OAuth callback URL. MUST be the origin
13-# users see in their browser (e.g. https://glean.at), NOT the backend's own URL.
12+# Public browser origin of the frontend. MUST be the origin users see in their
13+# browser (e.g. https://glean.at). The OAuth callback URL is derived from this.
1414 GLEAN_FRONTEND_URL=https://glean.at
1515
1616 # ATProto / Jetstream.
@@ -22,11 +22,11 @@ GLEAN_FETCH_INTERVAL=15m
2222 GLEAN_COLLECTION_DIR_URL=https://relay1.us-west.bsky.network/xrpc/com.atproto.sync.listReposByCollection?collection=at.glean.subscription
2323 GLEAN_BACKFILL_CONCURRENCY=5
2424
25-# OAuth. Leave GLEAN_OAUTH_CLIENT_ID empty for localhost dev (uses OAuth localhost flow).
26-# In production, set both. The callback is served by the FRONTEND at /api/auth/callback
27-# (the frontend proxies it to this backend), so it points at the frontend origin.
28-GLEAN_OAUTH_CLIENT_ID=https://glean.at/oauth/client-metadata
29-GLEAN_OAUTH_REDIRECT_URL=https://glean.at/api/auth/callback
25+# OAuth. Leave GLEAN_OAUTH_CLIENT_ID empty for localhost dev (uses the OAuth
26+# localhost flow). In production set it to this server's client-metadata URL;
27+# it must resolve to /api/oauth/client-metadata on the public origin.
28+# The OAuth callback is always $GLEAN_FRONTEND_URL/api/auth/callback.
29+GLEAN_OAUTH_CLIENT_ID=https://glean.at/api/oauth/client-metadata
3030
3131 # Embeddings (recommended as it powers content-based feed/article recommendations).
3232 # Point to any OpenAI-compatible /v1/embeddings endpoint (OpenAI, Ollama, etc.).
@@ -50,10 +50,8 @@ GLEAN_LLM_MODEL=qwen3.5-35b-a3b
5050
5151 # ─────────────────────────────── FRONTEND (SvelteKit) ───────────────────────────
5252
53-# Base URL of the Go API. The SvelteKit server proxies /api/* here server-to-server.
54-GLEAN_API_URL=https://api.glean.at
55-# The public origin users see in their browser. Used by SvelteKit's adapter-node
56-# to set the canonical origin and must match GLEAN_FRONTEND_URL on the backend.
57-ORIGIN=https://glean.at
53+# Where the SvelteKit server finds the Go API. In the Docker setup they share a
54+# network namespace, so this is loopback.
55+GLEAN_API_URL=http://127.0.0.1:8080
5856 # Port the SvelteKit Node server listens on.
5957 PORT=3000
@@ -2,15 +2,15 @@
2 #2 #
3 # ─────────────────────────────── BACKEND (Go API) ──────────────────────────────3 # ─────────────────────────────── BACKEND (Go API) ──────────────────────────────
4 4
5-# Listen address for the Go HTTP server.5+# Listen address for the Go HTTP server. In the Docker setup this is loopback
6-GLEAN_ADDR=:80806+# only: the SvelteKit server proxies to it.
7-# SQLite database path.7+GLEAN_ADDR=127.0.0.1:8080
8-GLEAN_DB=glean.db8+# SQLite database base path (_users, _articles, _recs suffixes are appended).
9+GLEAN_DB=/data/glean.db
9 # HMAC key for signing session cookies. Generate with: openssl rand -hex 3210 # HMAC key for signing session cookies. Generate with: openssl rand -hex 32
10 GLEAN_SESSION_KEY=change-me-to-a-random-string11 GLEAN_SESSION_KEY=change-me-to-a-random-string
11-# Public browser origin of the frontend. The backend uses this to validate the12+# Public browser origin of the frontend. MUST be the origin users see in their
12-# CSRF Origin header and to build the OAuth callback URL. MUST be the origin13+# browser (e.g. https://glean.at). The OAuth callback URL is derived from this.
13-# users see in their browser (e.g. https://glean.at), NOT the backend's own URL.
14 GLEAN_FRONTEND_URL=https://glean.at14 GLEAN_FRONTEND_URL=https://glean.at
15 15
16 # ATProto / Jetstream.16 # ATProto / Jetstream.
@@ -22,11 +22,11 @@ GLEAN_FETCH_INTERVAL=15m
22 GLEAN_COLLECTION_DIR_URL=https://relay1.us-west.bsky.network/xrpc/com.atproto.sync.listReposByCollection?collection=at.glean.subscription22 GLEAN_COLLECTION_DIR_URL=https://relay1.us-west.bsky.network/xrpc/com.atproto.sync.listReposByCollection?collection=at.glean.subscription
23 GLEAN_BACKFILL_CONCURRENCY=523 GLEAN_BACKFILL_CONCURRENCY=5
24 24
25-# OAuth. Leave GLEAN_OAUTH_CLIENT_ID empty for localhost dev (uses OAuth localhost flow).25+# OAuth. Leave GLEAN_OAUTH_CLIENT_ID empty for localhost dev (uses the OAuth
26-# In production, set both. The callback is served by the FRONTEND at /api/auth/callback26+# localhost flow). In production set it to this server's client-metadata URL;
27-# (the frontend proxies it to this backend), so it points at the frontend origin.27+# it must resolve to /api/oauth/client-metadata on the public origin.
28-GLEAN_OAUTH_CLIENT_ID=https://glean.at/oauth/client-metadata28+# The OAuth callback is always $GLEAN_FRONTEND_URL/api/auth/callback.
29-GLEAN_OAUTH_REDIRECT_URL=https://glean.at/api/auth/callback29+GLEAN_OAUTH_CLIENT_ID=https://glean.at/api/oauth/client-metadata
30 30
31 # Embeddings (recommended as it powers content-based feed/article recommendations).31 # Embeddings (recommended as it powers content-based feed/article recommendations).
32 # Point to any OpenAI-compatible /v1/embeddings endpoint (OpenAI, Ollama, etc.).32 # Point to any OpenAI-compatible /v1/embeddings endpoint (OpenAI, Ollama, etc.).
@@ -50,10 +50,8 @@ GLEAN_LLM_MODEL=qwen3.5-35b-a3b
50 50
51 # ─────────────────────────────── FRONTEND (SvelteKit) ───────────────────────────51 # ─────────────────────────────── FRONTEND (SvelteKit) ───────────────────────────
52 52
53-# Base URL of the Go API. The SvelteKit server proxies /api/* here server-to-server.53+# Where the SvelteKit server finds the Go API. In the Docker setup they share a
54-GLEAN_API_URL=https://api.glean.at54+# network namespace, so this is loopback.
55-# The public origin users see in their browser. Used by SvelteKit's adapter-node55+GLEAN_API_URL=http://127.0.0.1:8080
56-# to set the canonical origin and must match GLEAN_FRONTEND_URL on the backend.
57-ORIGIN=https://glean.at
58 # Port the SvelteKit Node server listens on.56 # Port the SvelteKit Node server listens on.
59 PORT=300057 PORT=3000
modified Dockerfile +0 -1
@@ -36,7 +36,6 @@ WORKDIR /app/web
3636 # SvelteKit proxies /api to the Go API on localhost:8080.
3737 ENV GLEAN_API_URL=http://127.0.0.1:8080
3838 ENV PORT=3000
39-ENV ORIGIN=http://localhost:3000
4039
4140 EXPOSE 3000
4241
@@ -36,7 +36,6 @@ WORKDIR /app/web
36 # SvelteKit proxies /api to the Go API on localhost:8080.36 # SvelteKit proxies /api to the Go API on localhost:8080.
37 ENV GLEAN_API_URL=http://127.0.0.1:808037 ENV GLEAN_API_URL=http://127.0.0.1:8080
38 ENV PORT=300038 ENV PORT=3000
39-ENV ORIGIN=http://localhost:3000
40 39
41 EXPOSE 300040 EXPOSE 3000
42 41
modified internal/server/auth_handler.go +3 -44
@@ -66,7 +66,7 @@ func (s *Server) handleAuthStart(w http.ResponseWriter, r *http.Request) {
6666 writeAPIError(w, http.StatusInternalServerError, "Could not create your account. Please try again.")
6767 return
6868 }
69- s.setUserSession(w, user)
69+ s.setUserSession(w, r, user)
7070 writeJSON(w, http.StatusOK, redirectResponse{Redirect: "/dashboard"})
7171 return
7272 }
@@ -77,38 +77,6 @@ func (s *Server) handleAuthStart(w http.ResponseWriter, r *http.Request) {
7777 // handleAuthCallback is hit by the OAuth provider after authorization. It is a
7878 // browser navigation (not an XHR), so it issues HTTP redirects rather than JSON.
7979 func (s *Server) handleAuthCallback(w http.ResponseWriter, r *http.Request) {
80- params := r.URL.Query()
81-
82- if params.Get("code") != "" && params.Get("state") != "" {
83- s.handleOAuthCallback(w, r)
84- return
85- }
86-
87- handle := params.Get("handle")
88- if handle == "" {
89- http.Redirect(w, r, "/auth/login?error=missing_handle", http.StatusSeeOther)
90- return
91- }
92-
93- did, err := atproto.ResolveHandle(r.Context(), handle)
94- if err != nil {
95- s.logger.Error("failed to resolve handle", "error", err)
96- http.Redirect(w, r, "/auth/login?error=handle_not_found", http.StatusSeeOther)
97- return
98- }
99-
100- user, err := s.dbs.Users.CreateUser(r.Context(), did)
101- if err != nil {
102- s.logger.Error("failed to create user", "error", err)
103- http.Redirect(w, r, "/auth/login?error=create_failed", http.StatusSeeOther)
104- return
105- }
106-
107- s.setUserSession(w, user)
108- http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
109-}
110-
111-func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
11280 sessData, err := s.oauth.ProcessCallback(r.Context(), r.URL.Query())
11381 if err != nil {
11482 s.logger.Error("OAuth callback failed", "error", err)
@@ -117,7 +85,6 @@ func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
11785 }
11886
11987 did := sessData.AccountDID.String()
120-
12188 client := s.pdsClientFromSession(sessData)
12289
12390 user, err := s.dbs.Users.CreateUser(r.Context(), did)
@@ -139,15 +106,7 @@ func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
139106 return
140107 }
141108
142- http.SetCookie(w, &http.Cookie{
143- Name: "glean_session",
144- Value: encoded,
145- Path: "/",
146- MaxAge: 86400 * 30,
147- HttpOnly: true,
148- Secure: s.secureCookies,
149- SameSite: http.SameSiteLaxMode,
150- })
109+ http.SetCookie(w, sessionCookie("glean_session", encoded, 86400*30, r))
151110
152111 s.syncUserInBackground(user.DID, client)
153112
@@ -187,6 +146,6 @@ func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) {
187146 _ = s.oauth.Logout(r.Context(), did, session.SessionID)
188147 }
189148 }
190- s.clearUserSession(w)
149+ s.clearUserSession(w, r)
191150 writeJSON(w, http.StatusOK, redirectResponse{Redirect: "/"})
192151 }
@@ -66,7 +66,7 @@ func (s *Server) handleAuthStart(w http.ResponseWriter, r *http.Request) {
66 writeAPIError(w, http.StatusInternalServerError, "Could not create your account. Please try again.")66 writeAPIError(w, http.StatusInternalServerError, "Could not create your account. Please try again.")
67 return67 return
68 }68 }
69- s.setUserSession(w, user)69+ s.setUserSession(w, r, user)
70 writeJSON(w, http.StatusOK, redirectResponse{Redirect: "/dashboard"})70 writeJSON(w, http.StatusOK, redirectResponse{Redirect: "/dashboard"})
71 return71 return
72 }72 }
@@ -77,38 +77,6 @@ func (s *Server) handleAuthStart(w http.ResponseWriter, r *http.Request) {
77 // handleAuthCallback is hit by the OAuth provider after authorization. It is a77 // handleAuthCallback is hit by the OAuth provider after authorization. It is a
78 // browser navigation (not an XHR), so it issues HTTP redirects rather than JSON.78 // browser navigation (not an XHR), so it issues HTTP redirects rather than JSON.
79 func (s *Server) handleAuthCallback(w http.ResponseWriter, r *http.Request) {79 func (s *Server) handleAuthCallback(w http.ResponseWriter, r *http.Request) {
80- params := r.URL.Query()
81-
82- if params.Get("code") != "" && params.Get("state") != "" {
83- s.handleOAuthCallback(w, r)
84- return
85- }
86-
87- handle := params.Get("handle")
88- if handle == "" {
89- http.Redirect(w, r, "/auth/login?error=missing_handle", http.StatusSeeOther)
90- return
91- }
92-
93- did, err := atproto.ResolveHandle(r.Context(), handle)
94- if err != nil {
95- s.logger.Error("failed to resolve handle", "error", err)
96- http.Redirect(w, r, "/auth/login?error=handle_not_found", http.StatusSeeOther)
97- return
98- }
99-
100- user, err := s.dbs.Users.CreateUser(r.Context(), did)
101- if err != nil {
102- s.logger.Error("failed to create user", "error", err)
103- http.Redirect(w, r, "/auth/login?error=create_failed", http.StatusSeeOther)
104- return
105- }
106-
107- s.setUserSession(w, user)
108- http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
109-}
110-
111-func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
112 sessData, err := s.oauth.ProcessCallback(r.Context(), r.URL.Query())80 sessData, err := s.oauth.ProcessCallback(r.Context(), r.URL.Query())
113 if err != nil {81 if err != nil {
114 s.logger.Error("OAuth callback failed", "error", err)82 s.logger.Error("OAuth callback failed", "error", err)
@@ -117,7 +85,6 @@ func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
117 }85 }
118 86
119 did := sessData.AccountDID.String()87 did := sessData.AccountDID.String()
120-
121 client := s.pdsClientFromSession(sessData)88 client := s.pdsClientFromSession(sessData)
122 89
123 user, err := s.dbs.Users.CreateUser(r.Context(), did)90 user, err := s.dbs.Users.CreateUser(r.Context(), did)
@@ -139,15 +106,7 @@ func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
139 return106 return
140 }107 }
141 108
142- http.SetCookie(w, &http.Cookie{109+ http.SetCookie(w, sessionCookie("glean_session", encoded, 86400*30, r))
143- Name: "glean_session",
144- Value: encoded,
145- Path: "/",
146- MaxAge: 86400 * 30,
147- HttpOnly: true,
148- Secure: s.secureCookies,
149- SameSite: http.SameSiteLaxMode,
150- })
151 110
152 s.syncUserInBackground(user.DID, client)111 s.syncUserInBackground(user.DID, client)
153 112
@@ -187,6 +146,6 @@ func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) {
187 _ = s.oauth.Logout(r.Context(), did, session.SessionID)146 _ = s.oauth.Logout(r.Context(), did, session.SessionID)
188 }147 }
189 }148 }
190- s.clearUserSession(w)149+ s.clearUserSession(w, r)
191 writeJSON(w, http.StatusOK, redirectResponse{Redirect: "/"})150 writeJSON(w, http.StatusOK, redirectResponse{Redirect: "/"})
192 }151 }
modified internal/server/middleware.go +8 -30
@@ -5,7 +5,6 @@ import (
55 "crypto/rand"
66 "encoding/hex"
77 "net/http"
8- "net/url"
98 "strings"
109 "time"
1110
@@ -18,7 +17,7 @@ func (s *Server) sessionMiddleware(next http.Handler) http.Handler {
1817 if user != nil {
1918 data := s.getSessionData(r)
2019 if data != nil && data.SessionID != "" && !s.isOAuthSessionValid(r.Context(), data) {
21- s.clearUserSession(w)
20+ s.clearUserSession(w, r)
2221 next.ServeHTTP(w, r)
2322 return
2423 }
@@ -60,7 +59,12 @@ func csrfToken() string {
6059
6160 // csrfMiddleware enforces double-submit CSRF. The token is issued in a readable
6261 // cookie (glean_csrf) and must be echoed back via the X-CSRF-Token header or
63-// csrf_token form field on every state-changing request.
62+// csrf_token form field on every state-changing request. The cookie is
63+// SameSite=Lax, so a cross-site request can't both carry it and read it to forge
64+// the header; that is the CSRF boundary. We deliberately do not check the
65+// Origin header here: behind the Caddy → SvelteKit → Go proxy chain the Host and
66+// Origin headers are rewritten in ways that make a same-origin comparison
67+// unreliable, and the double-submit token already provides the protection.
6468 func (s *Server) csrfMiddleware(next http.Handler) http.Handler {
6569 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
6670 if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions {
@@ -69,9 +73,8 @@ func (s *Server) csrfMiddleware(next http.Handler) http.Handler {
6973 Name: "glean_csrf",
7074 Value: csrfToken(),
7175 Path: "/",
72- MaxAge: 86400,
76+ MaxAge: 86400 * 30,
7377 HttpOnly: false,
74- Secure: s.secureCookies,
7578 SameSite: http.SameSiteLaxMode,
7679 })
7780 }
@@ -79,12 +82,6 @@ func (s *Server) csrfMiddleware(next http.Handler) http.Handler {
7982 return
8083 }
8184
82- // Origin must match the configured allowlist, not the proxied Host header.
83- if !s.originAllowed(r) {
84- writeAPIError(w, http.StatusForbidden, "forbidden")
85- return
86- }
87-
8885 cookie, err := r.Cookie("glean_csrf")
8986 if err != nil {
9087 writeAPIError(w, http.StatusForbidden, "missing csrf token")
@@ -102,25 +99,6 @@ func (s *Server) csrfMiddleware(next http.Handler) http.Handler {
10299 })
103100 }
104101
105-func (s *Server) originAllowed(r *http.Request) bool {
106- origin := r.Header.Get("Origin")
107- if origin == "" {
108- return true // non-browser client
109- }
110- if s.allowedOrigin != "" {
111- return origin == s.allowedOrigin
112- }
113- return sameOrigin(origin, r.Host)
114-}
115-
116-func sameOrigin(origin, host string) bool {
117- u, err := url.Parse(origin)
118- if err != nil {
119- return false
120- }
121- return u.Host == host
122-}
123-
124102 func (s *Server) realIPLogger(next http.Handler) http.Handler {
125103 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
126104 start := time.Now()
@@ -5,7 +5,6 @@ import (
5 "crypto/rand"5 "crypto/rand"
6 "encoding/hex"6 "encoding/hex"
7 "net/http"7 "net/http"
8- "net/url"
9 "strings"8 "strings"
10 "time"9 "time"
11 10
@@ -18,7 +17,7 @@ func (s *Server) sessionMiddleware(next http.Handler) http.Handler {
18 if user != nil {17 if user != nil {
19 data := s.getSessionData(r)18 data := s.getSessionData(r)
20 if data != nil && data.SessionID != "" && !s.isOAuthSessionValid(r.Context(), data) {19 if data != nil && data.SessionID != "" && !s.isOAuthSessionValid(r.Context(), data) {
21- s.clearUserSession(w)20+ s.clearUserSession(w, r)
22 next.ServeHTTP(w, r)21 next.ServeHTTP(w, r)
23 return22 return
24 }23 }
@@ -60,7 +59,12 @@ func csrfToken() string {
60 59
61 // csrfMiddleware enforces double-submit CSRF. The token is issued in a readable60 // csrfMiddleware enforces double-submit CSRF. The token is issued in a readable
62 // cookie (glean_csrf) and must be echoed back via the X-CSRF-Token header or61 // cookie (glean_csrf) and must be echoed back via the X-CSRF-Token header or
63-// csrf_token form field on every state-changing request.62+// csrf_token form field on every state-changing request. The cookie is
63+// SameSite=Lax, so a cross-site request can't both carry it and read it to forge
64+// the header; that is the CSRF boundary. We deliberately do not check the
65+// Origin header here: behind the Caddy → SvelteKit → Go proxy chain the Host and
66+// Origin headers are rewritten in ways that make a same-origin comparison
67+// unreliable, and the double-submit token already provides the protection.
64 func (s *Server) csrfMiddleware(next http.Handler) http.Handler {68 func (s *Server) csrfMiddleware(next http.Handler) http.Handler {
65 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {69 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
66 if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions {70 if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions {
@@ -69,9 +73,8 @@ func (s *Server) csrfMiddleware(next http.Handler) http.Handler {
69 Name: "glean_csrf",73 Name: "glean_csrf",
70 Value: csrfToken(),74 Value: csrfToken(),
71 Path: "/",75 Path: "/",
72- MaxAge: 86400,76+ MaxAge: 86400 * 30,
73 HttpOnly: false,77 HttpOnly: false,
74- Secure: s.secureCookies,
75 SameSite: http.SameSiteLaxMode,78 SameSite: http.SameSiteLaxMode,
76 })79 })
77 }80 }
@@ -79,12 +82,6 @@ func (s *Server) csrfMiddleware(next http.Handler) http.Handler {
79 return82 return
80 }83 }
81 84
82- // Origin must match the configured allowlist, not the proxied Host header.
83- if !s.originAllowed(r) {
84- writeAPIError(w, http.StatusForbidden, "forbidden")
85- return
86- }
87-
88 cookie, err := r.Cookie("glean_csrf")85 cookie, err := r.Cookie("glean_csrf")
89 if err != nil {86 if err != nil {
90 writeAPIError(w, http.StatusForbidden, "missing csrf token")87 writeAPIError(w, http.StatusForbidden, "missing csrf token")
@@ -102,25 +99,6 @@ func (s *Server) csrfMiddleware(next http.Handler) http.Handler {
102 })99 })
103 }100 }
104 101
105-func (s *Server) originAllowed(r *http.Request) bool {
106- origin := r.Header.Get("Origin")
107- if origin == "" {
108- return true // non-browser client
109- }
110- if s.allowedOrigin != "" {
111- return origin == s.allowedOrigin
112- }
113- return sameOrigin(origin, r.Host)
114-}
115-
116-func sameOrigin(origin, host string) bool {
117- u, err := url.Parse(origin)
118- if err != nil {
119- return false
120- }
121- return u.Host == host
122-}
123-
124 func (s *Server) realIPLogger(next http.Handler) http.Handler {102 func (s *Server) realIPLogger(next http.Handler) http.Handler {
125 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {103 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
126 start := time.Now()104 start := time.Now()
modified internal/server/server.go +53 -71
@@ -48,28 +48,25 @@ var oauthScopes = []string{
4848 }
4949
5050 type Server struct {
51- dbs *db.Store
52- router *chi.Mux
53- logger *slog.Logger
54- oauth *oauth.ClientApp
55- oauthStore *db.OAuthStore
56- fetcher *feed.Fetcher
57- scheduler *feed.Scheduler
58- engine *cluster.Engine
59- feedback *feedback.Service
60- scraper *scraper.Scraper
61- llm ml.TextModel
62- clientID string
63- callbackURL string
64- frontendURL string
65- sessionKey []byte
66- secureCookies bool // true in production (clientID set); gates cookie Secure flag
67- allowedOrigin string // configured browser origin for CSRF; empty in localhost dev
51+ dbs *db.Store
52+ router *chi.Mux
53+ logger *slog.Logger
54+ oauth *oauth.ClientApp
55+ oauthStore *db.OAuthStore
56+ fetcher *feed.Fetcher
57+ scheduler *feed.Scheduler
58+ engine *cluster.Engine
59+ feedback *feedback.Service
60+ scraper *scraper.Scraper
61+ llm ml.TextModel
62+ clientID string // empty in localhost dev; toggles the OAuth flow mode
63+ frontendURL string // public browser origin (e.g. https://glean.at)
64+ sessionKey []byte
6865 }
6966
7067 func New(
7168 dbs *db.Store,
72- clientID, callbackURL, frontendURL string,
69+ clientID, frontendURL string,
7370 scheduler *feed.Scheduler,
7471 fetcher *feed.Fetcher,
7572 engine *cluster.Engine,
@@ -79,45 +76,33 @@ func New(
7976 ) *Server {
8077 oauthStore := db.NewOAuthStore(dbs)
8178
79+ // The OAuth callback is always served by the frontend at /api/auth/callback
80+ // (SvelteKit proxies it here). clientID empty = localhost dev flow.
81+ callbackURL := strings.TrimRight(frontendURL, "/") + "/api/auth/callback"
82+
8283 var config oauth.ClientConfig
8384 if clientID == "" {
84- // Localhost dev: the OAuth callback must go through the SvelteKit
85- // frontend (which proxies /api to Go) so the post-callback redirect to
86- // /dashboard lands on the frontend, not on Go's API-only server.
87- origin := frontendURL
88- if origin == "" {
89- origin = "http://localhost:3000"
90- }
91- cbURL := strings.TrimRight(origin, "/") + "/api/auth/callback"
92- config = oauth.NewLocalhostConfig(cbURL, oauthScopes)
85+ config = oauth.NewLocalhostConfig(callbackURL, oauthScopes)
9386 } else {
94- // callbackURL points at the public SvelteKit origin, proxied to /api/auth/callback.
95- cb := callbackURL
96- if !strings.Contains(cb, "/api/auth/callback") {
97- cb = strings.TrimRight(cb, "/") + "/api/auth/callback"
98- }
99- config = oauth.NewPublicConfig(clientID, cb, oauthScopes)
87+ config = oauth.NewPublicConfig(clientID, callbackURL, oauthScopes)
10088 }
10189 oauthClient := oauth.NewClientApp(&config, oauthStore)
10290
10391 s := &Server{
104- dbs: dbs,
105- router: chi.NewMux(),
106- logger: logger,
107- oauth: oauthClient,
108- oauthStore: oauthStore,
109- fetcher: fetcher,
110- scheduler: scheduler,
111- engine: engine,
112- feedback: feedback.NewService(dbs.SQLDB()),
113- scraper: scraper.New(logger),
114- llm: textModel,
115- clientID: clientID,
116- callbackURL: callbackURL,
117- frontendURL: frontendURL,
118- sessionKey: sessionKey,
119- secureCookies: clientID != "",
120- allowedOrigin: frontendOrigin(frontendURL, clientID),
92+ dbs: dbs,
93+ router: chi.NewMux(),
94+ logger: logger,
95+ oauth: oauthClient,
96+ oauthStore: oauthStore,
97+ fetcher: fetcher,
98+ scheduler: scheduler,
99+ engine: engine,
100+ feedback: feedback.NewService(dbs.SQLDB()),
101+ scraper: scraper.New(logger),
102+ llm: textModel,
103+ clientID: clientID,
104+ frontendURL: frontendURL,
105+ sessionKey: sessionKey,
121106 }
122107
123108 s.setupMiddleware()
@@ -336,31 +321,28 @@ func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) {
336321 writeAPIError(w, http.StatusNotFound, "not found")
337322 }
338323
339-// allowedOrigins returns the browser origins permitted by CORS. In production
340-// this is the configured frontend URL; in localhost dev the SvelteKit proxy
341-// is same-origin so any value works, but we still avoid "*".
324+// allowedOrigins returns the browser origins permitted by CORS. The app is
325+// normally same-origin (SvelteKit proxies /api to Go); this mainly matters for
326+// the localhost dev server.
342327 func (s *Server) allowedOrigins() []string {
343- if s.allowedOrigin != "" {
344- return []string{s.allowedOrigin}
328+ if o := originOf(s.frontendURL); o != "" {
329+ return []string{o}
345330 }
346331 return []string{"http://localhost:3000", "http://localhost:5173"}
347332 }
348333
349-// frontendOrigin derives the browser origin (scheme://host) from frontendURL,
350-// falling back to the client ID host.
351-func frontendOrigin(frontendURL, clientID string) string {
352- for _, raw := range []string{frontendURL, clientID} {
353- if raw == "" {
354- continue
355- }
356- u, err := url.Parse(raw)
357- if err != nil || u.Host == "" {
358- continue
359- }
360- if u.Scheme == "" {
361- u.Scheme = "https"
362- }
363- return u.Scheme + "://" + u.Host
334+// originOf parses a URL and returns its scheme://host, defaulting an absent
335+// scheme to https. Returns "" if raw is empty or unparseable.
336+func originOf(raw string) string {
337+ if raw == "" {
338+ return ""
339+ }
340+ u, err := url.Parse(raw)
341+ if err != nil || u.Host == "" {
342+ return ""
343+ }
344+ if u.Scheme == "" {
345+ u.Scheme = "https"
364346 }
365- return ""
347+ return u.Scheme + "://" + u.Host
366348 }
@@ -48,28 +48,25 @@ var oauthScopes = []string{
48 }48 }
49 49
50 type Server struct {50 type Server struct {
51- dbs *db.Store51+ dbs *db.Store
52- router *chi.Mux52+ router *chi.Mux
53- logger *slog.Logger53+ logger *slog.Logger
54- oauth *oauth.ClientApp54+ oauth *oauth.ClientApp
55- oauthStore *db.OAuthStore55+ oauthStore *db.OAuthStore
56- fetcher *feed.Fetcher56+ fetcher *feed.Fetcher
57- scheduler *feed.Scheduler57+ scheduler *feed.Scheduler
58- engine *cluster.Engine58+ engine *cluster.Engine
59- feedback *feedback.Service59+ feedback *feedback.Service
60- scraper *scraper.Scraper60+ scraper *scraper.Scraper
61- llm ml.TextModel61+ llm ml.TextModel
62- clientID string62+ clientID string // empty in localhost dev; toggles the OAuth flow mode
63- callbackURL string63+ frontendURL string // public browser origin (e.g. https://glean.at)
64- frontendURL string64+ sessionKey []byte
65- sessionKey []byte
66- secureCookies bool // true in production (clientID set); gates cookie Secure flag
67- allowedOrigin string // configured browser origin for CSRF; empty in localhost dev
68 }65 }
69 66
70 func New(67 func New(
71 dbs *db.Store,68 dbs *db.Store,
72- clientID, callbackURL, frontendURL string,69+ clientID, frontendURL string,
73 scheduler *feed.Scheduler,70 scheduler *feed.Scheduler,
74 fetcher *feed.Fetcher,71 fetcher *feed.Fetcher,
75 engine *cluster.Engine,72 engine *cluster.Engine,
@@ -79,45 +76,33 @@ func New(
79 ) *Server {76 ) *Server {
80 oauthStore := db.NewOAuthStore(dbs)77 oauthStore := db.NewOAuthStore(dbs)
81 78
79+ // The OAuth callback is always served by the frontend at /api/auth/callback
80+ // (SvelteKit proxies it here). clientID empty = localhost dev flow.
81+ callbackURL := strings.TrimRight(frontendURL, "/") + "/api/auth/callback"
82+
82 var config oauth.ClientConfig83 var config oauth.ClientConfig
83 if clientID == "" {84 if clientID == "" {
84- // Localhost dev: the OAuth callback must go through the SvelteKit85+ config = oauth.NewLocalhostConfig(callbackURL, oauthScopes)
85- // frontend (which proxies /api to Go) so the post-callback redirect to
86- // /dashboard lands on the frontend, not on Go's API-only server.
87- origin := frontendURL
88- if origin == "" {
89- origin = "http://localhost:3000"
90- }
91- cbURL := strings.TrimRight(origin, "/") + "/api/auth/callback"
92- config = oauth.NewLocalhostConfig(cbURL, oauthScopes)
93 } else {86 } else {
94- // callbackURL points at the public SvelteKit origin, proxied to /api/auth/callback.87+ config = oauth.NewPublicConfig(clientID, callbackURL, oauthScopes)
95- cb := callbackURL
96- if !strings.Contains(cb, "/api/auth/callback") {
97- cb = strings.TrimRight(cb, "/") + "/api/auth/callback"
98- }
99- config = oauth.NewPublicConfig(clientID, cb, oauthScopes)
100 }88 }
101 oauthClient := oauth.NewClientApp(&config, oauthStore)89 oauthClient := oauth.NewClientApp(&config, oauthStore)
102 90
103 s := &Server{91 s := &Server{
104- dbs: dbs,92+ dbs: dbs,
105- router: chi.NewMux(),93+ router: chi.NewMux(),
106- logger: logger,94+ logger: logger,
107- oauth: oauthClient,95+ oauth: oauthClient,
108- oauthStore: oauthStore,96+ oauthStore: oauthStore,
109- fetcher: fetcher,97+ fetcher: fetcher,
110- scheduler: scheduler,98+ scheduler: scheduler,
111- engine: engine,99+ engine: engine,
112- feedback: feedback.NewService(dbs.SQLDB()),100+ feedback: feedback.NewService(dbs.SQLDB()),
113- scraper: scraper.New(logger),101+ scraper: scraper.New(logger),
114- llm: textModel,102+ llm: textModel,
115- clientID: clientID,103+ clientID: clientID,
116- callbackURL: callbackURL,104+ frontendURL: frontendURL,
117- frontendURL: frontendURL,105+ sessionKey: sessionKey,
118- sessionKey: sessionKey,
119- secureCookies: clientID != "",
120- allowedOrigin: frontendOrigin(frontendURL, clientID),
121 }106 }
122 107
123 s.setupMiddleware()108 s.setupMiddleware()
@@ -336,31 +321,28 @@ func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) {
336 writeAPIError(w, http.StatusNotFound, "not found")321 writeAPIError(w, http.StatusNotFound, "not found")
337 }322 }
338 323
339-// allowedOrigins returns the browser origins permitted by CORS. In production324+// allowedOrigins returns the browser origins permitted by CORS. The app is
340-// this is the configured frontend URL; in localhost dev the SvelteKit proxy325+// normally same-origin (SvelteKit proxies /api to Go); this mainly matters for
341-// is same-origin so any value works, but we still avoid "*".326+// the localhost dev server.
342 func (s *Server) allowedOrigins() []string {327 func (s *Server) allowedOrigins() []string {
343- if s.allowedOrigin != "" {328+ if o := originOf(s.frontendURL); o != "" {
344- return []string{s.allowedOrigin}329+ return []string{o}
345 }330 }
346 return []string{"http://localhost:3000", "http://localhost:5173"}331 return []string{"http://localhost:3000", "http://localhost:5173"}
347 }332 }
348 333
349-// frontendOrigin derives the browser origin (scheme://host) from frontendURL,334+// originOf parses a URL and returns its scheme://host, defaulting an absent
350-// falling back to the client ID host.335+// scheme to https. Returns "" if raw is empty or unparseable.
351-func frontendOrigin(frontendURL, clientID string) string {336+func originOf(raw string) string {
352- for _, raw := range []string{frontendURL, clientID} {337+ if raw == "" {
353- if raw == "" {338+ return ""
354- continue339+ }
355- }340+ u, err := url.Parse(raw)
356- u, err := url.Parse(raw)341+ if err != nil || u.Host == "" {
357- if err != nil || u.Host == "" {342+ return ""
358- continue343+ }
359- }344+ if u.Scheme == "" {
360- if u.Scheme == "" {345+ u.Scheme = "https"
361- u.Scheme = "https"
362- }
363- return u.Scheme + "://" + u.Host
364 }346 }
365- return ""347+ return u.Scheme + "://" + u.Host
366 }348 }
modified internal/server/session.go +20 -17
@@ -46,7 +46,7 @@ func (s *Server) getUserFromSession(r *http.Request) *db.User {
4646 return user
4747 }
4848
49-func (s *Server) setUserSession(w http.ResponseWriter, user *db.User) {
49+func (s *Server) setUserSession(w http.ResponseWriter, r *http.Request, user *db.User) {
5050 data := sessionData{DID: user.DID}
5151 encoded, err := encodeSession(s.sessionKey, data)
5252 if err != nil {
@@ -54,27 +54,30 @@ func (s *Server) setUserSession(w http.ResponseWriter, user *db.User) {
5454 return
5555 }
5656
57- http.SetCookie(w, &http.Cookie{
58- Name: "glean_session",
59- Value: encoded,
60- Path: "/",
61- MaxAge: 86400 * 30,
62- HttpOnly: true,
63- Secure: s.secureCookies,
64- SameSite: http.SameSiteLaxMode,
65- })
57+ http.SetCookie(w, sessionCookie("glean_session", encoded, 86400*30, r))
58+}
59+
60+func (s *Server) clearUserSession(w http.ResponseWriter, r *http.Request) {
61+ http.SetCookie(w, sessionCookie("glean_session", "", -1, r))
6662 }
6763
68-func (s *Server) clearUserSession(w http.ResponseWriter) {
69- http.SetCookie(w, &http.Cookie{
70- Name: "glean_session",
71- Value: "",
64+// sessionCookie builds the session cookie. Secure is set when the request
65+// arrived over TLS or a TLS-terminating proxy (X-Forwarded-Proto: https), so
66+// it is correct in production behind Caddy and off in plain-HTTP local dev.
67+func sessionCookie(name, value string, maxAge int, r *http.Request) *http.Cookie {
68+ return &http.Cookie{
69+ Name: name,
70+ Value: value,
7271 Path: "/",
73- MaxAge: -1,
72+ MaxAge: maxAge,
7473 HttpOnly: true,
75- Secure: s.secureCookies,
74+ Secure: isHTTPS(r),
7675 SameSite: http.SameSiteLaxMode,
77- })
76+ }
77+}
78+
79+func isHTTPS(r *http.Request) bool {
80+ return r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
7881 }
7982
8083 type sessionData struct {
@@ -46,7 +46,7 @@ func (s *Server) getUserFromSession(r *http.Request) *db.User {
46 return user46 return user
47 }47 }
48 48
49-func (s *Server) setUserSession(w http.ResponseWriter, user *db.User) {49+func (s *Server) setUserSession(w http.ResponseWriter, r *http.Request, user *db.User) {
50 data := sessionData{DID: user.DID}50 data := sessionData{DID: user.DID}
51 encoded, err := encodeSession(s.sessionKey, data)51 encoded, err := encodeSession(s.sessionKey, data)
52 if err != nil {52 if err != nil {
@@ -54,27 +54,30 @@ func (s *Server) setUserSession(w http.ResponseWriter, user *db.User) {
54 return54 return
55 }55 }
56 56
57- http.SetCookie(w, &http.Cookie{57+ http.SetCookie(w, sessionCookie("glean_session", encoded, 86400*30, r))
58- Name: "glean_session",58+}
59- Value: encoded,59+
60- Path: "/",60+func (s *Server) clearUserSession(w http.ResponseWriter, r *http.Request) {
61- MaxAge: 86400 * 30,61+ http.SetCookie(w, sessionCookie("glean_session", "", -1, r))
62- HttpOnly: true,
63- Secure: s.secureCookies,
64- SameSite: http.SameSiteLaxMode,
65- })
66 }62 }
67 63
68-func (s *Server) clearUserSession(w http.ResponseWriter) {64+// sessionCookie builds the session cookie. Secure is set when the request
69- http.SetCookie(w, &http.Cookie{65+// arrived over TLS or a TLS-terminating proxy (X-Forwarded-Proto: https), so
70- Name: "glean_session",66+// it is correct in production behind Caddy and off in plain-HTTP local dev.
71- Value: "",67+func sessionCookie(name, value string, maxAge int, r *http.Request) *http.Cookie {
68+ return &http.Cookie{
69+ Name: name,
70+ Value: value,
72 Path: "/",71 Path: "/",
73- MaxAge: -1,72+ MaxAge: maxAge,
74 HttpOnly: true,73 HttpOnly: true,
75- Secure: s.secureCookies,74+ Secure: isHTTPS(r),
76 SameSite: http.SameSiteLaxMode,75 SameSite: http.SameSiteLaxMode,
77- })76+ }
77+}
78+
79+func isHTTPS(r *http.Request) bool {
80+ return r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
78 }81 }
79 82
80 type sessionData struct {83 type sessionData struct {
modified main.go +6 -3
@@ -69,8 +69,11 @@ func main() {
6969 defer dbs.Close()
7070
7171 clientID := envOr("GLEAN_OAUTH_CLIENT_ID", "")
72- callbackURL := envOr("GLEAN_OAUTH_REDIRECT_URL", "")
73- frontendURL := envOr("GLEAN_FRONTEND_URL", "http://localhost:3000")
72+ frontendURL := envOr("GLEAN_FRONTEND_URL", "")
73+ if frontendURL == "" {
74+ fmt.Fprintln(os.Stderr, "GLEAN_FRONTEND_URL is required (the public browser origin, e.g. https://glean.at)")
75+ os.Exit(1)
76+ }
7477
7578 storeAdapter := db.NewFeedAdapter(dbs.Articles)
7679 siteFetcher := atproto.NewStandardSiteFetcher(logger)
@@ -105,7 +108,7 @@ func main() {
105108 engine := cluster.NewEngine(dbs.SQLDB(), dbs.Articles, embedder, llm, feedback.NewService(dbs.SQLDB()), logger, cluster.DefaultConfig())
106109
107110 fetcher := feed.NewFetcher(siteFetcher)
108- srv := server.New(dbs, clientID, callbackURL, frontendURL, scheduler, fetcher, engine, logger, []byte(sessionKey), llm)
111+ srv := server.New(dbs, clientID, frontendURL, scheduler, fetcher, engine, logger, []byte(sessionKey), llm)
109112
110113 cron := cluster.NewCron(engine, *clusterInterval, logger, dbs)
111114
@@ -69,8 +69,11 @@ func main() {
69 defer dbs.Close()69 defer dbs.Close()
70 70
71 clientID := envOr("GLEAN_OAUTH_CLIENT_ID", "")71 clientID := envOr("GLEAN_OAUTH_CLIENT_ID", "")
72- callbackURL := envOr("GLEAN_OAUTH_REDIRECT_URL", "")72+ frontendURL := envOr("GLEAN_FRONTEND_URL", "")
73- frontendURL := envOr("GLEAN_FRONTEND_URL", "http://localhost:3000")73+ if frontendURL == "" {
74+ fmt.Fprintln(os.Stderr, "GLEAN_FRONTEND_URL is required (the public browser origin, e.g. https://glean.at)")
75+ os.Exit(1)
76+ }
74 77
75 storeAdapter := db.NewFeedAdapter(dbs.Articles)78 storeAdapter := db.NewFeedAdapter(dbs.Articles)
76 siteFetcher := atproto.NewStandardSiteFetcher(logger)79 siteFetcher := atproto.NewStandardSiteFetcher(logger)
@@ -105,7 +108,7 @@ func main() {
105 engine := cluster.NewEngine(dbs.SQLDB(), dbs.Articles, embedder, llm, feedback.NewService(dbs.SQLDB()), logger, cluster.DefaultConfig())108 engine := cluster.NewEngine(dbs.SQLDB(), dbs.Articles, embedder, llm, feedback.NewService(dbs.SQLDB()), logger, cluster.DefaultConfig())
106 109
107 fetcher := feed.NewFetcher(siteFetcher)110 fetcher := feed.NewFetcher(siteFetcher)
108- srv := server.New(dbs, clientID, callbackURL, frontendURL, scheduler, fetcher, engine, logger, []byte(sessionKey), llm)111+ srv := server.New(dbs, clientID, frontendURL, scheduler, fetcher, engine, logger, []byte(sessionKey), llm)
109 112
110 cron := cluster.NewCron(engine, *clusterInterval, logger, dbs)113 cron := cluster.NewCron(engine, *clusterInterval, logger, dbs)
111 114
modified readme.md +27 -26
@@ -63,39 +63,40 @@ Then open `http://localhost:3000`.
6363
6464 ## Configuration
6565
66-| Variable | Default | What it does |
67-| ---------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------- |
68-| `GLEAN_SESSION_KEY` | _(required)_ | Secret key for signing session cookies (any random string) |
69-| `GLEAN_ADDR` | `:8080` | Listen address |
70-| `GLEAN_DB` | `glean.db` | SQLite base path (`_users`, `_articles`, `_recs` suffixes) |
71-| `GLEAN_JETSTREAM` | `wss://jetstream1.eurosky.network` | Jetstream WebSocket URL |
72-| `GLEAN_SYNC_INTERVAL` | `8h` | PDS sync interval (Go duration: `24h`, `12h`, etc.) |
73-| `GLEAN_CLUSTER_INTERVAL` | `1h` | Cluster recomputation interval (Go duration) |
74-| `GLEAN_FETCH_INTERVAL` | `15m` | Feed fetch scheduler tick interval (Go duration) |
75-| `GLEAN_COLLECTION_DIR_URL` | _(empty)_ | Collection directory URL for startup backfill |
76-| `GLEAN_BACKFILL_CONCURRENCY` | `5` | Max concurrent backfill workers |
77-| `GLEAN_PLC_URL` | `https://plc.eurosky.network` | PLC directory URL for DID resolution |
78-| `GLEAN_OAUTH_CLIENT_ID` | _(empty)_ | OAuth client metadata URL (leave empty for localhost dev) |
79-| `GLEAN_OAUTH_REDIRECT_URL` | _(empty)_ | OAuth redirect URL, must resolve to `/api/auth/callback` on the public origin (leave empty for localhost dev) |
80-| `GLEAN_FRONTEND_URL` | `http://localhost:3000` | Public origin of the SvelteKit frontend; used as the OAuth callback base in localhost dev |
81-| `GLEAN_EMBED_BASE_URL` | _(empty)_ | Embeddings API base URL (recommended, see below) |
82-| `GLEAN_EMBED_API_KEY` | _(empty)_ | API key for the embeddings endpoint |
83-| `GLEAN_EMBED_MODEL` | `text-embedding-3-small` | Embedding model name |
84-| `GLEAN_EMBED_DIMENSION` | `1536` | Embedding vector dimension |
85-| `GLEAN_LLM_BASE_URL` | _(empty)_ | LLM API base URL for language detection and digest summaries (see below) |
86-| `GLEAN_LLM_API_KEY` | _(empty)_ | API key for the LLM endpoint |
87-| `GLEAN_LLM_MODEL` | `gpt-4o-mini` | LLM model name |
88-| `GLEAN_PPROF_ADDR` | _(empty)_ | Enable pprof profiling server (e.g. `:6060`, off by default) |
66+| Variable | Default | What it does |
67+| ---------------------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
68+| `GLEAN_SESSION_KEY` | _(required)_ | Secret key for signing session cookies (any random string) |
69+| `GLEAN_ADDR` | `:8080` | Listen address |
70+| `GLEAN_DB` | `glean.db` | SQLite base path (`_users`, `_articles`, `_recs` suffixes) |
71+| `GLEAN_JETSTREAM` | `wss://jetstream1.eurosky.network` | Jetstream WebSocket URL |
72+| `GLEAN_SYNC_INTERVAL` | `8h` | PDS sync interval (Go duration: `24h`, `12h`, etc.) |
73+| `GLEAN_CLUSTER_INTERVAL` | `1h` | Cluster recomputation interval (Go duration) |
74+| `GLEAN_FETCH_INTERVAL` | `15m` | Feed fetch scheduler tick interval (Go duration) |
75+| `GLEAN_COLLECTION_DIR_URL` | _(empty)_ | Collection directory URL for startup backfill |
76+| `GLEAN_BACKFILL_CONCURRENCY` | `5` | Max concurrent backfill workers |
77+| `GLEAN_PLC_URL` | `https://plc.eurosky.network` | PLC directory URL for DID resolution |
78+| `GLEAN_OAUTH_CLIENT_ID` | _(empty)_ | OAuth client-metadata URL; enables production OAuth (leave empty for localhost dev). Must resolve to this server's `/api/oauth/client-metadata` |
79+| `GLEAN_FRONTEND_URL` | _(required)_ | Public origin of the SvelteKit frontend (e.g. `https://glean.at`); `make dev` defaults this to `http://localhost:3000` |
80+| `GLEAN_EMBED_BASE_URL` | _(empty)_ | Embeddings API base URL (recommended, see below) |
81+| `GLEAN_EMBED_API_KEY` | _(empty)_ | API key for the embeddings endpoint |
82+| `GLEAN_EMBED_MODEL` | `text-embedding-3-small` | Embedding model name |
83+| `GLEAN_EMBED_DIMENSION` | `1536` | Embedding vector dimension |
84+| `GLEAN_LLM_BASE_URL` | _(empty)_ | LLM API base URL for language detection and digest summaries (see below) |
85+| `GLEAN_LLM_API_KEY` | _(empty)_ | API key for the LLM endpoint |
86+| `GLEAN_LLM_MODEL` | `gpt-4o-mini` | LLM model name |
87+| `GLEAN_PPROF_ADDR` | _(empty)_ | Enable pprof profiling server (e.g. `:6060`, off by default) |
8988
9089 For production:
9190
9291 ```bash
93-export GLEAN_OAUTH_CLIENT_ID=https://yourdomain.com/oauth/client-metadata
94-export GLEAN_OAUTH_REDIRECT_URL=https://yourdomain.com/api/auth/callback
92+export GLEAN_OAUTH_CLIENT_ID=https://yourdomain.com/api/oauth/client-metadata
9593 ```
9694
95+The OAuth callback is always served at `$GLEAN_FRONTEND_URL/api/auth/callback` (the frontend proxies it to the backend), so there is no separate redirect-URL setting.
96+
9797 The SvelteKit server reads `GLEAN_API_URL` (default `http://localhost:8080`) to
98-know where the Go API is, and `ORIGIN`/`PORT` for its own listen address.
98+find the Go API and `PORT` for its listen address. The public origin lives in
99+`GLEAN_FRONTEND_URL`; SvelteKit's adapter-node `ORIGIN` is not needed here.
99100
100101 ## Documentation
101102
@@ -63,39 +63,40 @@ Then open `http://localhost:3000`.
63 63
64 ## Configuration64 ## Configuration
65 65
66-| Variable | Default | What it does |66+| Variable | Default | What it does |
67-| ---------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------- |67+| ---------------------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
68-| `GLEAN_SESSION_KEY` | _(required)_ | Secret key for signing session cookies (any random string) |68+| `GLEAN_SESSION_KEY` | _(required)_ | Secret key for signing session cookies (any random string) |
69-| `GLEAN_ADDR` | `:8080` | Listen address |69+| `GLEAN_ADDR` | `:8080` | Listen address |
70-| `GLEAN_DB` | `glean.db` | SQLite base path (`_users`, `_articles`, `_recs` suffixes) |70+| `GLEAN_DB` | `glean.db` | SQLite base path (`_users`, `_articles`, `_recs` suffixes) |
71-| `GLEAN_JETSTREAM` | `wss://jetstream1.eurosky.network` | Jetstream WebSocket URL |71+| `GLEAN_JETSTREAM` | `wss://jetstream1.eurosky.network` | Jetstream WebSocket URL |
72-| `GLEAN_SYNC_INTERVAL` | `8h` | PDS sync interval (Go duration: `24h`, `12h`, etc.) |72+| `GLEAN_SYNC_INTERVAL` | `8h` | PDS sync interval (Go duration: `24h`, `12h`, etc.) |
73-| `GLEAN_CLUSTER_INTERVAL` | `1h` | Cluster recomputation interval (Go duration) |73+| `GLEAN_CLUSTER_INTERVAL` | `1h` | Cluster recomputation interval (Go duration) |
74-| `GLEAN_FETCH_INTERVAL` | `15m` | Feed fetch scheduler tick interval (Go duration) |74+| `GLEAN_FETCH_INTERVAL` | `15m` | Feed fetch scheduler tick interval (Go duration) |
75-| `GLEAN_COLLECTION_DIR_URL` | _(empty)_ | Collection directory URL for startup backfill |75+| `GLEAN_COLLECTION_DIR_URL` | _(empty)_ | Collection directory URL for startup backfill |
76-| `GLEAN_BACKFILL_CONCURRENCY` | `5` | Max concurrent backfill workers |76+| `GLEAN_BACKFILL_CONCURRENCY` | `5` | Max concurrent backfill workers |
77-| `GLEAN_PLC_URL` | `https://plc.eurosky.network` | PLC directory URL for DID resolution |77+| `GLEAN_PLC_URL` | `https://plc.eurosky.network` | PLC directory URL for DID resolution |
78-| `GLEAN_OAUTH_CLIENT_ID` | _(empty)_ | OAuth client metadata URL (leave empty for localhost dev) |78+| `GLEAN_OAUTH_CLIENT_ID` | _(empty)_ | OAuth client-metadata URL; enables production OAuth (leave empty for localhost dev). Must resolve to this server's `/api/oauth/client-metadata` |
79-| `GLEAN_OAUTH_REDIRECT_URL` | _(empty)_ | OAuth redirect URL, must resolve to `/api/auth/callback` on the public origin (leave empty for localhost dev) |79+| `GLEAN_FRONTEND_URL` | _(required)_ | Public origin of the SvelteKit frontend (e.g. `https://glean.at`); `make dev` defaults this to `http://localhost:3000` |
80-| `GLEAN_FRONTEND_URL` | `http://localhost:3000` | Public origin of the SvelteKit frontend; used as the OAuth callback base in localhost dev |80+| `GLEAN_EMBED_BASE_URL` | _(empty)_ | Embeddings API base URL (recommended, see below) |
81-| `GLEAN_EMBED_BASE_URL` | _(empty)_ | Embeddings API base URL (recommended, see below) |81+| `GLEAN_EMBED_API_KEY` | _(empty)_ | API key for the embeddings endpoint |
82-| `GLEAN_EMBED_API_KEY` | _(empty)_ | API key for the embeddings endpoint |82+| `GLEAN_EMBED_MODEL` | `text-embedding-3-small` | Embedding model name |
83-| `GLEAN_EMBED_MODEL` | `text-embedding-3-small` | Embedding model name |83+| `GLEAN_EMBED_DIMENSION` | `1536` | Embedding vector dimension |
84-| `GLEAN_EMBED_DIMENSION` | `1536` | Embedding vector dimension |84+| `GLEAN_LLM_BASE_URL` | _(empty)_ | LLM API base URL for language detection and digest summaries (see below) |
85-| `GLEAN_LLM_BASE_URL` | _(empty)_ | LLM API base URL for language detection and digest summaries (see below) |85+| `GLEAN_LLM_API_KEY` | _(empty)_ | API key for the LLM endpoint |
86-| `GLEAN_LLM_API_KEY` | _(empty)_ | API key for the LLM endpoint |86+| `GLEAN_LLM_MODEL` | `gpt-4o-mini` | LLM model name |
87-| `GLEAN_LLM_MODEL` | `gpt-4o-mini` | LLM model name |87+| `GLEAN_PPROF_ADDR` | _(empty)_ | Enable pprof profiling server (e.g. `:6060`, off by default) |
88-| `GLEAN_PPROF_ADDR` | _(empty)_ | Enable pprof profiling server (e.g. `:6060`, off by default) |
89 88
90 For production:89 For production:
91 90
92 ```bash91 ```bash
93-export GLEAN_OAUTH_CLIENT_ID=https://yourdomain.com/oauth/client-metadata92+export GLEAN_OAUTH_CLIENT_ID=https://yourdomain.com/api/oauth/client-metadata
94-export GLEAN_OAUTH_REDIRECT_URL=https://yourdomain.com/api/auth/callback
95 ```93 ```
96 94
95+The OAuth callback is always served at `$GLEAN_FRONTEND_URL/api/auth/callback` (the frontend proxies it to the backend), so there is no separate redirect-URL setting.
96+
97 The SvelteKit server reads `GLEAN_API_URL` (default `http://localhost:8080`) to97 The SvelteKit server reads `GLEAN_API_URL` (default `http://localhost:8080`) to
98-know where the Go API is, and `ORIGIN`/`PORT` for its own listen address.98+find the Go API and `PORT` for its listen address. The public origin lives in
99+`GLEAN_FRONTEND_URL`; SvelteKit's adapter-node `ORIGIN` is not needed here.
99 100
100 ## Documentation101 ## Documentation
101 102
modified web/src/hooks.server.ts +7 -10
@@ -12,16 +12,6 @@ async function proxyApi({
1212 const target = API_URL.replace(/\/$/, "") + url.pathname + url.search;
1313
1414 const headers = new Headers(request.headers);
15- // Forward the browser's original Host so the Go CSRF same-origin check
16- // (Origin vs Host) passes. Go routes by path, not Host.
17- const originHost = request.headers.get("host");
18- if (originHost) {
19- headers.set("host", originHost);
20- headers.set("x-forwarded-host", originHost);
21- }
22- const proto = url.protocol.replace(":", "");
23- headers.set("x-forwarded-proto", proto);
24-
2515 const init: RequestInit = {
2616 method: request.method,
2717 headers,
@@ -29,6 +19,13 @@ async function proxyApi({
2919 request.method !== "GET" && request.method !== "HEAD"
3020 ? await request.arrayBuffer()
3121 : undefined,
22+ // The OAuth callback (/api/auth/callback) is a browser navigation, not an
23+ // XHR: the auth server redirects the user's browser here, and Go responds
24+ // with a 303 to a frontend route (/dashboard, /auth/login). Those routes
25+ // only exist on the SvelteKit origin, not the Go API, so the proxy must
26+ // NOT follow redirects server-side — pass the 303 through for the browser
27+ // to follow via the normal Caddy → SvelteKit chain.
28+ redirect: "manual",
3229 // @ts-expect-error Node fetch supports duplex for streaming request bodies.
3330 duplex: "half",
3431 };
@@ -12,16 +12,6 @@ async function proxyApi({
12 const target = API_URL.replace(/\/$/, "") + url.pathname + url.search;12 const target = API_URL.replace(/\/$/, "") + url.pathname + url.search;
13 13
14 const headers = new Headers(request.headers);14 const headers = new Headers(request.headers);
15- // Forward the browser's original Host so the Go CSRF same-origin check
16- // (Origin vs Host) passes. Go routes by path, not Host.
17- const originHost = request.headers.get("host");
18- if (originHost) {
19- headers.set("host", originHost);
20- headers.set("x-forwarded-host", originHost);
21- }
22- const proto = url.protocol.replace(":", "");
23- headers.set("x-forwarded-proto", proto);
24-
25 const init: RequestInit = {15 const init: RequestInit = {
26 method: request.method,16 method: request.method,
27 headers,17 headers,
@@ -29,6 +19,13 @@ async function proxyApi({
29 request.method !== "GET" && request.method !== "HEAD"19 request.method !== "GET" && request.method !== "HEAD"
30 ? await request.arrayBuffer()20 ? await request.arrayBuffer()
31 : undefined,21 : undefined,
22+ // The OAuth callback (/api/auth/callback) is a browser navigation, not an
23+ // XHR: the auth server redirects the user's browser here, and Go responds
24+ // with a 303 to a frontend route (/dashboard, /auth/login). Those routes
25+ // only exist on the SvelteKit origin, not the Go API, so the proxy must
26+ // NOT follow redirects server-side — pass the 303 through for the browser
27+ // to follow via the normal Caddy → SvelteKit chain.
28+ redirect: "manual",
32 // @ts-expect-error Node fetch supports duplex for streaming request bodies.29 // @ts-expect-error Node fetch supports duplex for streaming request bodies.
33 duplex: "half",30 duplex: "half",
34 };31 };
modified web/src/routes/+layout.svelte +3 -4
@@ -265,8 +265,7 @@
265265 <div class="space-y-2">
266266 <Logo size="sm" />
267267 <p class="max-w-xs text-[var(--muted)]">
268- The social RSS reader on AT Protocol. Your feeds, your data,
269- yours.
268+ The social RSS reader on AT Protocol.
270269 </p>
271270 </div>
272271 <div class="flex flex-wrap gap-x-10 gap-y-6">
@@ -317,7 +316,7 @@
317316 >
318317 Settings
319318 </p>
320- <ThemeToggle />
319+ <ThemeToggle block={true} />
321320 <button
322321 class="block font-bold uppercase hover:text-[var(--accent)]"
323322 onclick={() => (showShortcuts = true)}>Shortcuts</button
@@ -338,7 +337,7 @@
338337 <div
339338 class="mx-auto flex max-w-5xl flex-wrap items-center justify-between gap-2 px-4 py-3 text-[0.65rem] uppercase tracking-wide text-[var(--muted)]"
340339 >
341- <span>&copy; {new Date().getFullYear()} Glean</span>
340+ <span>&copy; {new Date().getFullYear()} Glean.at</span>
342341 <span
343342 >Made in Europe · <a
344343 href="https://bsky.app/profile/julien.rbrt.fr"
@@ -265,8 +265,7 @@
265 <div class="space-y-2">265 <div class="space-y-2">
266 <Logo size="sm" />266 <Logo size="sm" />
267 <p class="max-w-xs text-[var(--muted)]">267 <p class="max-w-xs text-[var(--muted)]">
268- The social RSS reader on AT Protocol. Your feeds, your data,268+ The social RSS reader on AT Protocol.
269- yours.
270 </p>269 </p>
271 </div>270 </div>
272 <div class="flex flex-wrap gap-x-10 gap-y-6">271 <div class="flex flex-wrap gap-x-10 gap-y-6">
@@ -317,7 +316,7 @@
317 >316 >
318 Settings317 Settings
319 </p>318 </p>
320- <ThemeToggle />319+ <ThemeToggle block={true} />
321 <button320 <button
322 class="block font-bold uppercase hover:text-[var(--accent)]"321 class="block font-bold uppercase hover:text-[var(--accent)]"
323 onclick={() => (showShortcuts = true)}>Shortcuts</button322 onclick={() => (showShortcuts = true)}>Shortcuts</button
@@ -338,7 +337,7 @@
338 <div337 <div
339 class="mx-auto flex max-w-5xl flex-wrap items-center justify-between gap-2 px-4 py-3 text-[0.65rem] uppercase tracking-wide text-[var(--muted)]"338 class="mx-auto flex max-w-5xl flex-wrap items-center justify-between gap-2 px-4 py-3 text-[0.65rem] uppercase tracking-wide text-[var(--muted)]"
340 >339 >
341- <span>&copy; {new Date().getFullYear()} Glean</span>340+ <span>&copy; {new Date().getFullYear()} Glean.at</span>
342 <span341 <span
343 >Made in Europe · <a342 >Made in Europe · <a
344 href="https://bsky.app/profile/julien.rbrt.fr"343 href="https://bsky.app/profile/julien.rbrt.fr"
modified web/src/routes/dashboard/+page.svelte +1 -0
@@ -64,6 +64,7 @@
6464 let digestOpen = $state(false);
6565 async function markDigestRead() {
6666 if (!digest) return;
67+ if (!confirm("Mark these articles as read?")) return;
6768 await endpoints.markDigestRead(digest.article_ids);
6869 digest = { ...digest, consumed: true };
6970 }
@@ -64,6 +64,7 @@
64 let digestOpen = $state(false);64 let digestOpen = $state(false);
65 async function markDigestRead() {65 async function markDigestRead() {
66 if (!digest) return;66 if (!digest) return;
67+ if (!confirm("Mark these articles as read?")) return;
67 await endpoints.markDigestRead(digest.article_ids);68 await endpoints.markDigestRead(digest.article_ids);
68 digest = { ...digest, consumed: true };69 digest = { ...digest, consumed: true };
69 }70 }
modified web/svelte.config.js +15 -6
@@ -1,12 +1,21 @@
1-import adapter from '@sveltejs/adapter-node';
2-import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
1+import adapter from "@sveltejs/adapter-node";
2+import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
33
44 /** @type {import('@sveltejs/kit').Config} */
55 const config = {
6- preprocess: vitePreprocess(),
7- kit: {
8- adapter: adapter()
9- }
6+ preprocess: vitePreprocess(),
7+ kit: {
8+ adapter: adapter(),
9+ // CSRF is enforced by the Go API (double-submit glean_csrf cookie +
10+ // X-CSRF-Token header). This app has no SvelteKit form actions; the
11+ // /api/* POSTs proxied here are form-encoded, which SvelteKit's own
12+ // origin check rejects whenever its computed url.origin differs from the
13+ // browser's (e.g. behind a TLS-terminating proxy). Trust all origins so
14+ // those requests reach the API, where Go's CSRF check applies.
15+ csrf: {
16+ trustedOrigins: ["*"],
17+ },
18+ },
1019 };
1120
1221 export default config;
@@ -1,12 +1,21 @@
1-import adapter from '@sveltejs/adapter-node';1+import adapter from "@sveltejs/adapter-node";
2-import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';2+import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
3 3
4 /** @type {import('@sveltejs/kit').Config} */4 /** @type {import('@sveltejs/kit').Config} */
5 const config = {5 const config = {
6- preprocess: vitePreprocess(),6+ preprocess: vitePreprocess(),
7- kit: {7+ kit: {
8- adapter: adapter()8+ adapter: adapter(),
9- }9+ // CSRF is enforced by the Go API (double-submit glean_csrf cookie +
10+ // X-CSRF-Token header). This app has no SvelteKit form actions; the
11+ // /api/* POSTs proxied here are form-encoded, which SvelteKit's own
12+ // origin check rejects whenever its computed url.origin differs from the
13+ // browser's (e.g. behind a TLS-terminating proxy). Trust all origins so
14+ // those requests reach the API, where Go's CSRF check applies.
15+ csrf: {
16+ trustedOrigins: ["*"],
17+ },
18+ },
10 };19 };
11 20
12 export default config;21 export default config;