package main import ( "encoding/json" "fmt" "llostre/backend/auth" "llostre/backend/fetchers" "net/http" "os" "strings" "sync" "github.com/joho/godotenv" ) var ( backendURL string frontendURL string sessionsMutex sync.RWMutex sessions = make(map[string]*auth.OAuthState) ) func init() { godotenv.Load("../.env") // fallback values appDomain := os.Getenv("APP_DOMAIN") if appDomain == "" { appDomain = "127.0.0.1" } frontendPort := os.Getenv("APP_FRONTEND_PORT") if frontendPort == "" { frontendPort = "8080" } backendPort := os.Getenv("APP_BACKEND_PORT") if backendPort == "" { backendPort = "8080" } backendURL = fmt.Sprintf("%s", appDomain) frontendURL = backendURL } 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 } fmt.Printf("Resolved DID: %s\n", did) fmt.Printf("Resolved PDS: %s\n", 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 + "/api/callback", RedirectURI: backendURL + "/api/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 + "/api/callback", "client_name": "llostre", "redirect_uris": []string{backendURL + "/api/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, 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, KeyPair: oauthState.KeyPair, } delete(sessions, state) // remove the partial oauth state from login sessionsMutex.Unlock() fmt.Printf("About to set cookie with token: %s\n", 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, }) fmt.Printf("Cookie set, redirecting to: %s\n", 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 postsHandler(w http.ResponseWriter, r *http.Request) { fmt.Printf("\n=== POST HANDLER START ===\n") cookie, err := r.Cookie("session_token") if err != nil { fmt.Printf("Cookie error: %v\n", err) http.Error(w, "unauthorized", http.StatusUnauthorized) return } fmt.Printf("Cookie value: %s\n", cookie.Value) sessionsMutex.RLock() session, exists := sessions[cookie.Value] fmt.Printf("Session exists: %v\n", exists) if exists { fmt.Printf("Session DID: %s\n", session.DID) fmt.Printf("Session PDS: %s\n", session.PDS) fmt.Printf("Session AccessToken length: %d\n", len(session.AccessToken)) fmt.Printf("Session KeyPair: %p\n", session.KeyPair) if session.KeyPair != nil { fmt.Printf("KeyPair PublicKey: %p\n", session.KeyPair.PublicKey) } } sessionsMutex.RUnlock() if !exists { http.Error(w, "invalid session", http.StatusUnauthorized) return } posts, err := fetchers.FetchAppBskyFeedPosts(session.DID, session.PDS, session.AccessToken, session.KeyPair) if err != nil { fmt.Printf("FetchAppBskyFeedPosts error: %v\n", err) http.Error(w, err.Error(), http.StatusInternalServerError) return } fmt.Printf("Successfully fetched %d posts\n", len(posts)) w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(posts) } func main() { // serve static files fs := http.FileServer(http.Dir("../frontend/dist")) http.Handle("/", fs) // api routes http.HandleFunc("/api/posts", postsHandler) http.HandleFunc("/api/session", sessionHandler) http.HandleFunc("/api/login", loginHandler) http.HandleFunc("/api/callback", callbackHandler) http.ListenAndServe("0.0.0.0:8080", nil) }