feat: Enhance real-time collaboration features with user awareness and document sharing
- Added user information (UserID, UserName, UserAvatar) to Client struct for presence tracking. - Implemented failure handling in the broadcastMessage function to manage send failures and disconnect clients if necessary. - Introduced document ownership and sharing capabilities: - Added OwnerID and Is_Public fields to Document model. - Created DocumentShare model for managing document sharing with permissions. - Implemented functions for creating, listing, and managing document shares in the Postgres store. - Added user management functionality: - Created User model and associated functions for user management in the Postgres store. - Implemented session management with token hashing for security. - Updated database schema with migrations for users, sessions, and document shares. - Enhanced frontend Yjs integration with awareness event logging for user connections and disconnections.
This commit is contained in:
302
backend/internal/handlers/auth.go
Normal file
302
backend/internal/handlers/auth.go
Normal file
@@ -0,0 +1,302 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/M1ngdaXie/realtime-collab/internal/auth"
|
||||
"github.com/M1ngdaXie/realtime-collab/internal/models"
|
||||
"github.com/M1ngdaXie/realtime-collab/internal/store"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
store store.Store
|
||||
googleConfig *oauth2.Config
|
||||
githubConfig *oauth2.Config
|
||||
jwtSecret string
|
||||
frontendURL string
|
||||
}
|
||||
|
||||
func NewAuthHandler(store store.Store, jwtSecret, frontendURL string) *AuthHandler {
|
||||
googleConfig := auth.GetGoogleOAuthConfig(
|
||||
os.Getenv("GOOGLE_CLIENT_ID"),
|
||||
os.Getenv("GOOGLE_CLIENT_SECRET"),
|
||||
os.Getenv("GOOGLE_REDIRECT_URL"),
|
||||
)
|
||||
|
||||
githubConfig := auth.GetGitHubOAuthConfig(
|
||||
os.Getenv("GITHUB_CLIENT_ID"),
|
||||
os.Getenv("GITHUB_CLIENT_SECRET"),
|
||||
os.Getenv("GITHUB_REDIRECT_URL"),
|
||||
)
|
||||
|
||||
return &AuthHandler{
|
||||
store: store,
|
||||
googleConfig: googleConfig,
|
||||
githubConfig: githubConfig,
|
||||
jwtSecret: jwtSecret,
|
||||
frontendURL: frontendURL,
|
||||
}
|
||||
}
|
||||
|
||||
// GoogleLogin redirects to Google OAuth
|
||||
func (h *AuthHandler) GoogleLogin(c *gin.Context) {
|
||||
// Generate random state and set cookie
|
||||
oauthState := generateStateOauthCookie(c.Writer)
|
||||
url := h.googleConfig.AuthCodeURL(oauthState, oauth2.AccessTypeOffline)
|
||||
c.Redirect(http.StatusTemporaryRedirect, url)
|
||||
}
|
||||
|
||||
// GoogleCallback handles Google OAuth callback
|
||||
func (h *AuthHandler) GoogleCallback(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("Google callback state:", c.Query("state"))
|
||||
// Exchange code for token
|
||||
token, err := h.googleConfig.Exchange(context.Background(), 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)
|
||||
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"})
|
||||
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)
|
||||
var userInfo struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Picture string `json:"picture"`
|
||||
}
|
||||
json.Unmarshal(data, &userInfo)
|
||||
log.Println("Google user info:", userInfo)
|
||||
// Upsert user in database
|
||||
user, err := h.store.UpsertUser(
|
||||
c.Request.Context(),
|
||||
"google",
|
||||
userInfo.ID,
|
||||
userInfo.Email,
|
||||
userInfo.Name,
|
||||
&userInfo.Picture,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create session and JWT
|
||||
jwt, err := h.createSessionAndJWT(c, user)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ DATABASE ERROR: %v\n", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("CreateSession Error: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect to frontend with token
|
||||
redirectURL := fmt.Sprintf("%s/auth/callback?token=%s", h.frontendURL, jwt)
|
||||
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
|
||||
}
|
||||
|
||||
// GithubLogin redirects to GitHub OAuth
|
||||
func (h *AuthHandler) GithubLogin(c *gin.Context) {
|
||||
url := h.githubConfig.AuthCodeURL("state", oauth2.AccessTypeOffline)
|
||||
c.Redirect(http.StatusTemporaryRedirect, url)
|
||||
}
|
||||
|
||||
// GithubCallback handles GitHub OAuth callback
|
||||
func (h *AuthHandler) GithubCallback(c *gin.Context) {
|
||||
code := c.Query("code")
|
||||
if code == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No code provided"})
|
||||
return
|
||||
}
|
||||
|
||||
// Exchange code for token
|
||||
token, err := h.githubConfig.Exchange(context.Background(), 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)
|
||||
|
||||
// Get user profile
|
||||
resp, err := client.Get("https://api.github.com/user")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user info"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
var userInfo struct {
|
||||
ID int `json:"id"`
|
||||
Login string `json:"login"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
}
|
||||
json.Unmarshal(data, &userInfo)
|
||||
|
||||
// If email is not public, fetch it separately
|
||||
if userInfo.Email == "" {
|
||||
emailResp, _ := client.Get("https://api.github.com/user/emails")
|
||||
if emailResp != nil {
|
||||
defer emailResp.Body.Close()
|
||||
emailData, _ := io.ReadAll(emailResp.Body)
|
||||
var emails []struct {
|
||||
Email string `json:"email"`
|
||||
Primary bool `json:"primary"`
|
||||
}
|
||||
json.Unmarshal(emailData, &emails)
|
||||
for _, e := range emails {
|
||||
if e.Primary {
|
||||
userInfo.Email = e.Email
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use login as name if name is empty
|
||||
if userInfo.Name == "" {
|
||||
userInfo.Name = userInfo.Login
|
||||
}
|
||||
|
||||
// Upsert user in database
|
||||
user, err := h.store.UpsertUser(
|
||||
c.Request.Context(),
|
||||
"github",
|
||||
fmt.Sprintf("%d", userInfo.ID),
|
||||
userInfo.Email,
|
||||
userInfo.Name,
|
||||
&userInfo.AvatarURL,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create session and JWT
|
||||
jwt, err := h.createSessionAndJWT(c, user)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create session"})
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect to frontend with token
|
||||
redirectURL := fmt.Sprintf("%s/auth/callback?token=%s", h.frontendURL, jwt)
|
||||
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
|
||||
}
|
||||
|
||||
// Me returns current user info
|
||||
func (h *AuthHandler) Me(c *gin.Context) {
|
||||
userID := auth.GetUserFromContext(c)
|
||||
if userID == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.store.GetUserByID(c.Request.Context(), *userID)
|
||||
if err != nil || user == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.UserResponse{User: user})
|
||||
}
|
||||
|
||||
// Logout invalidates the session
|
||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Already logged out"})
|
||||
return
|
||||
}
|
||||
|
||||
// Extract token
|
||||
token := ""
|
||||
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
|
||||
token = authHeader[7:]
|
||||
}
|
||||
|
||||
if token != "" {
|
||||
h.store.DeleteSession(c.Request.Context(), token)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Logged out successfully"})
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
// 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.jwtSecret, 7*24*time.Hour)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Create session in database
|
||||
sessionID := uuid.New()
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
ipAddress := c.ClientIP()
|
||||
_, err = h.store.CreateSession(
|
||||
c.Request.Context(),
|
||||
user.ID,
|
||||
sessionID,
|
||||
jwt,
|
||||
expiresAt,
|
||||
&userAgent,
|
||||
&ipAddress,
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return jwt, nil
|
||||
}
|
||||
func generateStateOauthCookie(w http.ResponseWriter) string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
state := base64.URLEncoding.EncodeToString(b)
|
||||
|
||||
cookie := http.Cookie{
|
||||
Name: "oauthstate",
|
||||
Value: state,
|
||||
Expires: time.Now().Add(10 * time.Minute),
|
||||
HttpOnly: true, // Prevents JavaScript access (XSS protection)
|
||||
Secure: false, // Must be false for http://localhost (set true in production)
|
||||
SameSite: http.SameSiteLaxMode, // ✅ Allows same-site OAuth redirects
|
||||
Path: "/", // ✅ Ensures cookie is sent to all backend paths
|
||||
}
|
||||
http.SetCookie(w, &cookie)
|
||||
|
||||
return state
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/M1ngdaXie/realtime-collab/internal/auth"
|
||||
"github.com/M1ngdaXie/realtime-collab/internal/models"
|
||||
"github.com/M1ngdaXie/realtime-collab/internal/store"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -10,135 +12,199 @@ import (
|
||||
)
|
||||
|
||||
type DocumentHandler struct {
|
||||
store *store.Store
|
||||
store *store.PostgresStore
|
||||
}
|
||||
|
||||
func NewDocumentHandler(s *store.Store) *DocumentHandler {
|
||||
func NewDocumentHandler(s *store.PostgresStore) *DocumentHandler {
|
||||
return &DocumentHandler{store: s}
|
||||
}
|
||||
|
||||
// CreateDocument creates a new document
|
||||
func (h *DocumentHandler) CreateDocument(c *gin.Context) {
|
||||
var req models.CreateDocumentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate document type
|
||||
if req.Type != models.DocumentTypeEditor && req.Type != models.DocumentTypeKanban {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid document type"})
|
||||
return
|
||||
}
|
||||
// CreateDocument creates a new document (requires auth)
|
||||
func (h *DocumentHandler) CreateDocument(c *gin.Context) {
|
||||
fmt.Println("getting userId right now.... ")
|
||||
userID := auth.GetUserFromContext(c)
|
||||
fmt.Println(userID)
|
||||
if userID == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
doc, err := h.store.CreateDocument(req.Name, req.Type)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var req models.CreateDocumentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, doc)
|
||||
}
|
||||
// Create document with owner_id
|
||||
doc, err := h.store.CreateDocumentWithOwner(req.Name, req.Type, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create document: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, doc)
|
||||
}
|
||||
|
||||
// ListDocuments returns all documents
|
||||
func (h *DocumentHandler) ListDocuments(c *gin.Context) {
|
||||
documents, err := h.store.ListDocuments()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
userID := auth.GetUserFromContext(c)
|
||||
|
||||
if documents == nil {
|
||||
documents = []models.Document{}
|
||||
}
|
||||
var docs []models.Document
|
||||
var err error
|
||||
|
||||
if userID != nil {
|
||||
// Authenticated: show owned + shared documents
|
||||
docs, err = h.store.ListUserDocuments(c.Request.Context(), *userID)
|
||||
} else {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": fmt.Sprintf("we dont know you: %v", err)})
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list documents"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.DocumentListResponse{
|
||||
Documents: docs,
|
||||
Total: len(docs),
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.DocumentListResponse{
|
||||
Documents: documents,
|
||||
Total: len(documents),
|
||||
})
|
||||
}
|
||||
|
||||
// GetDocument returns a single document
|
||||
func (h *DocumentHandler) GetDocument(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid document ID"})
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
|
||||
return
|
||||
}
|
||||
|
||||
doc, err := h.store.GetDocument(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "document not found"})
|
||||
return
|
||||
}
|
||||
userID := auth.GetUserFromContext(c)
|
||||
|
||||
c.JSON(http.StatusOK, doc)
|
||||
}
|
||||
// Check permission if authenticated
|
||||
if userID != nil {
|
||||
canView, err := h.store.CanViewDocument(c.Request.Context(), id, *userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check permissions"})
|
||||
return
|
||||
}
|
||||
if !canView {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
}else{
|
||||
c.JSON("this file is not public")
|
||||
return
|
||||
}
|
||||
|
||||
doc, err := h.store.GetDocument(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, doc)
|
||||
}
|
||||
// GetDocumentState returns the Yjs state for a document
|
||||
func (h *DocumentHandler) GetDocumentState(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid document ID"})
|
||||
return
|
||||
}
|
||||
// GetDocumentState retrieves document state (requires view permission)
|
||||
func (h *DocumentHandler) GetDocumentState(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
|
||||
return
|
||||
}
|
||||
|
||||
doc, err := h.store.GetDocument(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "document not found"})
|
||||
return
|
||||
}
|
||||
userID := auth.GetUserFromContext(c)
|
||||
|
||||
// Return binary state
|
||||
if doc.YjsState == nil {
|
||||
c.Data(http.StatusOK, "application/octet-stream", []byte{})
|
||||
return
|
||||
}
|
||||
// Check permission if authenticated
|
||||
if userID != nil {
|
||||
canView, err := h.store.CanViewDocument(c.Request.Context(), id, *userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check permissions"})
|
||||
return
|
||||
}
|
||||
if !canView {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.Data(http.StatusOK, "application/octet-stream", doc.YjsState)
|
||||
}
|
||||
doc, err := h.store.GetDocument(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// UpdateDocumentState updates the Yjs state for a document
|
||||
func (h *DocumentHandler) UpdateDocumentState(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid document ID"})
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/octet-stream", doc.YjsState)
|
||||
}
|
||||
|
||||
// Read binary body
|
||||
state, err := c.GetRawData()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read request body"})
|
||||
return
|
||||
}
|
||||
// UpdateDocumentState updates document state (requires edit permission)
|
||||
func (h *DocumentHandler) UpdateDocumentState(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
|
||||
return
|
||||
}
|
||||
|
||||
err = h.store.UpdateDocumentState(id, state)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
userID := auth.GetUserFromContext(c)
|
||||
if userID == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "state updated successfully"})
|
||||
}
|
||||
// Check edit permission
|
||||
canEdit, err := h.store.CanEditDocument(c.Request.Context(), id, *userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check permissions"})
|
||||
return
|
||||
}
|
||||
if !canEdit {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Edit access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
// DeleteDocument deletes a document
|
||||
func (h *DocumentHandler) DeleteDocument(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid document ID"})
|
||||
return
|
||||
}
|
||||
var req models.UpdateStateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
err = h.store.DeleteDocument(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "document not found"})
|
||||
return
|
||||
}
|
||||
if err := h.store.UpdateDocumentState(id, req.State); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update state"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "document deleted successfully"})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "State updated successfully"})
|
||||
}
|
||||
|
||||
// DeleteDocument deletes a document (owner only)
|
||||
func (h *DocumentHandler) DeleteDocument(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
|
||||
return
|
||||
}
|
||||
|
||||
userID := auth.GetUserFromContext(c)
|
||||
if userID == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check ownership
|
||||
isOwner, err := h.store.IsDocumentOwner(c.Request.Context(), id, *userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check ownership"})
|
||||
return
|
||||
}
|
||||
if !isOwner {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Only owner can delete documents"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.DeleteDocument(id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete document"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Document deleted successfully"})
|
||||
}
|
||||
286
backend/internal/handlers/share.go
Normal file
286
backend/internal/handlers/share.go
Normal file
@@ -0,0 +1,286 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os" // Add this
|
||||
|
||||
"github.com/M1ngdaXie/realtime-collab/internal/auth"
|
||||
"github.com/M1ngdaXie/realtime-collab/internal/models"
|
||||
"github.com/M1ngdaXie/realtime-collab/internal/store"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ShareHandler struct {
|
||||
store store.Store
|
||||
}
|
||||
|
||||
func NewShareHandler(store store.Store) *ShareHandler {
|
||||
return &ShareHandler{store: store}
|
||||
}
|
||||
|
||||
// CreateShare creates a new document share
|
||||
func (h *ShareHandler) CreateShare(c *gin.Context) {
|
||||
userID := auth.GetUserFromContext(c)
|
||||
if userID == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
documentID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is owner
|
||||
isOwner, err := h.store.IsDocumentOwner(c.Request.Context(), documentID, *userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check ownership"})
|
||||
return
|
||||
}
|
||||
if !isOwner {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Only owner can share documents"})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.CreateShareRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get user by email
|
||||
targetUser, err := h.store.GetUserByEmail(c.Request.Context(), req.UserEmail)
|
||||
if err != nil || targetUser == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create share
|
||||
share, err := h.store.CreateDocumentShare(
|
||||
c.Request.Context(),
|
||||
documentID,
|
||||
targetUser.ID,
|
||||
req.Permission,
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create share"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, share)
|
||||
}
|
||||
|
||||
// ListShares lists all shares for a document
|
||||
func (h *ShareHandler) ListShares(c *gin.Context) {
|
||||
userID := auth.GetUserFromContext(c)
|
||||
if userID == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
documentID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is owner
|
||||
isOwner, err := h.store.IsDocumentOwner(c.Request.Context(), documentID, *userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check ownership"})
|
||||
return
|
||||
}
|
||||
if !isOwner {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Only owner can view shares"})
|
||||
return
|
||||
}
|
||||
|
||||
shares, err := h.store.ListDocumentShares(c.Request.Context(), documentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list shares"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.ShareListResponse{Shares: shares})
|
||||
}
|
||||
|
||||
// DeleteShare removes a share
|
||||
func (h *ShareHandler) DeleteShare(c *gin.Context) {
|
||||
userID := auth.GetUserFromContext(c)
|
||||
if userID == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
documentID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
|
||||
return
|
||||
}
|
||||
|
||||
targetUserID, err := uuid.Parse(c.Param("userId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is owner
|
||||
isOwner, err := h.store.IsDocumentOwner(c.Request.Context(), documentID, *userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check ownership"})
|
||||
return
|
||||
}
|
||||
if !isOwner {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Only owner can delete shares"})
|
||||
return
|
||||
}
|
||||
|
||||
err = h.store.DeleteDocumentShare(c.Request.Context(), documentID, targetUserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete share"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Share deleted successfully"})
|
||||
}
|
||||
// CreateShareLink generates a public share link
|
||||
func (h *ShareHandler) CreateShareLink(c *gin.Context) {
|
||||
documentID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
|
||||
return
|
||||
}
|
||||
|
||||
userID := auth.GetUserFromContext(c)
|
||||
if userID == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is owner
|
||||
isOwner, err := h.store.IsDocumentOwner(c.Request.Context(), documentID, *userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check ownership"})
|
||||
return
|
||||
}
|
||||
if !isOwner {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Only document owner can create share links"})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
var req struct {
|
||||
Permission string `json:"permission" binding:"required,oneof=view edit"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Permission must be 'view' or 'edit'"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate share token
|
||||
token, err := h.store.GenerateShareToken(c.Request.Context(), documentID, req.Permission)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate share link"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get frontend URL from env
|
||||
frontendURL := os.Getenv("FRONTEND_URL")
|
||||
if frontendURL == "" {
|
||||
frontendURL = "http://localhost:5173"
|
||||
}
|
||||
|
||||
shareURL := fmt.Sprintf("%s/editor/%s?share=%s", frontendURL, documentID.String(), token)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"url": shareURL,
|
||||
"token": token,
|
||||
"permission": req.Permission,
|
||||
})
|
||||
}
|
||||
|
||||
// GetShareLink retrieves the current public share link
|
||||
func (h *ShareHandler) GetShareLink(c *gin.Context) {
|
||||
documentID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
|
||||
return
|
||||
}
|
||||
|
||||
userID := auth.GetUserFromContext(c)
|
||||
if userID == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is owner
|
||||
isOwner, err := h.store.IsDocumentOwner(c.Request.Context(), documentID, *userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check ownership"})
|
||||
return
|
||||
}
|
||||
if !isOwner {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Only document owner can view share links"})
|
||||
return
|
||||
}
|
||||
|
||||
token, exists, err := h.store.GetShareToken(c.Request.Context(), documentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get share link"})
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "No public share link exists"})
|
||||
return
|
||||
}
|
||||
|
||||
frontendURL := os.Getenv("FRONTEND_URL")
|
||||
if frontendURL == "" {
|
||||
frontendURL = "http://localhost:5173"
|
||||
}
|
||||
|
||||
shareURL := fmt.Sprintf("%s/editor/%s?share=%s", frontendURL, documentID.String(), token)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"url": shareURL,
|
||||
"token": token,
|
||||
})
|
||||
}
|
||||
|
||||
// RevokeShareLink removes the public share link
|
||||
func (h *ShareHandler) RevokeShareLink(c *gin.Context) {
|
||||
documentID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
|
||||
return
|
||||
}
|
||||
|
||||
userID := auth.GetUserFromContext(c)
|
||||
if userID == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is owner
|
||||
isOwner, err := h.store.IsDocumentOwner(c.Request.Context(), documentID, *userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check ownership"})
|
||||
return
|
||||
}
|
||||
if !isOwner {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Only document owner can revoke share links"})
|
||||
return
|
||||
}
|
||||
|
||||
err = h.store.RevokeShareToken(c.Request.Context(), documentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to revoke share link"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Share link revoked"})
|
||||
}
|
||||
@@ -3,57 +3,147 @@ package handlers
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/M1ngdaXie/realtime-collab/internal/auth"
|
||||
"github.com/M1ngdaXie/realtime-collab/internal/hub"
|
||||
"github.com/M1ngdaXie/realtime-collab/internal/store"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
// Allow all origins for development
|
||||
// TODO: Restrict in production
|
||||
return true
|
||||
},
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
// Check origin against allowed origins from environment
|
||||
allowedOrigins := os.Getenv("ALLOWED_ORIGINS")
|
||||
if allowedOrigins == "" {
|
||||
// Default for development
|
||||
origin := r.Header.Get("Origin")
|
||||
return origin == "http://localhost:5173" || origin == "http://localhost:3000"
|
||||
}
|
||||
// Production: validate against ALLOWED_ORIGINS
|
||||
// TODO: Parse and validate origin
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
type WebSocketHandler struct {
|
||||
hub *hub.Hub
|
||||
}
|
||||
type WebSocketHandler struct {
|
||||
hub *hub.Hub
|
||||
store store.Store
|
||||
}
|
||||
|
||||
func NewWebSocketHandler(h *hub.Hub) *WebSocketHandler {
|
||||
return &WebSocketHandler{hub: h}
|
||||
}
|
||||
func NewWebSocketHandler(h *hub.Hub, s store.Store) *WebSocketHandler {
|
||||
return &WebSocketHandler{
|
||||
hub: h,
|
||||
store: s,
|
||||
}
|
||||
}
|
||||
|
||||
func (wsh *WebSocketHandler) HandleWebSocket(c *gin.Context){
|
||||
func (wsh *WebSocketHandler) HandleWebSocket(c *gin.Context) {
|
||||
roomID := c.Param("roomId")
|
||||
|
||||
if(roomID == ""){
|
||||
if roomID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "roomId is required"})
|
||||
return
|
||||
}
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
|
||||
// Parse document ID
|
||||
documentID, err := uuid.Parse(roomID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to upgrade to WebSocket"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create a new client
|
||||
clientID := uuid.New().String()
|
||||
client := hub.NewClient(clientID, conn, wsh.hub, roomID)
|
||||
|
||||
// Register client with hub
|
||||
wsh.hub.Register <- client
|
||||
// Try to authenticate via JWT token or share token
|
||||
var userID *uuid.UUID
|
||||
var userName string
|
||||
var userAvatar *string
|
||||
authenticated := false
|
||||
|
||||
// Start read and write pumps in separate goroutines
|
||||
go client.WritePump()
|
||||
go client.ReadPump()
|
||||
// Check for JWT token in query parameter
|
||||
jwtToken := c.Query("token")
|
||||
if jwtToken != "" {
|
||||
// Validate JWT and get user data from token claims (no DB query!)
|
||||
jwtSecret := os.Getenv("JWT_SECRET")
|
||||
if jwtSecret == "" {
|
||||
log.Println("JWT_SECRET not configured")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Server configuration error"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("WebSocket connection established for client %s in room %s", clientID, roomID)
|
||||
}
|
||||
authMiddleware := auth.NewAuthMiddleware(wsh.store, jwtSecret)
|
||||
uid, name, avatar, err := authMiddleware.ValidateToken(jwtToken)
|
||||
if err == nil && uid != nil {
|
||||
// User data comes directly from JWT claims - no DB query needed!
|
||||
userID = uid
|
||||
userName = name
|
||||
if avatar != "" {
|
||||
userAvatar = &avatar
|
||||
}
|
||||
authenticated = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// If not authenticated via JWT, check for share token
|
||||
if !authenticated {
|
||||
shareToken := c.Query("share")
|
||||
if shareToken != "" {
|
||||
// Validate share token
|
||||
valid, err := wsh.store.ValidateShareToken(c.Request.Context(), documentID, shareToken)
|
||||
if err != nil {
|
||||
log.Printf("Error validating share token: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to validate share token"})
|
||||
return
|
||||
}
|
||||
if !valid {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Invalid or expired share token"})
|
||||
return
|
||||
}
|
||||
// Share token is valid, allow connection with anonymous user
|
||||
userName = "Anonymous"
|
||||
authenticated = true
|
||||
}
|
||||
}
|
||||
|
||||
// If still not authenticated, reject connection
|
||||
if !authenticated {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required. Provide 'token' or 'share' query parameter"})
|
||||
return
|
||||
}
|
||||
|
||||
// If authenticated with JWT, check document permissions
|
||||
if userID != nil {
|
||||
canView, err := wsh.store.CanViewDocument(c.Request.Context(), documentID, *userID)
|
||||
if err != nil {
|
||||
log.Printf("Error checking permissions: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check permissions"})
|
||||
return
|
||||
}
|
||||
if !canView {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "You don't have permission to access this document"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Upgrade connection
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
log.Printf("Failed to upgrade connection: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Create client with user information
|
||||
clientID := uuid.New().String()
|
||||
client := hub.NewClient(clientID, userID, userName, userAvatar, conn, wsh.hub, roomID)
|
||||
|
||||
// Register client
|
||||
wsh.hub.Register <- client
|
||||
|
||||
// Start goroutines
|
||||
go client.WritePump()
|
||||
go client.ReadPump()
|
||||
|
||||
log.Printf("Client connected: %s (user: %s) to room: %s", clientID, userName, roomID)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user