Refactor API configuration and improve WebSocket handling in frontend and backend

This commit is contained in:
M1ngdaXie
2026-02-03 17:25:11 -08:00
parent 35c4aa2580
commit 70a406c73c
14 changed files with 335 additions and 131 deletions

View File

@@ -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

View 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 != ""
}

View File

@@ -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
} }

View File

@@ -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,

View File

@@ -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

View File

@@ -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)
} }

View File

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

View File

@@ -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

View File

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

View 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';

View File

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

View File

@@ -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 }

View File

@@ -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`;

View File

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