diff options
| author | Guillermo Serrahima <dev@vylion.com> | 2026-03-12 11:09:46 +0100 |
|---|---|---|
| committer | Guillermo Serrahima <dev@vylion.com> | 2026-03-12 11:09:46 +0100 |
| commit | 20abd199c381eeab138563af897b54ecadc57bae (patch) | |
| tree | f1340c1d9e13585fcfd29f9fab2280fcd889387b | |
| parent | 60a0ae44cdf9bece1c15f20386e2ffdf80d80e96 (diff) | |
| download | llostre-20abd199c381eeab138563af897b54ecadc57bae.tar.xz llostre-20abd199c381eeab138563af897b54ecadc57bae.zip | |
Implemented OAuth login
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | backend/auth/dpop.go | 83 | ||||
| -rw-r--r-- | backend/auth/oauth.go | 194 | ||||
| -rw-r--r-- | backend/fetchers/app.bsky.go | 22 | ||||
| -rw-r--r-- | backend/fetchers/profiles.go | 38 | ||||
| -rw-r--r-- | backend/go.mod | 5 | ||||
| -rw-r--r-- | backend/go.sum | 4 | ||||
| -rw-r--r-- | backend/main.go | 275 | ||||
| -rw-r--r-- | frontend/login.html | 25 | ||||
| -rw-r--r-- | frontend/src/login.ts | 16 | ||||
| -rw-r--r-- | frontend/src/main.ts | 12 | ||||
| -rw-r--r-- | frontend/src/style.css | 17 | ||||
| -rw-r--r-- | frontend/vite.config.ts | 13 |
13 files changed, 691 insertions, 16 deletions
@@ -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'), + }, + }, + }, +}) |
