aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGuillermo Serrahima <dev@vylion.com>2026-03-12 11:09:46 +0100
committerGuillermo Serrahima <dev@vylion.com>2026-03-12 11:09:46 +0100
commit20abd199c381eeab138563af897b54ecadc57bae (patch)
treef1340c1d9e13585fcfd29f9fab2280fcd889387b
parent60a0ae44cdf9bece1c15f20386e2ffdf80d80e96 (diff)
downloadllostre-20abd199c381eeab138563af897b54ecadc57bae.tar.xz
llostre-20abd199c381eeab138563af897b54ecadc57bae.zip
Implemented OAuth login
-rw-r--r--.gitignore3
-rw-r--r--backend/auth/dpop.go83
-rw-r--r--backend/auth/oauth.go194
-rw-r--r--backend/fetchers/app.bsky.go22
-rw-r--r--backend/fetchers/profiles.go38
-rw-r--r--backend/go.mod5
-rw-r--r--backend/go.sum4
-rw-r--r--backend/main.go275
-rw-r--r--frontend/login.html25
-rw-r--r--frontend/src/login.ts16
-rw-r--r--frontend/src/main.ts12
-rw-r--r--frontend/src/style.css17
-rw-r--r--frontend/vite.config.ts13
13 files changed, 691 insertions, 16 deletions
diff --git a/.gitignore b/.gitignore
index a01b0e5..03ef822 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
.vscode
-README.md \ No newline at end of file
+.env
+README.md
diff --git a/backend/auth/dpop.go b/backend/auth/dpop.go
new file mode 100644
index 0000000..2a8f6ed
--- /dev/null
+++ b/backend/auth/dpop.go
@@ -0,0 +1,83 @@
+package auth
+
+import (
+ "crypto"
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "encoding/base64"
+ "time"
+
+ "github.com/golang-jwt/jwt/v5"
+)
+
+type DPoPKeyPair struct {
+ PrivateKey *ecdsa.PrivateKey
+ PublicKey *ecdsa.PublicKey
+ JWK map[string]string
+}
+
+func GenerateDPoPKeyPair() (*DPoPKeyPair, error) {
+ privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ return nil, err
+ }
+
+ jwk := map[string]string{
+ "kty": "EC",
+ "crv": "P-256",
+ "x": base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.X.Bytes()),
+ "y": base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.Y.Bytes()),
+ }
+
+ return &DPoPKeyPair{
+ PrivateKey: privateKey,
+ PublicKey: &privateKey.PublicKey,
+ JWK: jwk,
+ }, nil
+}
+
+func (kp *DPoPKeyPair) GenerateDPoPProof(method, url, nonce string) (string, error) {
+ // generate unique ID for this proof
+ jti, err := GenerateRandomToken()
+ if err != nil {
+ return "", err
+ }
+
+ // create JWT header with JWK
+ header := map[string]interface{}{
+ "typ": "dpop+jwt",
+ "alg": "ES256",
+ "jwk": kp.JWK,
+ }
+
+ // create JWT claims
+ claims := jwt.MapClaims{
+ "jti": jti,
+ "htm": method, // HTTP method (GET, POST, etc)
+ "htu": url, // HTTP URL (the endpoint you're calling)
+ "iat": time.Now().Unix(),
+ }
+
+ // add nonce if provided
+ if nonce != "" {
+ claims["nonce"] = nonce
+ }
+
+ // create token with custom header
+ token := jwt.NewWithClaims(&jwt.SigningMethodECDSA{
+ Name: "ES256",
+ Hash: crypto.SHA256,
+ KeySize: 32,
+ CurveBits: 256,
+ }, claims)
+ token.Header = header
+
+ // sign with private key
+ tokenString, err := token.SignedString(kp.PrivateKey)
+ if err != nil {
+ return "", err
+ }
+
+ return tokenString, nil
+}
diff --git a/backend/auth/oauth.go b/backend/auth/oauth.go
new file mode 100644
index 0000000..012f339
--- /dev/null
+++ b/backend/auth/oauth.go
@@ -0,0 +1,194 @@
+package auth
+
+import (
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+)
+
+type OAuthConfig struct {
+ ClientID string
+ RedirectURI string
+ Scopes []string
+}
+
+type OAuthState struct {
+ State string
+ CodeVerifier string
+ DID string
+ PDS string
+ AccessToken string
+ RefreshToken string
+ KeyPair *DPoPKeyPair
+}
+
+type OAuthMetadata struct {
+ Issuer string `json:"issuer"`
+ AuthorizationEndpoint string `json:"authorization_endpoint"`
+ TokenEndpoint string `json:"token_endpoint"`
+ Scopes []string `json:"scopes_supported"`
+}
+
+type TokenResponse struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ TokenType string `json:"token_type"`
+ ExpiresIn int `json:"expires_in"`
+}
+
+func GenerateRandomToken() (string, error) {
+ b := make([]byte, 32)
+ _, err := rand.Read(b)
+ if err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(b), nil
+}
+
+func GenerateCodeChallenge(verifier string) string {
+ h := sha256.Sum256([]byte(verifier))
+ return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(h[:])
+}
+
+func FetchOAuthMetadata(pdsURL string) (*OAuthMetadata, error) {
+ // first get the resource metadata to find the auth server
+ resourceURL := fmt.Sprintf("%s/.well-known/oauth-protected-resource", pdsURL)
+ fmt.Printf("Fetching oauth metadata from: %s\n", resourceURL)
+ resp, err := http.Get(resourceURL)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ var resource struct {
+ AuthorizationServers []string `json:"authorization_servers"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&resource); err != nil {
+ return nil, err
+ }
+
+ if len(resource.AuthorizationServers) == 0 {
+ return nil, fmt.Errorf("no authorization servers found")
+ }
+
+ // now fetch the actual oauth metadata from the auth server
+ authServer := resource.AuthorizationServers[0]
+ metadataURL := fmt.Sprintf("%s/.well-known/oauth-authorization-server", authServer)
+
+ resp, err = http.Get(metadataURL)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ var metadata OAuthMetadata
+ if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil {
+ return nil, err
+ }
+
+ return &metadata, nil
+}
+
+func BuildAuthorizationURL(metadata *OAuthMetadata, config *OAuthConfig, state, codeChallenge string) string {
+ params := url.Values{
+ "response_type": {"code"},
+ "client_id": {config.ClientID},
+ "redirect_uri": {config.RedirectURI},
+ "state": {state},
+ "code_challenge": {codeChallenge},
+ "code_challenge_method": {"S256"},
+ "scope": {"atproto"},
+ }
+
+ return metadata.AuthorizationEndpoint + "?" + params.Encode()
+}
+
+func ExchangeCodeForTokens(pdsURL, code, verifier, backendURL string, keypair *DPoPKeyPair) (*TokenResponse, error) {
+ metadata, err := FetchOAuthMetadata(pdsURL)
+ if err != nil {
+ return nil, err
+ }
+
+ data := url.Values{
+ "grant_type": {"authorization_code"},
+ "code": {code},
+ "redirect_uri": {backendURL + "/api/callback"},
+ "code_verifier": {verifier},
+ "client_id": {backendURL + "/api/callback"},
+ }
+
+ fmt.Printf("Sending verifier: %s\n", verifier)
+
+ dpopProof, err := keypair.GenerateDPoPProof("POST", metadata.TokenEndpoint, "")
+ if err != nil {
+ return nil, err
+ }
+
+ req, err := http.NewRequest("POST", metadata.TokenEndpoint, strings.NewReader(data.Encode()))
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("DPoP", dpopProof)
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ // if nonce required, retry with nonce
+ if resp.StatusCode == 400 {
+ body, _ := io.ReadAll(resp.Body)
+ bodyStr := string(body)
+
+ if strings.Contains(bodyStr, "use_dpop_nonce") {
+ nonce := resp.Header.Get("DPoP-Nonce")
+ if nonce == "" {
+ return nil, fmt.Errorf("server requires nonce but didn't provide one")
+ }
+
+ // generate new DPoP proof with nonce
+ dpopProof, err = keypair.GenerateDPoPProof("POST", metadata.TokenEndpoint, nonce)
+ if err != nil {
+ return nil, err
+ }
+
+ retryReq, err := http.NewRequest("POST", metadata.TokenEndpoint, strings.NewReader(data.Encode()))
+ if err != nil {
+ return nil, err
+ }
+ retryReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ retryReq.Header.Set("DPoP", dpopProof)
+
+ resp, err = http.DefaultClient.Do(retryReq)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ } else {
+ // it's a different 400 error, don't retry
+ return nil, fmt.Errorf("token exchange failed: 400 - %s", bodyStr)
+ }
+ }
+
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ fmt.Printf("Token exchange error: %s\n", string(body))
+ return nil, fmt.Errorf("token exchange failed: %d - %s", resp.StatusCode, string(body))
+ }
+
+ var tokens TokenResponse
+ if err := json.NewDecoder(resp.Body).Decode(&tokens); err != nil {
+ return nil, err
+ }
+
+ return &tokens, nil
+}
diff --git a/backend/fetchers/app.bsky.go b/backend/fetchers/app.bsky.go
index 7c42f44..b6bb939 100644
--- a/backend/fetchers/app.bsky.go
+++ b/backend/fetchers/app.bsky.go
@@ -3,6 +3,7 @@ package fetchers
import (
"encoding/json"
"fmt"
+ "llostre/backend/auth"
"llostre/backend/notes"
"net/http"
)
@@ -11,10 +12,25 @@ type ListRecordsResponse struct {
Records []notes.AppBskyFeedPostRecord `json:"records"`
}
-func FetchAppBskyFeedPosts(did string, pdsURL string) ([]notes.AppBskyFeedPostNote, error) {
- url := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=app.bsky.feed.post&limit=50", pdsURL, did)
+func FetchAppBskyFeedPosts(did, pdsURL, accessToken string, keypair *auth.DPoPKeyPair) ([]notes.AppBskyFeedPostNote, error) {
+ url := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=app.bsky.feed.post", pdsURL, did)
- resp, err := http.Get(url)
+ fmt.Printf("Generating DPoP proof for: %s\n", url)
+ dpopProof, err := keypair.GenerateDPoPProof("GET", url, "")
+ if err != nil {
+ fmt.Printf("DPoP generation failed: %v\n", err)
+ return nil, err
+ }
+
+ fmt.Printf("Making request to PDS\n")
+ req, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Authorization", "Bearer "+accessToken)
+ req.Header.Set("DPoP", dpopProof)
+
+ resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
diff --git a/backend/fetchers/profiles.go b/backend/fetchers/profiles.go
index 038a07f..01a27c0 100644
--- a/backend/fetchers/profiles.go
+++ b/backend/fetchers/profiles.go
@@ -3,7 +3,9 @@ package fetchers
import (
"encoding/json"
"fmt"
+ "io"
"llostre/backend/general"
+ "net"
"net/http"
"strings"
)
@@ -17,6 +19,42 @@ type DIDDocument struct {
} `json:"service"`
}
+func FetchDID(handle string) (string, error) {
+ // return if it's a DID already
+ if strings.HasPrefix(handle, "did:") {
+ return handle, nil
+ }
+
+ // resolve DNS record first
+ txtRecords, err := net.LookupTXT("_atproto." + handle)
+ if err == nil {
+ for _, txt := range txtRecords {
+ if after, ok := strings.CutPrefix(txt, "did="); ok {
+ return after, nil
+ }
+ }
+ }
+
+ // fallback to HTTPS well-known
+ resp, err := http.Get(fmt.Sprintf("https://%s/.well-known/atproto-did", handle))
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", err
+ }
+
+ did := strings.TrimSpace(string(body))
+ if !strings.HasPrefix(did, "did:") {
+ return "", fmt.Errorf("invalid DID returned: %s", did)
+ }
+
+ return did, nil
+}
+
func FetchDidDoc(did string) (*DIDDocument, error) {
didDocURL := fmt.Sprintf("https://plc.directory/%s", did)
diff --git a/backend/go.mod b/backend/go.mod
index c893275..0fe3c28 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -1,3 +1,8 @@
module llostre/backend
go 1.25.7
+
+require (
+ github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
+ github.com/joho/godotenv v1.5.1 // indirect
+)
diff --git a/backend/go.sum b/backend/go.sum
new file mode 100644
index 0000000..a829f15
--- /dev/null
+++ b/backend/go.sum
@@ -0,0 +1,4 @@
+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
+github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
diff --git a/backend/main.go b/backend/main.go
index 1ca18df..cf87d0f 100644
--- a/backend/main.go
+++ b/backend/main.go
@@ -2,34 +2,287 @@ package main
import (
"encoding/json"
+ "fmt"
+ "llostre/backend/auth"
"llostre/backend/fetchers"
"net/http"
+ "os"
+ "strings"
+ "sync"
+
+ "github.com/joho/godotenv"
)
-func postsHandler(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Access-Control-Allow-Origin", "*")
- w.Header().Set("Content-Type", "application/json")
+var (
+ backendURL string
+ frontendURL string
+ sessionsMutex sync.RWMutex
+ sessions = make(map[string]*auth.OAuthState)
+)
- // wip
- did := "did:plc:hyyqbqqhpxt2bnn7s2e2iuxd"
- pdsURL := "https://bsky.vylion.com"
+func init() {
+ godotenv.Load("../.env")
- posts, err := fetchers.FetchAppBskyFeedPosts(did, pdsURL)
+ // 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, err.Error(), http.StatusInternalServerError)
+ 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)
- jsonData, err := json.Marshal(posts)
+ // 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)
+
+ // 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
}
- w.Write(jsonData)
+ 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.ListenAndServe(":8080", nil)
+ http.HandleFunc("/api/session", sessionHandler)
+ http.HandleFunc("/api/login", loginHandler)
+ http.HandleFunc("/api/callback", callbackHandler)
+ http.ListenAndServe("0.0.0.0:8080", nil)
}
diff --git a/frontend/login.html b/frontend/login.html
new file mode 100644
index 0000000..0f5ba0b
--- /dev/null
+++ b/frontend/login.html
@@ -0,0 +1,25 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>llostre - login</title>
+ <link rel="stylesheet" href="/src/style.css">
+</head>
+<body>
+ <div id="login-container">
+ <h2>log in to llostre</h2>
+ <form id="login-form">
+ <input
+ type="text"
+ id="handle-input"
+ placeholder="your_handle.bsky.social"
+ required
+ />
+ <button type="submit">log in</button>
+ </form>
+ <div id="login-error"></div>
+ </div>
+ <script type="module" src="/src/login.ts"></script>
+</body>
+</html>
diff --git a/frontend/src/login.ts b/frontend/src/login.ts
new file mode 100644
index 0000000..d8054c0
--- /dev/null
+++ b/frontend/src/login.ts
@@ -0,0 +1,16 @@
+const loginForm = document.getElementById('login-form') as HTMLFormElement;
+const handleInput = document.getElementById('handle-input') as HTMLInputElement;
+const loginError = document.getElementById('login-error') as HTMLDivElement;
+
+loginForm.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const handle = handleInput.value.trim();
+
+ if (!handle) {
+ loginError.textContent = 'please enter a handle';
+ return;
+ }
+
+ loginError.textContent = '';
+ window.location.href = `/api/login?handle=${encodeURIComponent(handle)}`;
+});
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index 20c3bea..4868365 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -68,6 +68,14 @@ function renderAuthor(note: any): string {
}
async function main() {
+ const sessionCheck = await fetch('/api/session', {
+ credentials: 'include'
+ });
+ if (!sessionCheck.ok) {
+ window.location.href = '/login.html';
+ throw new Error('not authenticated');
+ }
+
const app = document.querySelector('#app');
if (!app) {
console.error('App container not found');
@@ -77,7 +85,9 @@ async function main() {
app.innerHTML = '<div class="spinner"></div>';
try {
- const response = await fetch('http://localhost:8080/api/posts');
+ const response = await fetch('/api/posts', {
+ credentials: 'include'
+ });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const notes = await response.json();
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 549c596..700135c 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -13,6 +13,23 @@
-moz-osx-font-smoothing: grayscale;
}
+#login-container {
+ max-width: 400px;
+ margin: 2rem auto;
+ padding: 2rem;
+}
+
+#handle-input {
+ width: 100%;
+ padding: 0.5rem;
+ margin-bottom: 1rem;
+}
+
+#login-error {
+ color: red;
+ margin-top: 1rem;
+}
+
a {
font-weight: 500;
color: #646cff;
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..4420756
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,13 @@
+import { defineConfig } from 'vite'
+import { resolve } from 'path'
+
+export default defineConfig({
+ build: {
+ rollupOptions: {
+ input: {
+ main: resolve(__dirname, 'index.html'),
+ login: resolve(__dirname, 'login.html'),
+ },
+ },
+ },
+})