Refactor API configuration and improve WebSocket handling in frontend and backend
This commit is contained in:
@@ -1,32 +1,64 @@
|
|||||||
# Backend Environment Variables
|
# Backend Environment Variables
|
||||||
# Copy this file to .env.local for local development
|
# Copy this file to .env.local for local development
|
||||||
|
|
||||||
# Server Configuration
|
# ===================
|
||||||
PORT=8080
|
# REQUIRED VARIABLES
|
||||||
|
# ===================
|
||||||
|
|
||||||
# Database Configuration
|
# Database Configuration
|
||||||
# Format: postgres://username:password@host:port/database?sslmode=disable
|
# Format: postgres://username:password@host:port/database?sslmode=disable
|
||||||
DATABASE_URL=postgres://collab:collab123@localhost:5432/collaboration?sslmode=disable
|
DATABASE_URL=postgres://collab:collab123@localhost:5432/collaboration?sslmode=disable
|
||||||
|
|
||||||
# Redis Configuration (optional, for future use)
|
# JWT Secret - Use a strong random string (min 32 characters)
|
||||||
REDIS_URL=redis://localhost:6379
|
# Generate with: openssl rand -hex 32
|
||||||
|
JWT_SECRET=your-secret-key-change-this-in-production
|
||||||
|
|
||||||
# CORS Configuration
|
# ===================
|
||||||
# Comma-separated list of allowed origins
|
# SERVER CONFIGURATION
|
||||||
ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000
|
# ===================
|
||||||
|
|
||||||
# JWT Secret (use a strong random string in production)
|
# Server port (default: 8080)
|
||||||
JWT_SECRET=your-secret-key-change-this
|
# Can be overridden with --port flag: go run cmd/server/main.go --port 8081
|
||||||
|
PORT=8080
|
||||||
|
|
||||||
|
# Environment: development, staging, production
|
||||||
|
# Controls: cookie security (Secure flag), logging verbosity
|
||||||
|
ENVIRONMENT=development
|
||||||
|
|
||||||
|
# Backend URL (used to auto-construct OAuth callback URLs)
|
||||||
|
# If not set, defaults to http://localhost:${PORT}
|
||||||
|
# Set explicitly for production: https://api.yourdomain.com
|
||||||
|
BACKEND_URL=http://localhost:8080
|
||||||
|
|
||||||
# Frontend URL (for OAuth redirects after login)
|
# Frontend URL (for OAuth redirects after login)
|
||||||
FRONTEND_URL=http://localhost:5173
|
FRONTEND_URL=http://localhost:5173
|
||||||
|
|
||||||
# Google OAuth (get from https://console.cloud.google.com/)
|
# ===================
|
||||||
|
# CORS CONFIGURATION
|
||||||
|
# ===================
|
||||||
|
|
||||||
|
# Comma-separated list of allowed origins
|
||||||
|
ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||||
|
|
||||||
|
# ===================
|
||||||
|
# OAUTH CONFIGURATION
|
||||||
|
# ===================
|
||||||
|
|
||||||
|
# Google OAuth (https://console.cloud.google.com/)
|
||||||
GOOGLE_CLIENT_ID=your-google-client-id
|
GOOGLE_CLIENT_ID=your-google-client-id
|
||||||
GOOGLE_CLIENT_SECRET=your-google-client-secret
|
GOOGLE_CLIENT_SECRET=your-google-client-secret
|
||||||
GOOGLE_REDIRECT_URL=http://localhost:8080/api/auth/google/callback
|
# Redirect URL - auto-derived from BACKEND_URL if not set
|
||||||
|
# GOOGLE_REDIRECT_URL=http://localhost:8080/api/auth/google/callback
|
||||||
|
|
||||||
# GitHub OAuth (get from https://github.com/settings/developers)
|
# GitHub OAuth (https://github.com/settings/developers)
|
||||||
GITHUB_CLIENT_ID=your-github-client-id
|
GITHUB_CLIENT_ID=your-github-client-id
|
||||||
GITHUB_CLIENT_SECRET=your-github-client-secret
|
GITHUB_CLIENT_SECRET=your-github-client-secret
|
||||||
GITHUB_REDIRECT_URL=http://localhost:8080/api/auth/github/callback
|
# Redirect URL - auto-derived from BACKEND_URL if not set
|
||||||
|
# GITHUB_REDIRECT_URL=http://localhost:8080/api/auth/github/callback
|
||||||
|
|
||||||
|
# ===================
|
||||||
|
# OPTIONAL SERVICES
|
||||||
|
# ===================
|
||||||
|
|
||||||
|
# Redis (for future use)
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|||||||
141
backend/internal/config/config.go
Normal file
141
backend/internal/config/config.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds all application configuration
|
||||||
|
type Config struct {
|
||||||
|
// Server
|
||||||
|
Port string
|
||||||
|
Environment string // "development", "staging", "production"
|
||||||
|
|
||||||
|
// Database
|
||||||
|
DatabaseURL string
|
||||||
|
|
||||||
|
// Redis (optional)
|
||||||
|
RedisURL string
|
||||||
|
|
||||||
|
// Security
|
||||||
|
JWTSecret string
|
||||||
|
SecureCookie bool // Derived from Environment
|
||||||
|
|
||||||
|
// CORS
|
||||||
|
AllowedOrigins []string
|
||||||
|
|
||||||
|
// URLs
|
||||||
|
FrontendURL string
|
||||||
|
BackendURL string // Used to construct OAuth callback URLs
|
||||||
|
|
||||||
|
// OAuth - Google
|
||||||
|
GoogleClientID string
|
||||||
|
GoogleClientSecret string
|
||||||
|
GoogleRedirectURL string
|
||||||
|
|
||||||
|
// OAuth - GitHub
|
||||||
|
GitHubClientID string
|
||||||
|
GitHubClientSecret string
|
||||||
|
GitHubRedirectURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load loads configuration from environment variables with optional CLI flag overrides.
|
||||||
|
// flagPort overrides the PORT env var if non-empty.
|
||||||
|
func Load(flagPort string) (*Config, error) {
|
||||||
|
// Load .env files (ignore errors - files may not exist)
|
||||||
|
_ = godotenv.Load()
|
||||||
|
_ = godotenv.Overload(".env.local")
|
||||||
|
|
||||||
|
cfg := &Config{}
|
||||||
|
|
||||||
|
// Required fields
|
||||||
|
var missing []string
|
||||||
|
|
||||||
|
cfg.DatabaseURL = os.Getenv("DATABASE_URL")
|
||||||
|
if cfg.DatabaseURL == "" {
|
||||||
|
missing = append(missing, "DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.JWTSecret = os.Getenv("JWT_SECRET")
|
||||||
|
if cfg.JWTSecret == "" {
|
||||||
|
missing = append(missing, "JWT_SECRET")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(missing) > 0 {
|
||||||
|
return nil, fmt.Errorf("missing required environment variables: %s", strings.Join(missing, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Port: CLI flag > env var > default
|
||||||
|
cfg.Port = getEnvOrDefault("PORT", "8080")
|
||||||
|
if flagPort != "" {
|
||||||
|
cfg.Port = flagPort
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other optional vars with defaults
|
||||||
|
cfg.Environment = getEnvOrDefault("ENVIRONMENT", "development")
|
||||||
|
cfg.FrontendURL = getEnvOrDefault("FRONTEND_URL", "http://localhost:5173")
|
||||||
|
cfg.BackendURL = getEnvOrDefault("BACKEND_URL", fmt.Sprintf("http://localhost:%s", cfg.Port))
|
||||||
|
cfg.RedisURL = os.Getenv("REDIS_URL")
|
||||||
|
|
||||||
|
// Derived values
|
||||||
|
cfg.SecureCookie = cfg.Environment == "production"
|
||||||
|
|
||||||
|
// CORS origins
|
||||||
|
originsStr := os.Getenv("ALLOWED_ORIGINS")
|
||||||
|
if originsStr != "" {
|
||||||
|
cfg.AllowedOrigins = strings.Split(originsStr, ",")
|
||||||
|
for i := range cfg.AllowedOrigins {
|
||||||
|
cfg.AllowedOrigins[i] = strings.TrimSpace(cfg.AllowedOrigins[i])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cfg.AllowedOrigins = []string{
|
||||||
|
"http://localhost:5173",
|
||||||
|
"http://localhost:3000",
|
||||||
|
"https://realtime-collab-snowy.vercel.app",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuth - Google
|
||||||
|
cfg.GoogleClientID = os.Getenv("GOOGLE_CLIENT_ID")
|
||||||
|
cfg.GoogleClientSecret = os.Getenv("GOOGLE_CLIENT_SECRET")
|
||||||
|
cfg.GoogleRedirectURL = getEnvOrDefault("GOOGLE_REDIRECT_URL",
|
||||||
|
fmt.Sprintf("%s/api/auth/google/callback", cfg.BackendURL))
|
||||||
|
|
||||||
|
// OAuth - GitHub
|
||||||
|
cfg.GitHubClientID = os.Getenv("GITHUB_CLIENT_ID")
|
||||||
|
cfg.GitHubClientSecret = os.Getenv("GITHUB_CLIENT_SECRET")
|
||||||
|
cfg.GitHubRedirectURL = getEnvOrDefault("GITHUB_REDIRECT_URL",
|
||||||
|
fmt.Sprintf("%s/api/auth/github/callback", cfg.BackendURL))
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnvOrDefault(key, defaultValue string) string {
|
||||||
|
if value := os.Getenv(key); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsProduction returns true if running in production environment
|
||||||
|
func (c *Config) IsProduction() bool {
|
||||||
|
return c.Environment == "production"
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDevelopment returns true if running in development environment
|
||||||
|
func (c *Config) IsDevelopment() bool {
|
||||||
|
return c.Environment == "development"
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasGoogleOAuth returns true if Google OAuth is configured
|
||||||
|
func (c *Config) HasGoogleOAuth() bool {
|
||||||
|
return c.GoogleClientID != "" && c.GoogleClientSecret != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasGitHubOAuth returns true if GitHub OAuth is configured
|
||||||
|
func (c *Config) HasGitHubOAuth() bool {
|
||||||
|
return c.GitHubClientID != "" && c.GitHubClientSecret != ""
|
||||||
|
}
|
||||||
@@ -8,10 +8,10 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/M1ngdaXie/realtime-collab/internal/auth"
|
"github.com/M1ngdaXie/realtime-collab/internal/auth"
|
||||||
|
"github.com/M1ngdaXie/realtime-collab/internal/config"
|
||||||
"github.com/M1ngdaXie/realtime-collab/internal/models"
|
"github.com/M1ngdaXie/realtime-collab/internal/models"
|
||||||
"github.com/M1ngdaXie/realtime-collab/internal/store"
|
"github.com/M1ngdaXie/realtime-collab/internal/store"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -20,41 +20,45 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type AuthHandler struct {
|
type AuthHandler struct {
|
||||||
store store.Store
|
store store.Store
|
||||||
googleConfig *oauth2.Config
|
cfg *config.Config
|
||||||
githubConfig *oauth2.Config
|
googleConfig *oauth2.Config
|
||||||
jwtSecret string
|
githubConfig *oauth2.Config
|
||||||
frontendURL string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuthHandler(store store.Store, jwtSecret, frontendURL string) *AuthHandler {
|
func NewAuthHandler(store store.Store, cfg *config.Config) *AuthHandler {
|
||||||
googleConfig := auth.GetGoogleOAuthConfig(
|
var googleConfig *oauth2.Config
|
||||||
os.Getenv("GOOGLE_CLIENT_ID"),
|
if cfg.HasGoogleOAuth() {
|
||||||
os.Getenv("GOOGLE_CLIENT_SECRET"),
|
googleConfig = auth.GetGoogleOAuthConfig(
|
||||||
os.Getenv("GOOGLE_REDIRECT_URL"),
|
cfg.GoogleClientID,
|
||||||
)
|
cfg.GoogleClientSecret,
|
||||||
|
cfg.GoogleRedirectURL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
githubConfig := auth.GetGitHubOAuthConfig(
|
var githubConfig *oauth2.Config
|
||||||
os.Getenv("GITHUB_CLIENT_ID"),
|
if cfg.HasGitHubOAuth() {
|
||||||
os.Getenv("GITHUB_CLIENT_SECRET"),
|
githubConfig = auth.GetGitHubOAuthConfig(
|
||||||
os.Getenv("GITHUB_REDIRECT_URL"),
|
cfg.GitHubClientID,
|
||||||
)
|
cfg.GitHubClientSecret,
|
||||||
|
cfg.GitHubRedirectURL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return &AuthHandler{
|
return &AuthHandler{
|
||||||
store: store,
|
store: store,
|
||||||
|
cfg: cfg,
|
||||||
googleConfig: googleConfig,
|
googleConfig: googleConfig,
|
||||||
githubConfig: githubConfig,
|
githubConfig: githubConfig,
|
||||||
jwtSecret: jwtSecret,
|
|
||||||
frontendURL: frontendURL,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GoogleLogin redirects to Google OAuth
|
// GoogleLogin redirects to Google OAuth
|
||||||
func (h *AuthHandler) GoogleLogin(c *gin.Context) {
|
func (h *AuthHandler) GoogleLogin(c *gin.Context) {
|
||||||
// Generate random state and set cookie
|
// Generate random state and set cookie
|
||||||
oauthState := generateStateOauthCookie(c.Writer)
|
oauthState := h.generateStateOauthCookie(c.Writer)
|
||||||
url := h.googleConfig.AuthCodeURL(oauthState, oauth2.AccessTypeOffline)
|
url := h.googleConfig.AuthCodeURL(oauthState, oauth2.AccessTypeOffline)
|
||||||
c.Redirect(http.StatusTemporaryRedirect, url)
|
c.Redirect(http.StatusTemporaryRedirect, url)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GoogleCallback handles Google OAuth callback
|
// GoogleCallback handles Google OAuth callback
|
||||||
@@ -122,15 +126,15 @@ func (h *AuthHandler) GoogleCallback(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Redirect to frontend with token
|
// Redirect to frontend with token
|
||||||
redirectURL := fmt.Sprintf("%s/auth/callback?token=%s", h.frontendURL, jwt)
|
redirectURL := fmt.Sprintf("%s/auth/callback?token=%s", h.cfg.FrontendURL, jwt)
|
||||||
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
|
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GithubLogin redirects to GitHub OAuth
|
// GithubLogin redirects to GitHub OAuth
|
||||||
func (h *AuthHandler) GithubLogin(c *gin.Context) {
|
func (h *AuthHandler) GithubLogin(c *gin.Context) {
|
||||||
oauthState := generateStateOauthCookie(c.Writer)
|
oauthState := h.generateStateOauthCookie(c.Writer)
|
||||||
url := h.githubConfig.AuthCodeURL(oauthState)
|
url := h.githubConfig.AuthCodeURL(oauthState)
|
||||||
c.Redirect(http.StatusTemporaryRedirect, url)
|
c.Redirect(http.StatusTemporaryRedirect, url)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GithubCallback handles GitHub OAuth callback
|
// GithubCallback handles GitHub OAuth callback
|
||||||
@@ -227,7 +231,7 @@ func (h *AuthHandler) GithubCallback(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Redirect to frontend with token
|
// Redirect to frontend with token
|
||||||
redirectURL := fmt.Sprintf("%s/auth/callback?token=%s", h.frontendURL, jwt)
|
redirectURL := fmt.Sprintf("%s/auth/callback?token=%s", h.cfg.FrontendURL, jwt)
|
||||||
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
|
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,7 +278,7 @@ func (h *AuthHandler) createSessionAndJWT(c *gin.Context, user *models.User) (st
|
|||||||
expiresAt := time.Now().Add(7 * 24 * time.Hour) // 7 days
|
expiresAt := time.Now().Add(7 * 24 * time.Hour) // 7 days
|
||||||
|
|
||||||
// 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.jwtSecret, 7*24*time.Hour)
|
jwt, err := auth.GenerateJWT(user.ID, user.Name, user.Email, user.AvatarURL, h.cfg.JWTSecret, 7*24*time.Hour)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -298,25 +302,25 @@ func (h *AuthHandler) createSessionAndJWT(c *gin.Context, user *models.User) (st
|
|||||||
|
|
||||||
return jwt, nil
|
return jwt, nil
|
||||||
}
|
}
|
||||||
func generateStateOauthCookie(w http.ResponseWriter) string {
|
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)
|
fmt.Printf("Failed to generate random state: %v\n", err)
|
||||||
return "" // Critical for CSRF security
|
return "" // Critical for CSRF security
|
||||||
}
|
}
|
||||||
state := base64.URLEncoding.EncodeToString(b)
|
state := base64.URLEncoding.EncodeToString(b)
|
||||||
|
|
||||||
cookie := http.Cookie{
|
cookie := http.Cookie{
|
||||||
Name: "oauthstate",
|
Name: "oauthstate",
|
||||||
Value: state,
|
Value: state,
|
||||||
Expires: time.Now().Add(10 * time.Minute),
|
Expires: time.Now().Add(10 * time.Minute),
|
||||||
HttpOnly: true, // Prevents JavaScript access (XSS protection)
|
HttpOnly: true, // Prevents JavaScript access (XSS protection)
|
||||||
Secure: false, // Must be false for http://localhost (set true in production)
|
Secure: h.cfg.SecureCookie, // true in production, false for localhost
|
||||||
SameSite: http.SameSiteLaxMode, // ✅ Allows same-site OAuth redirects
|
SameSite: http.SameSiteLaxMode, // Allows same-site OAuth redirects
|
||||||
Path: "/", // ✅ Ensures cookie is sent to all backend paths
|
Path: "/", // Ensures cookie is sent to all backend paths
|
||||||
}
|
}
|
||||||
http.SetCookie(w, &cookie)
|
http.SetCookie(w, &cookie)
|
||||||
|
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os" // Add this
|
|
||||||
|
|
||||||
"github.com/M1ngdaXie/realtime-collab/internal/auth"
|
"github.com/M1ngdaXie/realtime-collab/internal/auth"
|
||||||
|
"github.com/M1ngdaXie/realtime-collab/internal/config"
|
||||||
"github.com/M1ngdaXie/realtime-collab/internal/models"
|
"github.com/M1ngdaXie/realtime-collab/internal/models"
|
||||||
"github.com/M1ngdaXie/realtime-collab/internal/store"
|
"github.com/M1ngdaXie/realtime-collab/internal/store"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -14,10 +14,11 @@ import (
|
|||||||
|
|
||||||
type ShareHandler struct {
|
type ShareHandler struct {
|
||||||
store store.Store
|
store store.Store
|
||||||
|
cfg *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewShareHandler(store store.Store) *ShareHandler {
|
func NewShareHandler(store store.Store, cfg *config.Config) *ShareHandler {
|
||||||
return &ShareHandler{store: store}
|
return &ShareHandler{store: store, cfg: cfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateShare creates a new document share
|
// CreateShare creates a new document share
|
||||||
@@ -193,13 +194,7 @@ func (h *ShareHandler) CreateShareLink(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get frontend URL from env
|
shareURL := fmt.Sprintf("%s/editor/%s?share=%s", h.cfg.FrontendURL, documentID.String(), token)
|
||||||
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{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"url": shareURL,
|
"url": shareURL,
|
||||||
@@ -253,12 +248,7 @@ func (h *ShareHandler) GetShareLink(c *gin.Context) {
|
|||||||
permission = "edit" // Default fallback
|
permission = "edit" // Default fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
frontendURL := os.Getenv("FRONTEND_URL")
|
shareURL := fmt.Sprintf("%s/editor/%s?share=%s", h.cfg.FrontendURL, documentID.String(), token)
|
||||||
if frontendURL == "" {
|
|
||||||
frontendURL = "http://localhost:5173"
|
|
||||||
}
|
|
||||||
|
|
||||||
shareURL := fmt.Sprintf("%s/editor/%s?share=%s", frontendURL, documentID.String(), token)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"url": shareURL,
|
"url": shareURL,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func (s *ShareHandlerSuite) SetupTest() {
|
|||||||
|
|
||||||
// Create handler and router
|
// Create handler and router
|
||||||
authMiddleware := auth.NewAuthMiddleware(s.store, s.jwtSecret)
|
authMiddleware := auth.NewAuthMiddleware(s.store, s.jwtSecret)
|
||||||
s.handler = NewShareHandler(s.store)
|
s.handler = NewShareHandler(s.store, s.cfg)
|
||||||
s.router = gin.New()
|
s.router = gin.New()
|
||||||
|
|
||||||
// Custom auth middleware for tests that sets user_id as pointer
|
// Custom auth middleware for tests that sets user_id as pointer
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
|
||||||
|
"github.com/M1ngdaXie/realtime-collab/internal/config"
|
||||||
"github.com/M1ngdaXie/realtime-collab/internal/store"
|
"github.com/M1ngdaXie/realtime-collab/internal/store"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -17,18 +18,26 @@ import (
|
|||||||
// BaseHandlerSuite provides common setup for all handler tests
|
// BaseHandlerSuite provides common setup for all handler tests
|
||||||
type BaseHandlerSuite struct {
|
type BaseHandlerSuite struct {
|
||||||
suite.Suite
|
suite.Suite
|
||||||
store *store.PostgresStore
|
store *store.PostgresStore
|
||||||
cleanup func()
|
cleanup func()
|
||||||
testData *store.TestData
|
testData *store.TestData
|
||||||
jwtSecret string
|
jwtSecret string
|
||||||
frontendURL string
|
cfg *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetupSuite runs once before all tests in the suite
|
// SetupSuite runs once before all tests in the suite
|
||||||
func (s *BaseHandlerSuite) SetupSuite() {
|
func (s *BaseHandlerSuite) SetupSuite() {
|
||||||
s.store, s.cleanup = store.SetupTestDB(s.T())
|
s.store, s.cleanup = store.SetupTestDB(s.T())
|
||||||
s.jwtSecret = "test-secret-key-do-not-use-in-production"
|
s.jwtSecret = "test-secret-key-do-not-use-in-production"
|
||||||
s.frontendURL = "http://localhost:5173"
|
s.cfg = &config.Config{
|
||||||
|
Port: "8080",
|
||||||
|
Environment: "development",
|
||||||
|
JWTSecret: s.jwtSecret,
|
||||||
|
FrontendURL: "http://localhost:5173",
|
||||||
|
BackendURL: "http://localhost:8080",
|
||||||
|
AllowedOrigins: []string{"http://localhost:5173", "http://localhost:3000"},
|
||||||
|
SecureCookie: false,
|
||||||
|
}
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,9 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/M1ngdaXie/realtime-collab/internal/auth"
|
"github.com/M1ngdaXie/realtime-collab/internal/auth"
|
||||||
|
"github.com/M1ngdaXie/realtime-collab/internal/config"
|
||||||
"github.com/M1ngdaXie/realtime-collab/internal/hub"
|
"github.com/M1ngdaXie/realtime-collab/internal/hub"
|
||||||
"github.com/M1ngdaXie/realtime-collab/internal/store"
|
"github.com/M1ngdaXie/realtime-collab/internal/store"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -14,36 +13,33 @@ import (
|
|||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
)
|
)
|
||||||
|
|
||||||
var upgrader = websocket.Upgrader{
|
|
||||||
ReadBufferSize: 1024,
|
|
||||||
WriteBufferSize: 1024,
|
|
||||||
CheckOrigin: func(r *http.Request) bool {
|
|
||||||
origin := r.Header.Get("Origin")
|
|
||||||
allowedOrigins := os.Getenv("ALLOWED_ORIGINS")
|
|
||||||
if allowedOrigins == "" {
|
|
||||||
// Default for development
|
|
||||||
return origin == "http://localhost:5173" || origin == "http://localhost:3000"
|
|
||||||
}
|
|
||||||
// Production: validate against ALLOWED_ORIGINS
|
|
||||||
origins := strings.Split(allowedOrigins, ",")
|
|
||||||
for _, allowed := range origins {
|
|
||||||
if strings.TrimSpace(allowed) == origin {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
type WebSocketHandler struct {
|
type WebSocketHandler struct {
|
||||||
hub *hub.Hub
|
hub *hub.Hub
|
||||||
store store.Store
|
store store.Store
|
||||||
|
cfg *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWebSocketHandler(h *hub.Hub, s store.Store) *WebSocketHandler {
|
func NewWebSocketHandler(h *hub.Hub, s store.Store, cfg *config.Config) *WebSocketHandler {
|
||||||
return &WebSocketHandler{
|
return &WebSocketHandler{
|
||||||
hub: h,
|
hub: h,
|
||||||
store: s,
|
store: s,
|
||||||
|
cfg: cfg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wsh *WebSocketHandler) getUpgrader() websocket.Upgrader {
|
||||||
|
return websocket.Upgrader{
|
||||||
|
ReadBufferSize: 1024,
|
||||||
|
WriteBufferSize: 1024,
|
||||||
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
|
origin := r.Header.Get("Origin")
|
||||||
|
for _, allowed := range wsh.cfg.AllowedOrigins {
|
||||||
|
if allowed == origin {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,16 +66,8 @@ func (wsh *WebSocketHandler) HandleWebSocket(c *gin.Context) {
|
|||||||
// Check for JWT token in query parameter
|
// Check for JWT token in query parameter
|
||||||
jwtToken := c.Query("token")
|
jwtToken := c.Query("token")
|
||||||
if jwtToken != "" {
|
if jwtToken != "" {
|
||||||
// Validate JWT signature and expiration - STATELESS, 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Direct JWT validation - fast path (~1ms)
|
// Direct JWT validation - fast path (~1ms)
|
||||||
claims, err := auth.ValidateJWT(jwtToken, jwtSecret)
|
claims, err := auth.ValidateJWT(jwtToken, wsh.cfg.JWTSecret)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Extract user data from JWT claims
|
// Extract user data from JWT claims
|
||||||
uid, parseErr := uuid.Parse(claims.Subject)
|
uid, parseErr := uuid.Parse(claims.Subject)
|
||||||
@@ -151,6 +139,7 @@ func (wsh *WebSocketHandler) HandleWebSocket(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Upgrade connection
|
// Upgrade connection
|
||||||
|
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)
|
log.Printf("Failed to upgrade connection: %v", err)
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
# Frontend Environment Variables
|
# Frontend Environment Variables
|
||||||
# Copy this file to .env.local for local development
|
# Copy this file to .env.local for local development
|
||||||
|
|
||||||
# API Configuration
|
# ===================
|
||||||
# Backend API base URL (must include /api path)
|
# API CONFIGURATION
|
||||||
|
# ===================
|
||||||
|
|
||||||
|
# Backend API URL (must include /api path)
|
||||||
|
# Default for development: http://localhost:8080/api
|
||||||
VITE_API_URL=http://localhost:8080/api
|
VITE_API_URL=http://localhost:8080/api
|
||||||
|
|
||||||
# WebSocket Configuration
|
# WebSocket URL (must include /ws path)
|
||||||
# WebSocket server URL for real-time collaboration (must include /ws path)
|
# Default for development: ws://localhost:8080/ws
|
||||||
|
# Use wss:// for production (secure WebSocket)
|
||||||
VITE_WS_URL=ws://localhost:8080/ws
|
VITE_WS_URL=ws://localhost:8080/ws
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const API_BASE_URL = import.meta.env.VITE_API_URL || "https://docnest-backend-mingda.fly.dev/api";
|
import { API_BASE_URL } from '../config';
|
||||||
|
|
||||||
export async function authFetch(url: string, options?: RequestInit): Promise<Response> {
|
export async function authFetch(url: string, options?: RequestInit): Promise<Response> {
|
||||||
const token = localStorage.getItem('auth_token');
|
const token = localStorage.getItem('auth_token');
|
||||||
|
|||||||
24
frontend/src/config/index.ts
Normal file
24
frontend/src/config/index.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
interface Config {
|
||||||
|
apiUrl: string;
|
||||||
|
wsUrl: string;
|
||||||
|
environment: 'development' | 'production';
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadConfig(): Config {
|
||||||
|
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8080/api';
|
||||||
|
const wsUrl = import.meta.env.VITE_WS_URL || 'ws://localhost:8080/ws';
|
||||||
|
const environment = import.meta.env.MODE === 'production' ? 'production' : 'development';
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiUrl,
|
||||||
|
wsUrl,
|
||||||
|
environment,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = loadConfig();
|
||||||
|
|
||||||
|
// Convenience exports
|
||||||
|
export const API_BASE_URL = config.apiUrl;
|
||||||
|
export const WS_URL = config.wsUrl;
|
||||||
|
export const isProduction = config.environment === 'production';
|
||||||
@@ -47,6 +47,13 @@ export const useYjsDocument = (documentId: string, shareToken?: string) => {
|
|||||||
let currentProviders: YjsProviders | null = null;
|
let currentProviders: YjsProviders | null = null;
|
||||||
|
|
||||||
const initializeDocument = async () => {
|
const initializeDocument = async () => {
|
||||||
|
// Read port override from query param for testing multiple backends
|
||||||
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
|
const portOverride = searchParams.get('port');
|
||||||
|
const wsUrlOverride = portOverride
|
||||||
|
? `ws://localhost:${portOverride}/ws`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// For share token access, use placeholder user info
|
// For share token access, use placeholder user info
|
||||||
// Extract user data (handle both direct user object and nested structure for backwards compat)
|
// Extract user data (handle both direct user object and nested structure for backwards compat)
|
||||||
const realUser = user || {
|
const realUser = user || {
|
||||||
@@ -69,12 +76,16 @@ export const useYjsDocument = (documentId: string, shareToken?: string) => {
|
|||||||
});
|
});
|
||||||
const authToken = token || "";
|
const authToken = token || "";
|
||||||
console.log("authToken is " + token);
|
console.log("authToken is " + token);
|
||||||
|
if (wsUrlOverride) {
|
||||||
|
console.log(`🔧 Using WebSocket URL override: ${wsUrlOverride}`);
|
||||||
|
}
|
||||||
|
|
||||||
const yjsProviders = await createYjsDocument(
|
const yjsProviders = await createYjsDocument(
|
||||||
documentId,
|
documentId,
|
||||||
{ id: currentId, name: currentName, avatar_url: currentAvatar },
|
{ id: currentId, name: currentName, avatar_url: currentAvatar },
|
||||||
authToken,
|
authToken,
|
||||||
shareToken
|
shareToken,
|
||||||
|
wsUrlOverride
|
||||||
);
|
);
|
||||||
currentProviders = yjsProviders;
|
currentProviders = yjsProviders;
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import { Awareness } from "y-protocols/awareness";
|
|||||||
import { WebsocketProvider } from "y-websocket";
|
import { WebsocketProvider } from "y-websocket";
|
||||||
import * as Y from "yjs";
|
import * as Y from "yjs";
|
||||||
import { documentsApi } from "../api/document";
|
import { documentsApi } from "../api/document";
|
||||||
|
import { WS_URL } from "../config";
|
||||||
const WS_URL = import.meta.env.VITE_WS_URL || "wss://docnest-backend-mingda.fly.dev/ws";
|
|
||||||
|
|
||||||
export interface YjsProviders {
|
export interface YjsProviders {
|
||||||
ydoc: Y.Doc;
|
ydoc: Y.Doc;
|
||||||
@@ -23,7 +22,8 @@ export const createYjsDocument = async (
|
|||||||
documentId: string,
|
documentId: string,
|
||||||
_user: YjsUser,
|
_user: YjsUser,
|
||||||
token: string,
|
token: string,
|
||||||
shareToken?: string
|
shareToken?: string,
|
||||||
|
wsUrlOverride?: string
|
||||||
): Promise<YjsProviders> => {
|
): Promise<YjsProviders> => {
|
||||||
// Create Yjs document
|
// Create Yjs document
|
||||||
const ydoc = new Y.Doc();
|
const ydoc = new Y.Doc();
|
||||||
@@ -46,8 +46,9 @@ export const createYjsDocument = async (
|
|||||||
const wsParams: { [key: string]: string } = shareToken
|
const wsParams: { [key: string]: string } = shareToken
|
||||||
? { share: shareToken }
|
? { share: shareToken }
|
||||||
: { token: token };
|
: { token: token };
|
||||||
|
const wsUrl = wsUrlOverride || WS_URL;
|
||||||
const websocketProvider = new WebsocketProvider(
|
const websocketProvider = new WebsocketProvider(
|
||||||
WS_URL,
|
wsUrl,
|
||||||
documentId,
|
documentId,
|
||||||
ydoc,
|
ydoc,
|
||||||
{ params: wsParams }
|
{ params: wsParams }
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
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 { API_BASE_URL } from '../config';
|
||||||
import './LandingPage.css';
|
import './LandingPage.css';
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_URL || "https://docnest-backend-mingda.fly.dev/api";
|
|
||||||
|
|
||||||
function LandingPage() {
|
function LandingPage() {
|
||||||
const handleGoogleLogin = () => {
|
const handleGoogleLogin = () => {
|
||||||
window.location.href = `${API_BASE_URL}/auth/google`;
|
window.location.href = `${API_BASE_URL}/auth/google`;
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { API_BASE_URL } from '../config';
|
||||||
import './LoginPage.css';
|
import './LoginPage.css';
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_URL || "https://docnest-backend-mingda.fly.dev/api";
|
|
||||||
|
|
||||||
function LoginPage() {
|
function LoginPage() {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|||||||
Reference in New Issue
Block a user