aboutsummaryrefslogtreecommitdiff
path: root/backend/main.go
diff options
context:
space:
mode:
Diffstat (limited to 'backend/main.go')
-rw-r--r--backend/main.go428
1 files changed, 0 insertions, 428 deletions
diff --git a/backend/main.go b/backend/main.go
deleted file mode 100644
index 6585e56..0000000
--- a/backend/main.go
+++ /dev/null
@@ -1,428 +0,0 @@
-package main
-
-import (
- "encoding/json"
- "fmt"
- "llostre/backend/auth"
- "llostre/backend/fetchers"
- "llostre/backend/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
-)
-
-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,
- }))
-
- 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
- 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.HandleFunc("/api/post/", postHandler) // /api/post/{uri}
- http.ListenAndServe("0.0.0.0:8080", nil)
-}