package main import ( "embed" "encoding/json" "fmt" "io" "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") postExpiryMinutes := parseTTLMinutes("POST_TTL_MIN", 4320) profileExpiryMinutes := parseTTLMinutes("PROFILE_TTL_MIN", 10080) valkeyAddr := os.Getenv("VALKEY_ADDR") valkeyPassword := os.Getenv("VALKEY_PASSWORD") postCache, err := fetchers.NewCache(valkeyAddr, valkeyPassword, postExpiryMinutes, profileExpiryMinutes) if err != nil { logger.Error("failed to connect to valkey", "error", err) panic(err) } porxieAddr := os.Getenv("PORXIE_ADDR") if porxieAddr == "" { porxieAddr = "porxie:6314" } jetstreamURL := os.Getenv("JETSTREAM_URL") if jetstreamURL == "" { jetstreamURL = "wss://jetstream2.fr.hose.cam/subscribe" } config = &fetchers.Config{ SlingshotURL: slingshotURL, PorxieURL: fmt.Sprintf("http://%s", porxieAddr), JetstreamURL: jetstreamURL, PageSize: 50, Logger: logger, Cache: postCache, } } func parseTTLMinutes(envVar string, defaultMinutes int) int { raw := os.Getenv(envVar) if raw == "" { return defaultMinutes } minutes, err := strconv.Atoi(raw) if err != nil { panic(err) } return minutes } 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 } posts = notes.DeduplicateThreads(posts) } config.Logger.Debug("posts ready", "count", len(posts)) // background refetch if cache is running low or has developed a gap 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) // skip right after a fresh fetch; expected count was just reset to match it hasGap := false if len(cached) > 0 { if expectedCount, hasExpected := config.Cache.GetPostCount(session.DID); hasExpected { hasGap = len(cached) < expectedCount } } config.Logger.Debug("refetch check", "count", count, "pageSize", config.PageSize, "hasCursor", hasCursor, "oldest", oldest, "hasGap", hasGap) switch { case hasGap: sessionCopy := session.Copy() go fetchers.FetchAppBskyFeedPostsWithCursor(config, sessionCopy.DID, sessionCopy.PDS, sessionCopy, "") case 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 blobHandler(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/api/blob/") parts := strings.SplitN(path, "/", 2) if len(parts) != 2 { http.Error(w, "invalid blob path", http.StatusBadRequest) return } porxieResp, err := http.Get(fmt.Sprintf("%s/%s/%s", config.PorxieURL, parts[0], parts[1])) if err != nil { config.Logger.Error("blob fetch failed", "url", porxieResp, "error", err) http.Error(w, "blob fetch failed", http.StatusBadGateway) return } defer porxieResp.Body.Close() w.Header().Set("Content-Type", porxieResp.Header.Get("Content-Type")) w.Header().Set("Cache-Control", porxieResp.Header.Get("Cache-Control")) w.WriteHeader(porxieResp.StatusCode) io.Copy(w, porxieResp.Body) } func main() { defer config.Cache.Close() go fetchers.StartJetstream(config) // 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.HandleFunc("/api/blob/", blobHandler) http.ListenAndServe("0.0.0.0:8080", nil) }