From 6ba18854bf9da41c96fe5d9403c12f41b035854b Mon Sep 17 00:00:00 2001 From: M1ngdaXie <156019134+M1ngdaXie@users.noreply.github.com> Date: Thu, 8 Jan 2026 16:35:38 -0800 Subject: [PATCH] feat: Improve OAuth handling and user data extraction in authentication flow --- README.md | 307 +++++++++++++-------------- backend/internal/handlers/auth.go | 38 +++- frontend/src/api/auth.ts | 7 +- frontend/src/hooks/useYjsDocument.ts | 28 ++- frontend/src/pages/Home.tsx | 3 +- 5 files changed, 205 insertions(+), 178 deletions(-) diff --git a/README.md b/README.md index 88a6921..706c561 100644 --- a/README.md +++ b/README.md @@ -1,241 +1,240 @@ # Real-Time Collaboration Platform -A full-stack real-time collaborative application supporting both text editing and Kanban boards, built with React and Go. The system uses Yjs CRDTs for conflict-free synchronization and WebSockets for real-time updates. +A full-stack real-time collaborative application supporting text editing and Kanban boards. Built with React, Go, Yjs CRDTs, and WebSockets. ## Features -- **Collaborative Text Editor**: Real-time document editing with TipTap editor +- **Collaborative Text Editor**: Real-time document editing with TipTap - **Collaborative Kanban Boards**: Drag-and-drop task management -- **Real-Time Synchronization**: Instant updates across all connected clients +- **User Authentication**: OAuth2 login (Google, GitHub) +- **Document Sharing**: Share documents with users or via public links - **Offline Support**: IndexedDB persistence for offline editing -- **User Presence**: See who's currently editing with live cursors and awareness +- **User Presence**: Live cursors and user awareness ## Tech Stack -### Frontend -- React 19 + TypeScript -- Vite (build tool) -- TipTap (collaborative rich text editor) -- Yjs (CRDT for conflict-free replication) -- y-websocket (WebSocket sync provider) -- y-indexeddb (offline persistence) +**Frontend:** React 19, TypeScript, Vite, TipTap, Yjs, y-websocket, y-indexeddb +**Backend:** Go 1.25, Gin, Gorilla WebSocket, PostgreSQL 16 +**Infrastructure:** Docker Compose -### Backend -- Go 1.25 -- Gin (web framework) -- Gorilla WebSocket -- PostgreSQL 16 (document storage) -- Redis 7 (future use) - -### Infrastructure -- Docker Compose -- PostgreSQL -- Redis - -## Getting Started +## Quick Start ### Prerequisites - -- Node.js 18+ and npm +- Node.js 18+, npm - Go 1.25+ -- Docker and Docker Compose +- Docker & Docker Compose -### Installation +### Setup -1. **Clone the repository** +1. **Clone and configure environment** ```bash -git clone +git clone cd realtime-collab -``` -2. **Set up environment variables** - -Create backend environment file: -```bash -cp backend/.env.example backend/.env -# Edit backend/.env with your configuration -``` - -Create frontend environment file: -```bash -cp frontend/.env.example frontend/.env -# Edit frontend/.env with your configuration (optional for local development) -``` - -Create root environment file for Docker: -```bash +# Setup environment files cp .env.example .env -# Edit .env with your Docker database credentials +cp backend/.env.example backend/.env + +# Edit .env files with your configuration +# Minimum required: DATABASE_URL, JWT_SECRET ``` -3. **Start the infrastructure** +2. **Start infrastructure** ```bash docker-compose up -d ``` -This will start PostgreSQL and Redis containers. - -4. **Install and run the backend** +3. **Run backend** ```bash cd backend go mod download go run cmd/server/main.go +# Server runs on http://localhost:8080 ``` -The backend server will start on `http://localhost:8080` - -5. **Install and run the frontend** +4. **Run frontend** ```bash cd frontend npm install npm run dev -``` - -The frontend will start on `http://localhost:5173` - -6. **Access the application** - -Open your browser and navigate to `http://localhost:5173` - -## Environment Variables - -### Backend (backend/.env) - -```env -PORT=8080 -DATABASE_URL=postgres://user:password@localhost:5432/collaboration?sslmode=disable -REDIS_URL=redis://localhost:6379 -ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000 -``` - -### Frontend (frontend/.env) - -```env -VITE_API_URL=http://localhost:8080/api -VITE_WS_URL=ws://localhost:8080/ws -``` - -### Docker Compose (.env) - -```env -POSTGRES_USER=collab -POSTGRES_PASSWORD=your_secure_password_here -POSTGRES_DB=collaboration +# App runs on http://localhost:5173 ``` ## Architecture +### System Overview +``` +┌─────────────┐ ┌──────────────┐ ┌─────────────┐ +│ Browser │◄────────►│ Go Backend │◄────────►│ PostgreSQL │ +│ │ │ │ │ │ +│ React + Yjs │ WS+HTTP │ Gin + Hub │ SQL │ Documents │ +│ TipTap │ │ WebSocket │ │ Users │ +│ IndexedDB │ │ REST API │ │ Sessions │ +└─────────────┘ └──────────────┘ └─────────────┘ +``` + ### Real-Time Collaboration Flow +``` +Client A Backend Hub Client B + │ │ │ + │──1. Edit document──────────►│ │ + │ (Yjs binary update) │ │ + │ │──2. Broadcast update────►│ + │ │ │ + │ │◄─3. Edit document────────│ + │◄─4. Broadcast update───────│ │ + │ │ │ + ▼ ▼ ▼ +[Apply CRDT merge] [Relay only] [Apply CRDT merge] +``` -1. Client connects to WebSocket endpoint `/ws/:documentId` -2. WebSocket handler creates a client and registers it with the Hub -3. Hub manages rooms (one room per document) -4. Yjs generates binary updates on document changes -5. Client sends updates to WebSocket -6. Hub broadcasts updates to all clients in the same room -7. Yjs applies updates and merges changes automatically (CRDT) -8. IndexedDB persists state locally for offline support - -### API Endpoints - -#### REST API -- `GET /api/documents` - List all documents -- `POST /api/documents` - Create a new document -- `GET /api/documents/:id` - Get document metadata -- `GET /api/documents/:id/state` - Get document Yjs state -- `PUT /api/documents/:id/state` - Update document Yjs state -- `DELETE /api/documents/:id` - Delete a document - -#### WebSocket -- `GET /ws/:roomId` - WebSocket connection for real-time sync - -#### Health Check -- `GET /health` - Server health status +### Authentication Flow +``` +User OAuth Provider Backend Database + │ │ │ │ + │──1. Click login───────►│ │ │ + │◄─2. Auth page──────────│ │ │ + │──3. Approve───────────►│ │ │ + │ │──4. Callback──────►│ │ + │ │ │──5. Create user───►│ + │ │ │◄──────────────────│ + │◄─6. JWT token + cookie─┤◄──────────────────│ │ +``` ## Project Structure ``` realtime-collab/ ├── backend/ -│ ├── cmd/server/ # Application entry point +│ ├── cmd/server/ # Entry point │ ├── internal/ +│ │ ├── auth/ # JWT & OAuth middleware │ │ ├── handlers/ # HTTP/WebSocket handlers -│ │ ├── hub/ # WebSocket hub (room management) +│ │ ├── hub/ # WebSocket hub & rooms │ │ ├── models/ # Domain models -│ │ └── store/ # Database layer -│ └── scripts/ # Database initialization scripts +│ │ └── store/ # PostgreSQL data layer +│ └── scripts/init.sql # Database schema ├── frontend/ │ ├── src/ │ │ ├── api/ # REST API client │ │ ├── components/ # React components -│ │ ├── lib/ # Yjs integration -│ │ ├── pages/ # Page components -│ │ └── hooks/ # Custom React hooks -│ └── public/ # Static assets -└── docker-compose.yml # Infrastructure setup +│ │ ├── hooks/ # Custom hooks +│ │ ├── lib/yjs.ts # Yjs setup +│ │ └── pages/ # Page components +└── docker-compose.yml # PostgreSQL & Redis ``` -## Development +## Key Endpoints -### Backend Commands +### REST API +- `POST /api/auth/google` - Google OAuth login +- `POST /api/auth/github` - GitHub OAuth login +- `GET /api/auth/me` - Get current user +- `POST /api/auth/logout` - Logout +- `GET /api/documents` - List all documents +- `POST /api/documents` - Create document +- `GET /api/documents/:id` - Get document +- `PUT /api/documents/:id/state` - Update Yjs state +- `DELETE /api/documents/:id` - Delete document +- `POST /api/documents/:id/shares` - Share with user +- `POST /api/documents/:id/share-link` - Create public link + +### WebSocket +- `GET /ws/:roomId` - Real-time sync endpoint + +## Environment Variables + +### Backend (.env) +```bash +PORT=8080 +DATABASE_URL=postgres://user:pass@localhost:5432/collaboration?sslmode=disable +JWT_SECRET=your-secret-key-here +FRONTEND_URL=http://localhost:5173 +ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000 + +# OAuth (optional) +GOOGLE_CLIENT_ID=your-google-client-id +GOOGLE_CLIENT_SECRET=your-google-client-secret +GITHUB_CLIENT_ID=your-github-client-id +GITHUB_CLIENT_SECRET=your-github-client-secret +``` + +### Docker (.env in root) +```bash +POSTGRES_USER=collab +POSTGRES_PASSWORD=your-secure-password +POSTGRES_DB=collaboration +``` + +## Development Commands + +### Backend ```bash cd backend -go run cmd/server/main.go # Run development server +go run cmd/server/main.go # Run server +go test ./... # Run tests +go test -v ./internal/handlers # Run handler tests go build -o server cmd/server/main.go # Build binary -go fmt ./... # Format code ``` -### Frontend Commands +### Frontend ```bash cd frontend -npm run dev # Start dev server +npm run dev # Dev server npm run build # Production build -npm run preview # Preview production build -npm run lint # Run ESLint +npm run lint # Lint code ``` ### Database - -The database schema is automatically initialized on first run using `backend/scripts/init.sql`. - -To reset the database: ```bash -docker-compose down -v -docker-compose up -d +docker-compose down -v # Reset database +docker-compose up -d # Start fresh ``` ## How It Works -### Conflict-Free Replication (CRDT) +### CRDT-Based Collaboration +- **Yjs** provides Conflict-free Replicated Data Types (CRDTs) +- Multiple users edit simultaneously without conflicts +- Changes merge automatically, no manual conflict resolution +- Works offline, syncs when reconnected -The application uses Yjs, a CRDT implementation, which allows: -- Multiple users to edit simultaneously without conflicts -- Automatic merging of concurrent changes -- Offline editing with eventual consistency -- No need for operational transformation or locking +### Backend as Message Broker +- Backend doesn't understand Yjs data structure +- Simply broadcasts binary updates to all clients in a room +- All conflict resolution happens client-side via Yjs +- Rooms are isolated by document ID -### WebSocket Broadcasting +### Data Flow +1. User edits → Yjs generates binary update +2. Update sent via WebSocket to backend +3. Backend broadcasts to all clients in room +4. Each client's Yjs applies and merges update +5. IndexedDB persists state locally +6. Periodic backup to PostgreSQL (optional) -The backend acts as a message broker: -1. Receives binary Yjs updates from clients -2. Broadcasts updates to all clients in the same room -3. Does not interpret or modify the updates -4. Yjs handles all conflict resolution on the client side +## Testing -## Contributing +The backend uses `testify/suite` for organized testing: +```bash +go test ./internal/handlers # All handler tests +go test -v ./internal/handlers -run TestDocumentHandler # Specific suite +``` -1. Fork the repository -2. Create a feature branch (`git checkout -b feature/amazing-feature`) -3. Commit your changes (`git commit -m 'Add amazing feature'`) -4. Push to the branch (`git push origin feature/amazing-feature`) -5. Open a Pull Request +## Database Schema + +- **documents**: Document metadata and Yjs state (BYTEA) +- **users**: OAuth user profiles +- **sessions**: JWT tokens with expiration +- **shares**: User-to-user document sharing +- **share_links**: Public shareable links ## License -This project is licensed under the MIT License. +MIT License ## Acknowledgments - [Yjs](https://github.com/yjs/yjs) - CRDT framework -- [TipTap](https://tiptap.dev/) - Rich text editor +- [TipTap](https://tiptap.dev/) - Collaborative editor - [Gin](https://gin-gonic.com/) - Go web framework diff --git a/backend/internal/handlers/auth.go b/backend/internal/handlers/auth.go index 7e4078c..5b0abe7 100644 --- a/backend/internal/handlers/auth.go +++ b/backend/internal/handlers/auth.go @@ -1,7 +1,6 @@ package handlers import ( - "context" "crypto/rand" "encoding/base64" "encoding/json" @@ -67,14 +66,14 @@ func (h *AuthHandler) GoogleCallback(c *gin.Context) { } log.Println("Google callback state:", c.Query("state")) // Exchange code for token - token, err := h.googleConfig.Exchange(context.Background(), c.Query("code")) + token, err := h.googleConfig.Exchange(c.Request.Context(), c.Query("code")) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to exchange token"}) return } // Get user info from Google - client := h.googleConfig.Client(context.Background(), token) + client := h.googleConfig.Client(c.Request.Context(), token) resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user info"}) @@ -91,7 +90,12 @@ func (h *AuthHandler) GoogleCallback(c *gin.Context) { Name string `json:"name"` Picture string `json:"picture"` } - json.Unmarshal(data, &userInfo) + + if err := json.Unmarshal(data, &userInfo); err != nil { + log.Printf("Failed to parse Google response: %v | Data: %s", err, string(data)) + 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( @@ -124,12 +128,19 @@ func (h *AuthHandler) GoogleCallback(c *gin.Context) { // GithubLogin redirects to GitHub OAuth func (h *AuthHandler) GithubLogin(c *gin.Context) { - url := h.githubConfig.AuthCodeURL("state", oauth2.AccessTypeOffline) - c.Redirect(http.StatusTemporaryRedirect, url) + oauthState := generateStateOauthCookie(c.Writer) + url := h.githubConfig.AuthCodeURL(oauthState) + c.Redirect(http.StatusTemporaryRedirect, url) } // GithubCallback handles GitHub OAuth callback func (h *AuthHandler) GithubCallback(c *gin.Context) { + oauthState, err := c.Cookie("oauthstate") + if err != nil || c.Query("state") != oauthState { + 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"}) @@ -137,14 +148,14 @@ func (h *AuthHandler) GithubCallback(c *gin.Context) { } // Exchange code for token - token, err := h.githubConfig.Exchange(context.Background(), code) + token, err := h.githubConfig.Exchange(c.Request.Context(), code) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to exchange token"}) return } // Get user info from GitHub - client := h.githubConfig.Client(context.Background(), token) + client := h.githubConfig.Client(c.Request.Context(), token) // Get user profile resp, err := client.Get("https://api.github.com/user") @@ -162,7 +173,11 @@ func (h *AuthHandler) GithubCallback(c *gin.Context) { Email string `json:"email"` AvatarURL string `json:"avatar_url"` } - json.Unmarshal(data, &userInfo) + if err := json.Unmarshal(data, &userInfo); err != nil { + log.Printf("Failed to parse GitHub response: %v | Data: %s", err, string(data)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid GitHub response"}) + return +} // If email is not public, fetch it separately if userInfo.Email == "" { @@ -188,7 +203,8 @@ 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(), @@ -199,7 +215,7 @@ func (h *AuthHandler) GithubCallback(c *gin.Context) { &userInfo.AvatarURL, ) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create user: %v", err)}) return } diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index c0ee100..afbc406 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -4,12 +4,15 @@ import { API_BASE_URL, authFetch } from './client'; export const authApi = { getCurrentUser: async (): Promise => { const response = await authFetch(`${API_BASE_URL}/auth/me`); - console.log("current user is " + response) if (!response.ok) { throw new Error('Failed to get current user'); } - return response.json(); + const data = await response.json(); + console.log("current user data:", data); + + // Backend returns { user: {...} }, extract the user object + return data.user; }, logout: async (): Promise => { diff --git a/frontend/src/hooks/useYjsDocument.ts b/frontend/src/hooks/useYjsDocument.ts index 6de5d96..4dc5ec4 100644 --- a/frontend/src/hooks/useYjsDocument.ts +++ b/frontend/src/hooks/useYjsDocument.ts @@ -27,17 +27,25 @@ export const useYjsDocument = (documentId: string, shareToken?: string) => { const initializeDocument = async () => { // For share token access, use placeholder user info - const authUser = user || { + // Extract user data (handle both direct user object and nested structure for backwards compat) + const realUser = user || { id: "anonymous", name: "Anonymous User", + email: "", avatar_url: undefined, }; - const realUser = user?.user ? user.user : user || {}; - const currentName = realUser.name || realUser.email || "Anonymous"; + const currentId = realUser.id; - const currentAvatar = realUser.avatar_url || realUser.avatar; - console.log("✅ [Fixed] User Name is:", currentName); - console.log("🔍 [Debug] Initializing Awareness with User:", authUser); // <--- 添加这行 + const currentName = realUser.name || realUser.email || "Anonymous"; + const currentAvatar = realUser.avatar_url; + + console.log("🔍 [Debug] User data for awareness:", { + id: currentId, + name: currentName, + email: realUser.email, + avatar: currentAvatar, + rawUser: realUser, + }); const authToken = token || ""; console.log("authToken is " + token); @@ -55,8 +63,8 @@ export const useYjsDocument = (documentId: string, shareToken?: string) => { } console.log( - "🔍 [Debug] Full authUser object:", - JSON.stringify(authUser, null, 2) + "🔍 [Debug] Full user object:", + JSON.stringify(realUser, null, 2) ); // Set user info for awareness with authenticated user data yjsProviders.awareness.setLocalStateField("user", { @@ -124,8 +132,8 @@ export const useYjsDocument = (documentId: string, shareToken?: string) => { ); // Log local user info - console.log(`[Awareness] Local user initialized: ${authUser.name}`, { - color: getColorFromUserId(authUser.id), + console.log(`[Awareness] Local user initialized: ${currentName}`, { + color: getColorFromUserId(currentId), clientId: yjsProviders.awareness.clientID, }); diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index bb0626a..bb545e9 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -15,9 +15,10 @@ const Home = () => { const loadDocuments = async () => { try { const { documents } = await documentsApi.list(); - setDocuments(documents); + setDocuments(documents || []); } catch (error) { console.error("Failed to load documents:", error); + setDocuments([]); // Set empty array on error to prevent null access } finally { setLoading(false); }