feat: add guest mode, bug fixes, and self-hosted config

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
M1ngdaXie
2026-03-15 09:45:17 +00:00
parent 763575f284
commit 9c19769eb0
15 changed files with 187 additions and 36 deletions

2
backend/.gitignore vendored
View File

@@ -4,7 +4,7 @@
.env.*.local .env.*.local
# Compiled binaries # Compiled binaries
server /server
*.exe *.exe
*.exe~ *.exe~
*.dll *.dll

View File

@@ -204,6 +204,7 @@ func main() {
authGroup.GET("/google/callback", authHandler.GoogleCallback) authGroup.GET("/google/callback", authHandler.GoogleCallback)
authGroup.GET("/github", authHandler.GithubLogin) authGroup.GET("/github", authHandler.GithubLogin)
authGroup.GET("/github/callback", authHandler.GithubCallback) authGroup.GET("/github/callback", authHandler.GithubCallback)
authGroup.POST("/guest", authHandler.GuestLogin)
authGroup.GET("/me", authMiddleware.RequireAuth(), authHandler.Me) authGroup.GET("/me", authMiddleware.RequireAuth(), authHandler.Me)
authGroup.POST("/logout", authMiddleware.RequireAuth(), authHandler.Logout) authGroup.POST("/logout", authMiddleware.RequireAuth(), authHandler.Logout)
} }

View File

@@ -6,7 +6,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log"
"net/http" "net/http"
"time" "time"
@@ -68,7 +68,7 @@ func (h *AuthHandler) GoogleCallback(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid oauth state"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid oauth state"})
return return
} }
log.Println("Google callback state:", c.Query("state"))
// Exchange code for token // Exchange code for token
token, err := h.googleConfig.Exchange(c.Request.Context(), c.Query("code")) token, err := h.googleConfig.Exchange(c.Request.Context(), c.Query("code"))
if err != nil { 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"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user info"})
return return
} }
log.Println("Google user info response status:", resp.Status)
log.Println("Google user info response headers:", resp.Header)
defer resp.Body.Close() defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body) data, _ := io.ReadAll(resp.Body)
@@ -96,11 +95,11 @@ func (h *AuthHandler) GoogleCallback(c *gin.Context) {
} }
if err := json.Unmarshal(data, &userInfo); err != nil { 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"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid Google response"})
return return
} }
log.Println("Google user info:", userInfo)
// Upsert user in database // Upsert user in database
user, err := h.store.UpsertUser( user, err := h.store.UpsertUser(
c.Request.Context(), c.Request.Context(),
@@ -116,12 +115,9 @@ func (h *AuthHandler) GoogleCallback(c *gin.Context) {
} }
// Create session and JWT // Create session and JWT
jwt, err := h.createSessionAndJWT(c, user) jwt, err := h.createSessionAndJWT(c, user, 7*24*time.Hour)
if err != nil { if err != nil {
fmt.Printf("❌ DATABASE ERROR: %v\n", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create session"})
c.JSON(http.StatusInternalServerError, gin.H{
"error": fmt.Sprintf("CreateSession Error: %v", err),
})
return return
} }
@@ -144,7 +140,7 @@ func (h *AuthHandler) GithubCallback(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid oauth state"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid oauth state"})
return return
} }
log.Println("Github callback state:", c.Query("state"))
code := c.Query("code") code := c.Query("code")
if code == "" { if code == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "No code provided"}) 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"` AvatarURL string `json:"avatar_url"`
} }
if err := json.Unmarshal(data, &userInfo); err != nil { 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"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid GitHub response"})
return return
} }
@@ -207,8 +203,7 @@ func (h *AuthHandler) GithubCallback(c *gin.Context) {
if userInfo.Name == "" { if userInfo.Name == "" {
userInfo.Name = userInfo.Login userInfo.Name = userInfo.Login
} }
fmt.Println("Getting user info : ")
fmt.Println(userInfo)
// Upsert user in database // Upsert user in database
user, err := h.store.UpsertUser( user, err := h.store.UpsertUser(
c.Request.Context(), c.Request.Context(),
@@ -224,7 +219,7 @@ func (h *AuthHandler) GithubCallback(c *gin.Context) {
} }
// Create session and JWT // Create session and JWT
jwt, err := h.createSessionAndJWT(c, user) jwt, err := h.createSessionAndJWT(c, user, 7*24*time.Hour)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create session"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create session"})
return return
@@ -273,12 +268,47 @@ func (h *AuthHandler) Logout(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Logged out successfully"}) 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 // Helper: create session and JWT
func (h *AuthHandler) createSessionAndJWT(c *gin.Context, user *models.User) (string, error) { func (h *AuthHandler) createSessionAndJWT(c *gin.Context, user *models.User, expiry time.Duration) (string, error) {
expiresAt := time.Now().Add(7 * 24 * time.Hour) // 7 days expiresAt := time.Now().Add(expiry)
// Generate JWT first (we need it for session) - now includes avatar URL // 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 { if err != nil {
return "", err return "", err
} }
@@ -306,7 +336,7 @@ func (h *AuthHandler) generateStateOauthCookie(w http.ResponseWriter) string {
b := make([]byte, 16) b := make([]byte, 16)
n, err := rand.Read(b) n, err := rand.Read(b)
if err != nil || n != 16 { 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 return "" // Critical for CSRF security
} }
state := base64.URLEncoding.EncodeToString(b) state := base64.URLEncoding.EncodeToString(b)

View File

@@ -108,7 +108,7 @@ func (wsh *WebSocketHandler) HandleWebSocket(c *gin.Context) {
// Validate share token // Validate share token
valid, err := wsh.store.ValidateShareToken(c.Request.Context(), documentID, shareToken) valid, err := wsh.store.ValidateShareToken(c.Request.Context(), documentID, shareToken)
if err != nil { 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"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to validate share token"})
return return
} }
@@ -134,7 +134,7 @@ func (wsh *WebSocketHandler) HandleWebSocket(c *gin.Context) {
// Authenticated user - get their permission level // Authenticated user - get their permission level
perm, err := wsh.store.GetUserPermission(c.Request.Context(), documentID, *userID) perm, err := wsh.store.GetUserPermission(c.Request.Context(), documentID, *userID)
if err != nil { 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"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check permissions"})
return return
} }
@@ -147,7 +147,7 @@ func (wsh *WebSocketHandler) HandleWebSocket(c *gin.Context) {
// Share token user - get share link permission // Share token user - get share link permission
perm, err := wsh.store.GetShareLinkPermission(c.Request.Context(), documentID) perm, err := wsh.store.GetShareLinkPermission(c.Request.Context(), documentID)
if err != nil { 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"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check permissions"})
return return
} }
@@ -163,7 +163,7 @@ func (wsh *WebSocketHandler) HandleWebSocket(c *gin.Context) {
upgrader := wsh.getUpgrader() upgrader := wsh.getUpgrader()
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil { if err != nil {
log.Printf("Failed to upgrade connection: %v", err) // Failed to upgrade WebSocket connection
return return
} }
@@ -179,7 +179,7 @@ func (wsh *WebSocketHandler) HandleWebSocket(c *gin.Context) {
go client.ReadPump() go client.ReadPump()
go wsh.replayBacklog(client, documentID) go wsh.replayBacklog(client, documentID)
log.Printf("Client connected: %s (user: %s) to room: %s", clientID, userName, roomID) // Client connected
} }
const maxReplayUpdates = 5000 const maxReplayUpdates = 5000

