diff options
Diffstat (limited to 'backend/main.go')
| -rw-r--r-- | backend/main.go | 428 |
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) -} |
