From 9c19769eb0b7937278876f31ef8e14b3cead3001 Mon Sep 17 00:00:00 2001 From: M1ngdaXie Date: Sun, 15 Mar 2026 09:45:17 +0000 Subject: [PATCH] feat: add guest mode, bug fixes, and self-hosted config Co-Authored-By: Claude Sonnet 4.6 --- backend/.gitignore | 2 +- backend/cmd/server/main.go | 1 + backend/internal/handlers/auth.go | 70 +++++++++++++------ backend/internal/handlers/websocket.go | 10 +-- .../internal/workers/update_persist_worker.go | 4 +- backend/scripts/012_add_guest_provider.sql | 3 + frontend/src/api/auth.ts | 9 +++ frontend/src/components/ProtectedRoute.tsx | 2 +- frontend/src/components/Share/ShareModal.tsx | 2 +- frontend/src/contexts/AuthContext.tsx | 6 +- frontend/src/pages/AuthCallback.tsx | 6 +- frontend/src/pages/LandingPage.css | 13 ++++ frontend/src/pages/LandingPage.tsx | 28 ++++++++ frontend/src/pages/LoginPage.css | 33 +++++++++ frontend/src/pages/LoginPage.tsx | 34 ++++++++- 15 files changed, 187 insertions(+), 36 deletions(-) create mode 100644 backend/scripts/012_add_guest_provider.sql diff --git a/backend/.gitignore b/backend/.gitignore index 92cfb6b..3173eb8 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -4,7 +4,7 @@ .env.*.local # Compiled binaries -server +/server *.exe *.exe~ *.dll diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index ab6bc17..8b60465 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -204,6 +204,7 @@ func main() { authGroup.GET("/google/callback", authHandler.GoogleCallback) authGroup.GET("/github", authHandler.GithubLogin) authGroup.GET("/github/callback", authHandler.GithubCallback) + authGroup.POST("/guest", authHandler.GuestLogin) authGroup.GET("/me", authMiddleware.RequireAuth(), authHandler.Me) authGroup.POST("/logout", authMiddleware.RequireAuth(), authHandler.Logout) } diff --git a/backend/internal/handlers/auth.go b/backend/internal/handlers/auth.go index 580b062..d2c1943 100644 --- a/backend/internal/handlers/auth.go +++ b/backend/internal/handlers/auth.go @@ -6,7 +6,7 @@ import ( "encoding/json" "fmt" "io" - "log" + "net/http" "time" @@ -68,7 +68,7 @@ func (h *AuthHandler) GoogleCallback(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid oauth state"}) return } - log.Println("Google callback state:", c.Query("state")) + // Exchange code for token token, err := h.googleConfig.Exchange(c.Request.Context(), c.Query("code")) if err != nil { @@ -83,8 +83,7 @@ func (h *AuthHandler) GoogleCallback(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user info"}) return } - log.Println("Google user info response status:", resp.Status) - log.Println("Google user info response headers:", resp.Header) + defer resp.Body.Close() data, _ := io.ReadAll(resp.Body) @@ -96,11 +95,11 @@ func (h *AuthHandler) GoogleCallback(c *gin.Context) { } if err := json.Unmarshal(data, &userInfo); err != nil { - log.Printf("Failed to parse Google response: %v | Data: %s", err, string(data)) + // Failed to parse Google response c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid Google response"}) return } - log.Println("Google user info:", userInfo) + // Upsert user in database user, err := h.store.UpsertUser( c.Request.Context(), @@ -116,12 +115,9 @@ func (h *AuthHandler) GoogleCallback(c *gin.Context) { } // Create session and JWT - jwt, err := h.createSessionAndJWT(c, user) + jwt, err := h.createSessionAndJWT(c, user, 7*24*time.Hour) if err != nil { - fmt.Printf("❌ DATABASE ERROR: %v\n", err) - c.JSON(http.StatusInternalServerError, gin.H{ - "error": fmt.Sprintf("CreateSession Error: %v", err), - }) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create session"}) return } @@ -144,7 +140,7 @@ func (h *AuthHandler) GithubCallback(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid oauth state"}) return } - log.Println("Github callback state:", c.Query("state")) + code := c.Query("code") if code == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "No code provided"}) @@ -178,7 +174,7 @@ func (h *AuthHandler) GithubCallback(c *gin.Context) { AvatarURL string `json:"avatar_url"` } if err := json.Unmarshal(data, &userInfo); err != nil { - log.Printf("Failed to parse GitHub response: %v | Data: %s", err, string(data)) + // Failed to parse GitHub response c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid GitHub response"}) return } @@ -207,8 +203,7 @@ func (h *AuthHandler) GithubCallback(c *gin.Context) { if userInfo.Name == "" { userInfo.Name = userInfo.Login } - fmt.Println("Getting user info : ") - fmt.Println(userInfo) + // Upsert user in database user, err := h.store.UpsertUser( c.Request.Context(), @@ -224,7 +219,7 @@ func (h *AuthHandler) GithubCallback(c *gin.Context) { } // Create session and JWT - jwt, err := h.createSessionAndJWT(c, user) + jwt, err := h.createSessionAndJWT(c, user, 7*24*time.Hour) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create session"}) return @@ -273,12 +268,47 @@ func (h *AuthHandler) Logout(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "Logged out successfully"}) } +// GuestLogin creates a temporary guest user and returns a JWT +func (h *AuthHandler) GuestLogin(c *gin.Context) { + // Generate random 4-byte hex string for guest ID + b := make([]byte, 4) + if _, err := rand.Read(b); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate guest ID"}) + return + } + guestHex := fmt.Sprintf("%x", b) + guestName := fmt.Sprintf("Guest-%s", guestHex) + guestEmail := fmt.Sprintf("guest-%s@guest.local", guestHex) + providerUserID := uuid.New().String() + + user, err := h.store.UpsertUser( + c.Request.Context(), + "guest", + providerUserID, + guestEmail, + guestName, + nil, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create guest user"}) + return + } + + jwt, err := h.createSessionAndJWT(c, user, 24*time.Hour) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create session"}) + return + } + + c.JSON(http.StatusOK, gin.H{"token": jwt}) +} + // Helper: create session and JWT -func (h *AuthHandler) createSessionAndJWT(c *gin.Context, user *models.User) (string, error) { - expiresAt := time.Now().Add(7 * 24 * time.Hour) // 7 days +func (h *AuthHandler) createSessionAndJWT(c *gin.Context, user *models.User, expiry time.Duration) (string, error) { + expiresAt := time.Now().Add(expiry) // Generate JWT first (we need it for session) - now includes avatar URL - jwt, err := auth.GenerateJWT(user.ID, user.Name, user.Email, user.AvatarURL, h.cfg.JWTSecret, 7*24*time.Hour) + jwt, err := auth.GenerateJWT(user.ID, user.Name, user.Email, user.AvatarURL, h.cfg.JWTSecret, expiry) if err != nil { return "", err } @@ -306,7 +336,7 @@ func (h *AuthHandler) generateStateOauthCookie(w http.ResponseWriter) string { b := make([]byte, 16) n, err := rand.Read(b) if err != nil || n != 16 { - fmt.Printf("Failed to generate random state: %v\n", err) + // Failed to generate random state return "" // Critical for CSRF security } state := base64.URLEncoding.EncodeToString(b) diff --git a/backend/internal/handlers/websocket.go b/backend/internal/handlers/websocket.go index 391d85b..1e484e0 100644 --- a/backend/internal/handlers/websocket.go +++ b/backend/internal/handlers/websocket.go @@ -108,7 +108,7 @@ func (wsh *WebSocketHandler) HandleWebSocket(c *gin.Context) { // Validate share token valid, err := wsh.store.ValidateShareToken(c.Request.Context(), documentID, shareToken) if err != nil { - log.Printf("Error validating share token: %v", err) + // Error validating share token c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to validate share token"}) return } @@ -134,7 +134,7 @@ func (wsh *WebSocketHandler) HandleWebSocket(c *gin.Context) { // Authenticated user - get their permission level perm, err := wsh.store.GetUserPermission(c.Request.Context(), documentID, *userID) if err != nil { - log.Printf("Error getting user permission: %v", err) + // Error getting user permission c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check permissions"}) return } @@ -147,7 +147,7 @@ func (wsh *WebSocketHandler) HandleWebSocket(c *gin.Context) { // Share token user - get share link permission perm, err := wsh.store.GetShareLinkPermission(c.Request.Context(), documentID) if err != nil { - log.Printf("Error getting share link permission: %v", err) + // Error getting share link permission c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check permissions"}) return } @@ -163,7 +163,7 @@ func (wsh *WebSocketHandler) HandleWebSocket(c *gin.Context) { upgrader := wsh.getUpgrader() conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { - log.Printf("Failed to upgrade connection: %v", err) + // Failed to upgrade WebSocket connection return } @@ -179,7 +179,7 @@ func (wsh *WebSocketHandler) HandleWebSocket(c *gin.Context) { go client.ReadPump() go wsh.replayBacklog(client, documentID) - log.Printf("Client connected: %s (user: %s) to room: %s", clientID, userName, roomID) + // Client connected } const maxReplayUpdates = 5000 diff --git a/backend/internal/workers/update_persist_worker.go b/backend/internal/workers/update_persist_worker.go index d20b7d6..5e62ba6 100644 --- a/backend/internal/workers/update_persist_worker.go +++ b/backend/internal/workers/update_persist_worker.go @@ -99,7 +99,9 @@ func runUpdatePersistWorker(ctx context.Context, msgBus messagebus.MessageBus, d return } case <-heartbeatTicker.C: - logWorker(logger, "Update persist worker heartbeat", zap.String("server_id", serverID)) + if logger != nil { + logger.Debug("Update persist worker heartbeat", zap.String("server_id", serverID)) + } case <-ticker.C: if err := processUpdatePersistence(ctx, msgBus, dbStore, logger, serverID); err != nil { logWorker(logger, "Update persist worker tick failed", zap.Error(err)) diff --git a/backend/scripts/012_add_guest_provider.sql b/backend/scripts/012_add_guest_provider.sql new file mode 100644 index 0000000..62b86f3 --- /dev/null +++ b/backend/scripts/012_add_guest_provider.sql @@ -0,0 +1,3 @@ +-- Add 'guest' as a valid provider for guest mode login +ALTER TABLE users DROP CONSTRAINT IF EXISTS users_provider_check; +ALTER TABLE users ADD CONSTRAINT users_provider_check CHECK (provider IN ('google', 'github', 'guest')); diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index afbc406..e989a8e 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -1,6 +1,15 @@ import type { User } from '../types/auth'; import { API_BASE_URL, authFetch } from './client'; +export async function guestLogin(): Promise { + const res = await fetch(`${API_BASE_URL}/auth/guest`, { method: 'POST' }); + if (!res.ok) { + throw new Error('Failed to create guest session'); + } + const data = await res.json(); + return data.token; +} + export const authApi = { getCurrentUser: async (): Promise => { const response = await authFetch(`${API_BASE_URL}/auth/me`); diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx index f047b1b..d5d8537 100644 --- a/frontend/src/components/ProtectedRoute.tsx +++ b/frontend/src/components/ProtectedRoute.tsx @@ -24,7 +24,7 @@ function ProtectedRoute({ children }: ProtectedRouteProps) { } if (!user) { - return ; + return ; } return <>{children}; diff --git a/frontend/src/components/Share/ShareModal.tsx b/frontend/src/components/Share/ShareModal.tsx index ebc0c2a..24863d2 100644 --- a/frontend/src/components/Share/ShareModal.tsx +++ b/frontend/src/components/Share/ShareModal.tsx @@ -31,7 +31,7 @@ function ShareModal({ const [permission, setPermission] = useState<'view' | 'edit'>('view'); // Form state for link sharing - const [linkPermission, setLinkPermission] = useState<'view' | 'edit'>('view'); + const [linkPermission, setLinkPermission] = useState<'view' | 'edit'>('edit'); const [copied, setCopied] = useState(false); // Load shares on mount diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index f709642..7c75969 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useState, useEffect } from 'react'; +import { createContext, useContext, useState, useEffect, useCallback } from 'react'; import type { ReactNode } from 'react'; import type { User, AuthContextType } from '../types/auth'; import { authApi } from '../api/auth'; @@ -40,7 +40,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { initAuth(); }, []); - const login = async (newToken: string) => { + const login = useCallback(async (newToken: string) => { try { setLoading(true); setError(null); @@ -60,7 +60,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { } finally { setLoading(false); } - }; + }, []); const logout = () => { localStorage.removeItem('auth_token'); diff --git a/frontend/src/pages/AuthCallback.tsx b/frontend/src/pages/AuthCallback.tsx index 903862b..a303002 100644 --- a/frontend/src/pages/AuthCallback.tsx +++ b/frontend/src/pages/AuthCallback.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useAuth } from '../contexts/AuthContext'; @@ -7,8 +7,12 @@ function AuthCallback() { const navigate = useNavigate(); const { login } = useAuth(); const [error, setError] = useState(null); + const processedRef = useRef(false); useEffect(() => { + if (processedRef.current) return; + processedRef.current = true; + const handleCallback = async () => { const token = searchParams.get('token'); const redirect = diff --git a/frontend/src/pages/LandingPage.css b/frontend/src/pages/LandingPage.css index 5d49099..07c0d59 100644 --- a/frontend/src/pages/LandingPage.css +++ b/frontend/src/pages/LandingPage.css @@ -141,6 +141,19 @@ background: hsl(var(--surface)); } +.landing-login-button.guest { + background: transparent; + border: 1px dashed hsl(var(--border)); + color: hsl(var(--text-secondary)); + font-size: 0.9rem; + padding: 0.6rem 1.5rem; +} + +.landing-login-button.guest:hover { + color: hsl(var(--text-primary)); + border-style: solid; +} + .landing-login-button.large { padding: 1rem 2rem; font-size: 1.05rem; diff --git a/frontend/src/pages/LandingPage.tsx b/frontend/src/pages/LandingPage.tsx index 3f7a8ff..f762bdd 100644 --- a/frontend/src/pages/LandingPage.tsx +++ b/frontend/src/pages/LandingPage.tsx @@ -1,3 +1,7 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../contexts/AuthContext'; +import { guestLogin } from '../api/auth'; import FloatingGem from '../components/PixelSprites/FloatingGem'; import PixelIcon from '../components/PixelIcon/PixelIcon'; import DocNestLogo from '../assets/docnest/docnest-icon-128.png'; @@ -6,6 +10,10 @@ import { API_BASE_URL } from '../config'; import './LandingPage.css'; function LandingPage() { + const { login } = useAuth(); + const navigate = useNavigate(); + const [guestLoading, setGuestLoading] = useState(false); + const handleGoogleLogin = () => { window.location.href = `${API_BASE_URL}/auth/google`; }; @@ -14,6 +22,19 @@ function LandingPage() { window.location.href = `${API_BASE_URL}/auth/github`; }; + const handleGuestLogin = async () => { + try { + setGuestLoading(true); + const token = await guestLogin(); + await login(token); + navigate('/'); + } catch (err) { + console.error('Guest login failed:', err); + } finally { + setGuestLoading(false); + } + }; + return (
@@ -61,6 +82,13 @@ function LandingPage() { Continue with GitHub
+

No credit card required.

diff --git a/frontend/src/pages/LoginPage.css b/frontend/src/pages/LoginPage.css index c239326..75dc9c7 100644 --- a/frontend/src/pages/LoginPage.css +++ b/frontend/src/pages/LoginPage.css @@ -105,3 +105,36 @@ transform: translateY(0); box-shadow: var(--shadow-sm); } + +.login-divider { + display: flex; + align-items: center; + gap: 12px; + margin: 4px 0; +} + +.login-divider::before, +.login-divider::after { + content: ''; + flex: 1; + height: 1px; + background: hsl(var(--border)); +} + +.login-divider span { + font-size: 13px; + color: hsl(var(--text-secondary)); + white-space: nowrap; +} + +.guest-button { + background: transparent; + border: 1px dashed hsl(var(--border)); + color: hsl(var(--text-secondary)); +} + +.guest-button:hover { + background: hsl(var(--surface-hover, var(--border) / 0.1)); + color: hsl(var(--text-primary)); + border-style: solid; +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 5bebc9e..60910bd 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -1,15 +1,17 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useAuth } from '../contexts/AuthContext'; +import { guestLogin } from '../api/auth'; import { API_BASE_URL } from '../config'; import DocNestLogo from '../assets/docnest/docnest-icon-128.png'; import ThemeToggle from '../components/ThemeToggle'; import './LoginPage.css'; function LoginPage() { - const { user, loading } = useAuth(); + const { user, loading, login } = useAuth(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); + const [guestLoading, setGuestLoading] = useState(false); useEffect(() => { if (!loading && user) { @@ -20,7 +22,7 @@ function LoginPage() { const saveRedirectAndGo = (oauthUrl: string) => { const redirect = searchParams.get('redirect'); if (redirect) { - sessionStorage.setItem('oauth_redirect', redirect); + sessionStorage.setItem('oauth_redirect', decodeURIComponent(redirect)); } window.location.href = oauthUrl; }; @@ -33,6 +35,20 @@ function LoginPage() { saveRedirectAndGo(`${API_BASE_URL}/auth/github`); }; + const handleGuestLogin = async () => { + try { + setGuestLoading(true); + const token = await guestLogin(); + await login(token); + const redirect = searchParams.get('redirect'); + navigate(redirect ? decodeURIComponent(redirect) : '/'); + } catch (err) { + console.error('Guest login failed:', err); + } finally { + setGuestLoading(false); + } + }; + if (loading) { return (
@@ -93,6 +109,18 @@ function LoginPage() { Continue with GitHub + +
+ +
+ +