diff options
| author | Guillermo Serrahima <dev@vylion.com> | 2026-04-11 07:40:30 +0200 |
|---|---|---|
| committer | Guillermo Serrahima <dev@vylion.com> | 2026-04-11 23:39:19 +0200 |
| commit | 9f0a9a6779f00786e4728ad5c3fad36814a293b0 (patch) | |
| tree | 4b16d535c5d41ef46980608a88b7581be3c2bfd0 /main.go | |
| parent | f6d60077b7cfc0d589fa7961e2bb59aea9f993a3 (diff) | |
| download | llostre-9f0a9a6779f00786e4728ad5c3fad36814a293b0.tar.xz llostre-9f0a9a6779f00786e4728ad5c3fad36814a293b0.zip | |
project restructure
Diffstat (limited to 'main.go')
| -rw-r--r-- | main.go | 441 |
1 files changed, 441 insertions, 0 deletions
@@ -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) +} |
