aboutsummaryrefslogtreecommitdiff
path: root/main.go
diff options
context:
space:
mode:
Diffstat (limited to 'main.go')
-rw-r--r--main.go441
1 files changed, 441 insertions, 0 deletions
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..6907fa4
--- /dev/null
+++ b/main.go
@@ -0,0 +1,441 @@
+package main
+
+import (
+ "embed"
+ "encoding/json"
+ "fmt"
+ "io/fs"
+ "llostre/auth"
+ "llostre/fetchers"
+ "llostre/notes"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "os"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/joho/godotenv"
+)
+
+var (
+ backendURL string
+ frontendURL string
+ sessionsMutex sync.RWMutex
+ sessions = make(map[string]*auth.OAuthState)
+ config *fetchers.Config
+ //go:embed frontend/dist
+ embeddedFrontend embed.FS
+)
+
+func init() {
+ godotenv.Load(".env")
+
+ // setup URLs
+ appDomain := os.Getenv("APP_DOMAIN")
+ if appDomain == "" {
+ appDomain = "http://127.0.0.1:8080"
+ }
+
+ backendURL = appDomain + "/api"
+ frontendURL = appDomain
+
+ // setup logger
+ logLevel := slog.LevelInfo
+ switch strings.ToLower(os.Getenv("LOG_LEVEL")) {
+ case "debug":
+ logLevel = slog.LevelDebug
+ case "info":
+ logLevel = slog.LevelInfo
+ case "warn":
+ logLevel = slog.LevelWarn
+ case "error":
+ logLevel = slog.LevelError
+ }
+
+ logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
+ Level: logLevel,
+ }))
+
+ logger.Debug("Logging configured", "log level", logLevel)
+
+ slingshotURL := os.Getenv("SLINGSHOT_URL")
+ ttlMin := os.Getenv("TTL_MIN")
+
+ var expiryMinutes int
+ if ttlMin != "" {
+ var err error
+ expiryMinutes, err = strconv.Atoi(ttlMin)
+ if err != nil {
+ panic(err)
+ }
+ } else {
+ expiryMinutes = 15
+ }
+ redisAddr := os.Getenv("REDIS_ADDR")
+ redisPassword := os.Getenv("REDIS_PASSWORD")
+ postCache, err := fetchers.NewCache(redisAddr, redisPassword, expiryMinutes)
+ if err != nil {
+ config.Logger.Error("failed to connect to redis", "error", err)
+ panic(err)
+ }
+
+ config = &fetchers.Config{
+ SlingshotURL: slingshotURL,
+ PageSize: 50,
+ Logger: logger,
+ Cache: postCache,
+ }
+}
+
+func loginHandler(w http.ResponseWriter, r *http.Request) {
+ handle := r.URL.Query().Get("handle")
+ if handle == "" {
+ http.Error(w, "handle parameter required", http.StatusBadRequest)
+ return
+ }
+
+ did, err := fetchers.FetchDID(handle)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("failed to resolve handle: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ pds, err := fetchers.FetchPDS(did)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("failed to fetch PDS: %v", err), http.StatusInternalServerError)
+ return
+ }
+ config.Logger.Debug("Resolved DID and PDS", "did", did, "pds", pds)
+
+ // fetch oauth metadata from PDS
+ metadata, err := auth.FetchOAuthMetadata(pds)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("failed to fetch oauth metadata: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ state, err := auth.GenerateRandomToken()
+ if err != nil {
+ http.Error(w, "failed to generate state", http.StatusInternalServerError)
+ return
+ }
+
+ verifier, err := auth.GenerateRandomToken()
+ if err != nil {
+ http.Error(w, "failed to generate verifier", http.StatusInternalServerError)
+ return
+ }
+
+ // generate DPoP keypair
+ keypair, err := auth.GenerateDPoPKeyPair()
+ if err != nil {
+ http.Error(w, "failed to generate keypair", http.StatusInternalServerError)
+ return
+ }
+
+ sessionsMutex.Lock()
+ sessions[state] = &auth.OAuthState{
+ State: state,
+ CodeVerifier: verifier,
+ DID: did,
+ PDS: pds,
+ KeyPair: keypair,
+ }
+ sessionsMutex.Unlock()
+
+ config := auth.OAuthConfig{
+ ClientID: backendURL + "/callback",
+ RedirectURI: backendURL + "/callback",
+ Scopes: []string{"atproto"},
+ }
+
+ challenge := auth.GenerateCodeChallenge(verifier)
+ authURL := auth.BuildAuthorizationURL(metadata, &config, state, challenge, handle)
+
+ // redirect user to PDS
+ http.Redirect(w, r, authURL, http.StatusFound)
+}
+
+func callbackHandler(w http.ResponseWriter, r *http.Request) {
+ // serve client metadata if no oauth params present
+ if r.URL.Query().Get("code") == "" && r.URL.Query().Get("state") == "" && r.URL.Query().Get("error") == "" {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "client_id": backendURL + "/callback",
+ "client_name": "llostre",
+ "redirect_uris": []string{backendURL + "/callback"},
+ "scope": "atproto",
+ "grant_types": []string{"authorization_code", "refresh_token"},
+ "response_types": []string{"code"},
+ "token_endpoint_auth_method": "none",
+ "application_type": "web",
+ "dpop_bound_access_tokens": true,
+ })
+ return
+ }
+
+ code := r.URL.Query().Get("code")
+ state := r.URL.Query().Get("state")
+
+ if code == "" || state == "" {
+ http.Error(w, "missing code or state", http.StatusBadRequest)
+ return
+ }
+
+ // verify state matches stored session
+ sessionsMutex.RLock()
+ oauthState, exists := sessions[state]
+ sessionsMutex.RUnlock()
+
+ if !exists {
+ http.Error(w, "invalid state", http.StatusBadRequest)
+ return
+ }
+
+ // exchange code for tokens
+ tokens, err := auth.ExchangeCodeForTokens(
+ oauthState.PDS,
+ code,
+ oauthState.CodeVerifier,
+ backendURL+"/callback",
+ oauthState.KeyPair,
+ )
+ if err != nil {
+ http.Error(w, fmt.Sprintf("token exchange failed: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ // create session with tokens
+ sessionToken, err := auth.GenerateRandomToken()
+ if err != nil {
+ http.Error(w, "failed to generate session token", http.StatusInternalServerError)
+ return
+ }
+
+ // store session with tokens from the callback
+ sessionsMutex.Lock()
+ sessions[sessionToken] = &auth.OAuthState{
+ State: sessionToken,
+ DID: oauthState.DID,
+ PDS: oauthState.PDS,
+ AccessToken: tokens.AccessToken,
+ RefreshToken: tokens.RefreshToken,
+ ExpiresAt: time.Now().Add(time.Duration(tokens.ExpiresIn) * time.Second),
+ KeyPair: oauthState.KeyPair,
+ }
+ delete(sessions, state) // remove the partial oauth state from login
+ sessionsMutex.Unlock()
+
+ config.Logger.Debug("About to set cookie", "token", sessionToken)
+
+ // set session cookie
+ http.SetCookie(w, &http.Cookie{
+ Name: "session_token",
+ Value: sessionToken,
+ Path: "/",
+ HttpOnly: true,
+ Secure: strings.HasPrefix(backendURL, "https"),
+ SameSite: http.SameSiteLaxMode,
+ MaxAge: 86400 * 30,
+ })
+
+ config.Logger.Debug("Cookie set, redirecting to frontend", "destination", frontendURL+"/")
+
+ // redirect to main app
+ http.Redirect(w, r, frontendURL, http.StatusFound)
+}
+
+func sessionHandler(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Access-Control-Allow-Origin", frontendURL)
+ w.Header().Set("Access-Control-Allow-Credentials", "true")
+ w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
+ w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
+
+ if r.Method == "OPTIONS" {
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+
+ // get session token from cookie
+ cookie, err := r.Cookie("session_token")
+ if err != nil {
+ w.WriteHeader(http.StatusUnauthorized)
+ return
+ }
+
+ // check if session exists
+ sessionsMutex.RLock()
+ _, exists := sessions[cookie.Value]
+ sessionsMutex.RUnlock()
+
+ if !exists {
+ w.WriteHeader(http.StatusUnauthorized)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+func getSession(cookieValue string) (*auth.OAuthState, error) {
+ // Check for access token refresh
+ sessionsMutex.RLock()
+ session, exists := sessions[cookieValue]
+ if !exists {
+ return nil, fmt.Errorf("no session found")
+ }
+ sessionRead := session.Copy()
+ sessionsMutex.RUnlock()
+
+ config.Logger.Debug("session found", "did", session.DID, "pds", session.PDS, "access token length", len(session.AccessToken), "session keypair", session.KeyPair)
+ if session.KeyPair != nil {
+ config.Logger.Debug("session public key", "key", session.KeyPair.PublicKey)
+ }
+
+ needsRefresh := time.Now().After(sessionRead.ExpiresAt)
+
+ if needsRefresh {
+ newTokens, err := auth.RefreshAccessToken(sessionRead.PDS, sessionRead.RefreshToken, backendURL+"/callback", sessionRead.KeyPair)
+ if err != nil {
+ sessionsMutex.Lock()
+ delete(sessions, cookieValue)
+ sessionsMutex.Unlock()
+ config.Logger.Error("no authorization servers found for token refresh")
+ return nil, fmt.Errorf("no authorization servers found")
+ }
+
+ sessionsMutex.Lock()
+ session, _ = sessions[cookieValue]
+ session.AccessToken = newTokens.AccessToken
+ session.RefreshToken = newTokens.RefreshToken
+ session.ExpiresAt = time.Now().Add(time.Duration(newTokens.ExpiresIn) * time.Second)
+ sessionsMutex.Unlock()
+ }
+
+ return session, nil
+}
+
+func postsHandler(w http.ResponseWriter, r *http.Request) {
+ config.Logger.Debug("posts handler start")
+
+ cookie, err := r.Cookie("session_token")
+ if err != nil {
+ config.Logger.Error("cookie not received", "error", err)
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ config.Logger.Debug("cookie received", "value", cookie.Value)
+
+ session, err := getSession(cookie.Value)
+ if err != nil {
+ http.Error(w, "invalid session", http.StatusUnauthorized)
+ return
+ }
+
+ // Fetch app.bsky.feed.post records
+ var posts []notes.Note
+
+ cached, err := config.Cache.GetPostsByDID(session.DID)
+ config.Logger.Debug("cache read", "count", len(cached), "err", err)
+ if err != nil {
+ config.Logger.Warn("cache read failed, falling back to fetch", "error", err)
+ }
+
+ if len(cached) > 0 {
+ posts = notes.DeduplicateThreads(cached)
+ } else {
+ posts, err = fetchers.FetchAppBskyFeedPostsWithCursor(config, session.DID, session.PDS, session, "")
+ if err != nil {
+ config.Logger.Error("fetching app.bsky.feed.posts failed", "error", err)
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ }
+ config.Logger.Debug("posts ready", "count", len(posts))
+
+ // background refetch if cache is running low
+ oldestSent, hasOldest := config.Cache.GetOldestSent(session.DID)
+ if !hasOldest || len(posts) > 0 {
+ oldest := posts[len(posts)-1].CreatedAt
+ count, _ := config.Cache.CountPostsOlderThan(session.DID, oldest)
+ cursor, hasCursor := config.Cache.GetCursor(session.DID)
+ config.Logger.Debug("refetch check", "count", count, "pageSize", config.PageSize, "hasCursor", hasCursor, "oldest", oldest)
+ if count < config.PageSize && hasCursor {
+ sessionCopy := session.Copy()
+ go fetchers.FetchAppBskyFeedPostsWithCursor(config, sessionCopy.DID, sessionCopy.PDS, sessionCopy, cursor)
+ }
+ if !hasOldest || oldest.Before(oldestSent) {
+ config.Cache.SetOldestSent(session.DID, oldest)
+ }
+ }
+
+ // return posts
+ config.Logger.Debug("successfully fetched posts", "number", len(posts))
+
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(posts)
+}
+
+func postHandler(w http.ResponseWriter, r *http.Request) {
+ uri := strings.TrimPrefix(r.URL.Path, "/api/post/")
+ if uri == "" {
+ http.Error(w, "uri required", http.StatusBadRequest)
+ return
+ }
+
+ // decode if URL-encoded
+ uri, err := url.QueryUnescape(uri)
+ if err != nil {
+ http.Error(w, "invalid uri", http.StatusBadRequest)
+ return
+ }
+
+ var currentSession *auth.OAuthState
+ cookie, err := r.Cookie("session_token")
+ if err != nil {
+ config.Logger.Warn("unauthorized")
+ } else {
+ session, err := getSession(cookie.Value)
+
+ if err != nil {
+ config.Logger.Warn("invalid session", "error", err)
+ } else {
+ currentSession = session
+ }
+ }
+
+ note, err := fetchers.GetAppBskyFeedPostByURI(config, uri, currentSession)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(note)
+}
+
+func main() {
+ defer config.Cache.Close()
+
+ // serve static files
+ distFS, err := fs.Sub(embeddedFrontend, "frontend/dist")
+ if err != nil {
+ panic(err)
+ }
+ entries, _ := embeddedFrontend.ReadDir("frontend/dist")
+ for _, e := range entries {
+ config.Logger.Debug("embedded file", "name", e.Name())
+ }
+ http.Handle("/", http.FileServer(http.FS(distFS)))
+
+ // api routes
+ http.HandleFunc("/api/posts", postsHandler)
+ http.HandleFunc("/api/session", sessionHandler)
+ http.HandleFunc("/api/login", loginHandler)
+ http.HandleFunc("/api/callback", callbackHandler)
+ http.HandleFunc("/api/post/", postHandler) // /api/post/{uri}
+ http.ListenAndServe("0.0.0.0:8080", nil)
+}