View File

@@ -99,7 +99,9 @@ func runUpdatePersistWorker(ctx context.Context, msgBus messagebus.MessageBus, d
return return
} }
case <-heartbeatTicker.C: 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: case <-ticker.C:
if err := processUpdatePersistence(ctx, msgBus, dbStore, logger, serverID); err != nil { if err := processUpdatePersistence(ctx, msgBus, dbStore, logger, serverID); err != nil {
logWorker(logger, "Update persist worker tick failed", zap.Error(err)) logWorker(logger, "Update persist worker tick failed", zap.Error(err))

View File

@@ -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'));

View File

@@ -1,6 +1,15 @@
import type { User } from '../types/auth'; import type { User } from '../types/auth';
import { API_BASE_URL, authFetch } from './client'; import { API_BASE_URL, authFetch } from './client';
export async function guestLogin(): Promise<string> {
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 = { export const authApi = {
getCurrentUser: async (): Promise<User> => { getCurrentUser: async (): Promise<User> => {
const response = await authFetch(`${API_BASE_URL}/auth/me`); const response = await authFetch(`${API_BASE_URL}/auth/me`);

View File

@@ -24,7 +24,7 @@ function ProtectedRoute({ children }: ProtectedRouteProps) {
} }
if (!user) { if (!user) {
return <Navigate to={`/login?redirect=${location.pathname}`} replace />; return <Navigate to={`/login?redirect=${encodeURIComponent(location.pathname + location.search)}`} replace />;
} }
return <>{children}</>; return <>{children}</>;

View File

@@ -31,7 +31,7 @@ function ShareModal({
const [permission, setPermission] = useState<'view' | 'edit'>('view'); const [permission, setPermission] = useState<'view' | 'edit'>('view');
// Form state for link sharing // Form state for link sharing
const [linkPermission, setLinkPermission] = useState<'view' | 'edit'>('view'); const [linkPermission, setLinkPermission] = useState<'view' | 'edit'>('edit');
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
// Load shares on mount // Load shares on mount

View File

@@ -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 { ReactNode } from 'react';
import type { User, AuthContextType } from '../types/auth'; import type { User, AuthContextType } from '../types/auth';
import { authApi } from '../api/auth'; import { authApi } from '../api/auth';
@@ -40,7 +40,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
initAuth(); initAuth();
}, []); }, []);
const login = async (newToken: string) => { const login = useCallback(async (newToken: string) => {
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
@@ -60,7 +60,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, []);
const logout = () => { const logout = () => {
localStorage.removeItem('auth_token'); localStorage.removeItem('auth_token');

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
@@ -7,8 +7,12 @@ function AuthCallback() {
const navigate = useNavigate(); const navigate = useNavigate();
const { login } = useAuth(); const { login } = useAuth();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const processedRef = useRef(false);
useEffect(() => { useEffect(() => {
if (processedRef.current) return;
processedRef.current = true;
const handleCallback = async () => { const handleCallback = async () => {
const token = searchParams.get('token'); const token = searchParams.get('token');
const redirect = const redirect =

View File

@@ -141,6 +141,19 @@
background: hsl(var(--surface)); 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 { .landing-login-button.large {
padding: 1rem 2rem; padding: 1rem 2rem;
font-size: 1.05rem; font-size: 1.05rem;

View File

@@ -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 FloatingGem from '../components/PixelSprites/FloatingGem';
import PixelIcon from '../components/PixelIcon/PixelIcon'; import PixelIcon from '../components/PixelIcon/PixelIcon';
import DocNestLogo from '../assets/docnest/docnest-icon-128.png'; import DocNestLogo from '../assets/docnest/docnest-icon-128.png';
@@ -6,6 +10,10 @@ import { API_BASE_URL } from '../config';
import './LandingPage.css'; import './LandingPage.css';
function LandingPage() { function LandingPage() {
const { login } = useAuth();
const navigate = useNavigate();
const [guestLoading, setGuestLoading] = useState(false);
const handleGoogleLogin = () => { const handleGoogleLogin = () => {
window.location.href = `${API_BASE_URL}/auth/google`; window.location.href = `${API_BASE_URL}/auth/google`;
}; };
@@ -14,6 +22,19 @@ function LandingPage() {
window.location.href = `${API_BASE_URL}/auth/github`; 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 ( return (
<div className="landing-page"> <div className="landing-page">
<div className="landing-theme-toggle"> <div className="landing-theme-toggle">
@@ -61,6 +82,13 @@ function LandingPage() {
<span>Continue with GitHub</span> <span>Continue with GitHub</span>
</button> </button>
</div> </div>
<button
className="landing-login-button guest"
onClick={handleGuestLogin}
disabled={guestLoading}
>
{guestLoading ? 'Entering...' : 'Try as Guest'}
</button>
<p className="hero-note">No credit card required.</p> <p className="hero-note">No credit card required.</p>
</div> </div>
</div> </div>

View File

@@ -105,3 +105,36 @@
transform: translateY(0); transform: translateY(0);
box-shadow: var(--shadow-sm); 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;
}

View File

@@ -1,15 +1,17 @@
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { guestLogin } from '../api/auth';
import { API_BASE_URL } from '../config'; import { API_BASE_URL } from '../config';
import DocNestLogo from '../assets/docnest/docnest-icon-128.png'; import DocNestLogo from '../assets/docnest/docnest-icon-128.png';
import ThemeToggle from '../components/ThemeToggle'; import ThemeToggle from '../components/ThemeToggle';
import './LoginPage.css'; import './LoginPage.css';
function LoginPage() { function LoginPage() {
const { user, loading } = useAuth(); const { user, loading, login } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const [guestLoading, setGuestLoading] = useState(false);
useEffect(() => { useEffect(() => {
if (!loading && user) { if (!loading && user) {
@@ -20,7 +22,7 @@ function LoginPage() {
const saveRedirectAndGo = (oauthUrl: string) => { const saveRedirectAndGo = (oauthUrl: string) => {
const redirect = searchParams.get('redirect'); const redirect = searchParams.get('redirect');
if (redirect) { if (redirect) {
sessionStorage.setItem('oauth_redirect', redirect); sessionStorage.setItem('oauth_redirect', decodeURIComponent(redirect));
} }
window.location.href = oauthUrl; window.location.href = oauthUrl;
}; };
@@ -33,6 +35,20 @@ function LoginPage() {
saveRedirectAndGo(`${API_BASE_URL}/auth/github`); 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) { if (loading) {
return ( return (
<div className="login-page"> <div className="login-page">
@@ -93,6 +109,18 @@ function LoginPage() {
</svg> </svg>
Continue with GitHub Continue with GitHub
</button> </button>
<div className="login-divider">
<span></span>
</div>
<button
className="login-button guest-button"
onClick={handleGuestLogin}
disabled={guestLoading}
>
{guestLoading ? 'Entering...' : 'Continue as Guest'}
</button>
</div> </div>
</div> </div>
</div> </div>