From 4a06ceb7f6dddbea8b906cff7c7035b0a70c7dc5 Mon Sep 17 00:00:00 2001 From: poignatov Date: Thu, 1 Jan 2026 18:21:18 +0300 Subject: [PATCH] v2.0.0: Multi-user authentication with JWT Features: - User registration and login with JWT tokens - All data is now user-specific (multi-tenancy) - Profile page with integrations and logout - Automatic migration of existing data to first user Backend changes: - Added users and refresh_tokens tables - Added user_id to all data tables (projects, entries, nodes, dictionaries, words, progress, configs, telegram_integrations, weekly_goals) - JWT authentication middleware - claimOrphanedData() for data migration Frontend changes: - AuthContext for state management - Login/Register forms - Profile page (replaced Integrations) - All API calls use authFetch with Bearer token Migration notes: - On first deploy, backend automatically adds user_id columns - First user to login claims all existing data --- VERSION | 3 +- env.example | 9 + play-life-backend/go.mod | 4 +- play-life-backend/go.sum | 4 + play-life-backend/main.go | 1243 ++++++++++++++--- .../009_add_users_and_multitenancy.sql | 128 ++ play-life-web/package.json | 2 +- play-life-web/src/App.jsx | 64 +- play-life-web/src/components/AddConfig.jsx | 8 +- play-life-web/src/components/AddWords.jsx | 4 +- play-life-web/src/components/Integrations.jsx | 57 - play-life-web/src/components/Profile.jsx | 124 ++ .../src/components/ProjectPriorityManager.jsx | 10 +- .../src/components/TelegramIntegration.jsx | 6 +- .../src/components/TestConfigSelection.jsx | 8 +- play-life-web/src/components/TestWords.jsx | 6 +- .../src/components/TodoistIntegration.jsx | 4 +- play-life-web/src/components/WordList.jsx | 10 +- .../src/components/auth/AuthContext.jsx | 230 +++ .../src/components/auth/AuthScreen.jsx | 16 + .../src/components/auth/LoginForm.jsx | 112 ++ .../src/components/auth/RegisterForm.jsx | 150 ++ restore-db.sh | 47 +- 23 files changed, 1970 insertions(+), 279 deletions(-) create mode 100644 play-life-backend/migrations/009_add_users_and_multitenancy.sql delete mode 100644 play-life-web/src/components/Integrations.jsx create mode 100644 play-life-web/src/components/Profile.jsx create mode 100644 play-life-web/src/components/auth/AuthContext.jsx create mode 100644 play-life-web/src/components/auth/AuthScreen.jsx create mode 100644 play-life-web/src/components/auth/LoginForm.jsx create mode 100644 play-life-web/src/components/auth/RegisterForm.jsx diff --git a/VERSION b/VERSION index 5d99e49..227cea2 100644 --- a/VERSION +++ b/VERSION @@ -1,2 +1 @@ -1.1.1 - +2.0.0 diff --git a/env.example b/env.example index 93a8ab2..fd00567 100644 --- a/env.example +++ b/env.example @@ -47,6 +47,15 @@ WEBHOOK_BASE_URL=https://your-domain.com # Оставьте пустым, если не хотите использовать проверку секрета TODOIST_WEBHOOK_SECRET= +# ============================================ +# Authentication Configuration +# ============================================ +# Секретный ключ для подписи JWT токенов +# ВАЖНО: Обязательно задайте свой уникальный секретный ключ для production! +# Если не задан, будет использован случайно сгенерированный (не рекомендуется для production) +# Можно сгенерировать с помощью: openssl rand -base64 32 +JWT_SECRET=your-super-secret-jwt-key-change-in-production + # ============================================ # Scheduler Configuration # ============================================ diff --git a/play-life-backend/go.mod b/play-life-backend/go.mod index d3fbf27..351b547 100644 --- a/play-life-backend/go.mod +++ b/play-life-backend/go.mod @@ -1,11 +1,13 @@ module play-eng-backend -go 1.21 +go 1.24.0 require ( github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/gorilla/mux v1.8.1 github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.10.9 github.com/robfig/cron/v3 v3.0.1 + golang.org/x/crypto v0.46.0 ) diff --git a/play-life-backend/go.sum b/play-life-backend/go.sum index 70f945b..bcdc1e9 100644 --- a/play-life-backend/go.sum +++ b/play-life-backend/go.sum @@ -1,5 +1,7 @@ github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= @@ -8,3 +10,5 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= diff --git a/play-life-backend/main.go b/play-life-backend/main.go index c4c60c7..0a5ddca 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -2,7 +2,10 @@ package main import ( "bytes" + "context" + "crypto/rand" "database/sql" + "encoding/base64" "encoding/json" "fmt" "io" @@ -20,10 +23,12 @@ import ( "unicode/utf16" "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/golang-jwt/jwt/v5" "github.com/gorilla/mux" "github.com/joho/godotenv" _ "github.com/lib/pq" "github.com/robfig/cron/v3" + "golang.org/x/crypto/bcrypt" ) type Word struct { @@ -182,23 +187,563 @@ type TelegramWebhook struct { // TelegramUpdate - структура для Telegram webhook (обычно это Update объект) type TelegramUpdate struct { - UpdateID int `json:"update_id"` - Message *TelegramMessage `json:"message,omitempty"` + UpdateID int `json:"update_id"` + Message *TelegramMessage `json:"message,omitempty"` EditedMessage *TelegramMessage `json:"edited_message,omitempty"` } +// ============================================ +// Auth types +// ============================================ + +type User struct { + ID int `json:"id"` + Email string `json:"email"` + Name *string `json:"name,omitempty"` + PasswordHash string `json:"-"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + IsActive bool `json:"is_active"` + LastLoginAt *time.Time `json:"last_login_at,omitempty"` +} + +type LoginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type RegisterRequest struct { + Email string `json:"email"` + Password string `json:"password"` + Name *string `json:"name,omitempty"` +} + +type TokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + User User `json:"user"` +} + +type RefreshRequest struct { + RefreshToken string `json:"refresh_token"` +} + +type UserResponse struct { + User User `json:"user"` +} + +type JWTClaims struct { + UserID int `json:"user_id"` + jwt.RegisteredClaims +} + +// Context key for user ID +type contextKey string + +const userIDKey contextKey = "user_id" + type App struct { DB *sql.DB webhookMutex sync.Mutex lastWebhookTime map[int]time.Time // config_id -> last webhook time telegramBot *tgbotapi.BotAPI telegramChatID int64 + jwtSecret []byte } func setCORSHeaders(w http.ResponseWriter) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") +} + +// ============================================ +// Auth helper functions +// ============================================ + +func hashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(bytes), err +} + +func checkPasswordHash(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} + +func generateRefreshToken() (string, error) { + b := make([]byte, 32) + _, err := rand.Read(b) + if err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(b), nil +} + +func (a *App) generateAccessToken(userID int) (string, error) { + claims := JWTClaims{ + UserID: userID, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(a.jwtSecret) +} + +func (a *App) validateAccessToken(tokenString string) (*JWTClaims, error) { + token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return a.jwtSecret, nil + }) + if err != nil { + return nil, err + } + if claims, ok := token.Claims.(*JWTClaims); ok && token.Valid { + return claims, nil + } + return nil, fmt.Errorf("invalid token") +} + +// getUserIDFromContext extracts user ID from request context +func getUserIDFromContext(r *http.Request) (int, bool) { + userID, ok := r.Context().Value(userIDKey).(int) + return userID, ok +} + +// ============================================ +// Auth middleware +// ============================================ + +func (a *App) authMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Handle CORS preflight + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + sendErrorWithCORS(w, "Authorization header required", http.StatusUnauthorized) + return + } + + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Bearer" { + sendErrorWithCORS(w, "Invalid authorization header format", http.StatusUnauthorized) + return + } + + claims, err := a.validateAccessToken(parts[1]) + if err != nil { + sendErrorWithCORS(w, "Invalid or expired token", http.StatusUnauthorized) + return + } + + // Add user_id to context + ctx := context.WithValue(r.Context(), userIDKey, claims.UserID) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// ============================================ +// Auth handlers +// ============================================ + +func (a *App) registerHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + var req RegisterRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.Email == "" || req.Password == "" { + sendErrorWithCORS(w, "Email and password are required", http.StatusBadRequest) + return + } + + if len(req.Password) < 6 { + sendErrorWithCORS(w, "Password must be at least 6 characters", http.StatusBadRequest) + return + } + + // Check if email already exists + var existingID int + err := a.DB.QueryRow("SELECT id FROM users WHERE email = $1", req.Email).Scan(&existingID) + if err == nil { + sendErrorWithCORS(w, "Email already registered", http.StatusConflict) + return + } + if err != sql.ErrNoRows { + log.Printf("Error checking existing user: %v", err) + sendErrorWithCORS(w, "Database error", http.StatusInternalServerError) + return + } + + // Hash password + passwordHash, err := hashPassword(req.Password) + if err != nil { + log.Printf("Error hashing password: %v", err) + sendErrorWithCORS(w, "Error processing password", http.StatusInternalServerError) + return + } + + // Insert user + var user User + err = a.DB.QueryRow(` + INSERT INTO users (email, password_hash, name, created_at, updated_at, is_active) + VALUES ($1, $2, $3, NOW(), NOW(), true) + RETURNING id, email, name, created_at, updated_at, is_active, last_login_at + `, req.Email, passwordHash, req.Name).Scan( + &user.ID, &user.Email, &user.Name, &user.CreatedAt, &user.UpdatedAt, &user.IsActive, &user.LastLoginAt, + ) + if err != nil { + log.Printf("Error inserting user: %v", err) + sendErrorWithCORS(w, "Error creating user", http.StatusInternalServerError) + return + } + + // Check if this is the first user - if so, claim all orphaned data + var userCount int + a.DB.QueryRow("SELECT COUNT(*) FROM users").Scan(&userCount) + if userCount == 1 { + log.Printf("First user registered (ID: %d), claiming all orphaned data", user.ID) + a.claimOrphanedData(user.ID) + } + + // Generate tokens + accessToken, err := a.generateAccessToken(user.ID) + if err != nil { + log.Printf("Error generating access token: %v", err) + sendErrorWithCORS(w, "Error generating token", http.StatusInternalServerError) + return + } + + refreshToken, err := generateRefreshToken() + if err != nil { + log.Printf("Error generating refresh token: %v", err) + sendErrorWithCORS(w, "Error generating token", http.StatusInternalServerError) + return + } + + // Hash and store refresh token + refreshTokenHash, _ := hashPassword(refreshToken) + _, err = a.DB.Exec(` + INSERT INTO refresh_tokens (user_id, token_hash, expires_at) + VALUES ($1, $2, $3) + `, user.ID, refreshTokenHash, time.Now().Add(7*24*time.Hour)) + if err != nil { + log.Printf("Error storing refresh token: %v", err) + } + + // Update last login + a.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = $1", user.ID) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(TokenResponse{ + AccessToken: accessToken, + RefreshToken: refreshToken, + ExpiresIn: 900, // 15 minutes + User: user, + }) +} + +func (a *App) loginHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + var req LoginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.Email == "" || req.Password == "" { + sendErrorWithCORS(w, "Email and password are required", http.StatusBadRequest) + return + } + + // Find user + var user User + err := a.DB.QueryRow(` + SELECT id, email, password_hash, name, created_at, updated_at, is_active, last_login_at + FROM users WHERE email = $1 + `, req.Email).Scan( + &user.ID, &user.Email, &user.PasswordHash, &user.Name, + &user.CreatedAt, &user.UpdatedAt, &user.IsActive, &user.LastLoginAt, + ) + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Invalid email or password", http.StatusUnauthorized) + return + } + if err != nil { + log.Printf("Error finding user: %v", err) + sendErrorWithCORS(w, "Database error", http.StatusInternalServerError) + return + } + + if !user.IsActive { + sendErrorWithCORS(w, "Account is disabled", http.StatusForbidden) + return + } + + // Check password + if !checkPasswordHash(req.Password, user.PasswordHash) { + sendErrorWithCORS(w, "Invalid email or password", http.StatusUnauthorized) + return + } + + // Check if there is any orphaned data - claim it for this user + var orphanedDataCount int + a.DB.QueryRow(` + SELECT COUNT(*) FROM ( + SELECT 1 FROM projects WHERE user_id IS NULL + UNION ALL SELECT 1 FROM entries WHERE user_id IS NULL + UNION ALL SELECT 1 FROM nodes WHERE user_id IS NULL + UNION ALL SELECT 1 FROM dictionaries WHERE user_id IS NULL + UNION ALL SELECT 1 FROM words WHERE user_id IS NULL + UNION ALL SELECT 1 FROM progress WHERE user_id IS NULL + UNION ALL SELECT 1 FROM configs WHERE user_id IS NULL + UNION ALL SELECT 1 FROM telegram_integrations WHERE user_id IS NULL + UNION ALL SELECT 1 FROM weekly_goals WHERE user_id IS NULL + LIMIT 1 + ) orphaned + `).Scan(&orphanedDataCount) + if orphanedDataCount > 0 { + log.Printf("User %d logging in, claiming orphaned data from all tables", user.ID) + a.claimOrphanedData(user.ID) + } + + // Generate tokens + accessToken, err := a.generateAccessToken(user.ID) + if err != nil { + log.Printf("Error generating access token: %v", err) + sendErrorWithCORS(w, "Error generating token", http.StatusInternalServerError) + return + } + + refreshToken, err := generateRefreshToken() + if err != nil { + log.Printf("Error generating refresh token: %v", err) + sendErrorWithCORS(w, "Error generating token", http.StatusInternalServerError) + return + } + + // Hash and store refresh token + refreshTokenHash, _ := hashPassword(refreshToken) + _, err = a.DB.Exec(` + INSERT INTO refresh_tokens (user_id, token_hash, expires_at) + VALUES ($1, $2, $3) + `, user.ID, refreshTokenHash, time.Now().Add(7*24*time.Hour)) + if err != nil { + log.Printf("Error storing refresh token: %v", err) + } + + // Update last login + a.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = $1", user.ID) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(TokenResponse{ + AccessToken: accessToken, + RefreshToken: refreshToken, + ExpiresIn: 900, + User: user, + }) +} + +func (a *App) refreshTokenHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + var req RefreshRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.RefreshToken == "" { + sendErrorWithCORS(w, "Refresh token is required", http.StatusBadRequest) + return + } + + // Find valid refresh token + rows, err := a.DB.Query(` + SELECT rt.id, rt.user_id, rt.token_hash, u.email, u.name, u.created_at, u.updated_at, u.is_active, u.last_login_at + FROM refresh_tokens rt + JOIN users u ON rt.user_id = u.id + WHERE rt.expires_at > NOW() + `) + if err != nil { + log.Printf("Error querying refresh tokens: %v", err) + sendErrorWithCORS(w, "Database error", http.StatusInternalServerError) + return + } + defer rows.Close() + + var foundTokenID int + var user User + var tokenFound bool + + for rows.Next() { + var tokenID int + var tokenHash string + err := rows.Scan(&tokenID, &user.ID, &tokenHash, &user.Email, &user.Name, + &user.CreatedAt, &user.UpdatedAt, &user.IsActive, &user.LastLoginAt) + if err != nil { + continue + } + if checkPasswordHash(req.RefreshToken, tokenHash) { + foundTokenID = tokenID + tokenFound = true + break + } + } + + if !tokenFound { + sendErrorWithCORS(w, "Invalid or expired refresh token", http.StatusUnauthorized) + return + } + + if !user.IsActive { + sendErrorWithCORS(w, "Account is disabled", http.StatusForbidden) + return + } + + // Delete old refresh token + a.DB.Exec("DELETE FROM refresh_tokens WHERE id = $1", foundTokenID) + + // Generate new tokens + accessToken, err := a.generateAccessToken(user.ID) + if err != nil { + log.Printf("Error generating access token: %v", err) + sendErrorWithCORS(w, "Error generating token", http.StatusInternalServerError) + return + } + + refreshToken, err := generateRefreshToken() + if err != nil { + log.Printf("Error generating refresh token: %v", err) + sendErrorWithCORS(w, "Error generating token", http.StatusInternalServerError) + return + } + + // Store new refresh token + refreshTokenHash, _ := hashPassword(refreshToken) + a.DB.Exec(` + INSERT INTO refresh_tokens (user_id, token_hash, expires_at) + VALUES ($1, $2, $3) + `, user.ID, refreshTokenHash, time.Now().Add(7*24*time.Hour)) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(TokenResponse{ + AccessToken: accessToken, + RefreshToken: refreshToken, + ExpiresIn: 900, + User: user, + }) +} + +func (a *App) logoutHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Delete all refresh tokens for this user + a.DB.Exec("DELETE FROM refresh_tokens WHERE user_id = $1", userID) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"message": "Logged out successfully"}) +} + +func (a *App) getMeHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + var user User + err := a.DB.QueryRow(` + SELECT id, email, name, created_at, updated_at, is_active, last_login_at + FROM users WHERE id = $1 + `, userID).Scan( + &user.ID, &user.Email, &user.Name, &user.CreatedAt, &user.UpdatedAt, &user.IsActive, &user.LastLoginAt, + ) + if err != nil { + log.Printf("Error finding user: %v", err) + sendErrorWithCORS(w, "User not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(UserResponse{User: user}) +} + +// claimOrphanedData assigns all data with NULL user_id to the specified user +func (a *App) claimOrphanedData(userID int) { + tables := []string{"projects", "entries", "nodes", "dictionaries", "words", "progress", "configs", "telegram_integrations", "weekly_goals"} + for _, table := range tables { + // First check if user_id column exists + var columnExists bool + err := a.DB.QueryRow(` + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'user_id' + ) + `, table).Scan(&columnExists) + + if err != nil || !columnExists { + log.Printf("Skipping %s: user_id column does not exist (run migrations as table owner)", table) + continue + } + + result, err := a.DB.Exec(fmt.Sprintf("UPDATE %s SET user_id = $1 WHERE user_id IS NULL", table), userID) + if err != nil { + log.Printf("Error claiming orphaned data in %s: %v", table, err) + } else { + rowsAffected, _ := result.RowsAffected() + if rowsAffected > 0 { + log.Printf("Claimed %d orphaned rows in %s for user %d", rowsAffected, table, userID) + } + } + } } func sendErrorWithCORS(w http.ResponseWriter, message string, statusCode int) { @@ -212,13 +757,17 @@ func sendErrorWithCORS(w http.ResponseWriter, message string, statusCode int) { func (a *App) getWordsHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + // Get dictionary_id from query parameter dictionaryIDStr := r.URL.Query().Get("dictionary_id") var dictionaryID *int @@ -239,20 +788,15 @@ func (a *App) getWordsHandler(w http.ResponseWriter, r *http.Request) { CASE WHEN p.last_success_at IS NOT NULL THEN p.last_success_at::text ELSE NULL END as last_success_at, CASE WHEN p.last_failure_at IS NOT NULL THEN p.last_failure_at::text ELSE NULL END as last_failure_at FROM words w - LEFT JOIN progress p ON w.id = p.word_id - WHERE ($1::INTEGER IS NULL OR w.dictionary_id = $1) + JOIN dictionaries d ON w.dictionary_id = d.id + LEFT JOIN progress p ON w.id = p.word_id AND p.user_id = $1 + WHERE d.user_id = $1 AND ($2::INTEGER IS NULL OR w.dictionary_id = $2) ORDER BY w.id ` - var rows *sql.Rows - var err error - if dictionaryID != nil { - rows, err = a.DB.Query(query, *dictionaryID) - } else { - rows, err = a.DB.Query(query, nil) - } + rows, err := a.DB.Query(query, userID, dictionaryID) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } defer rows.Close() @@ -273,7 +817,7 @@ func (a *App) getWordsHandler(w http.ResponseWriter, r *http.Request) { &lastFailure, ) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } @@ -287,40 +831,62 @@ func (a *App) getWordsHandler(w http.ResponseWriter, r *http.Request) { words = append(words, word) } + setCORSHeaders(w) w.Header().Set("Content-Type", "application/json") - w.Header().Set("Access-Control-Allow-Origin", "*") json.NewEncoder(w).Encode(words) } func (a *App) addWordsHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + var req WordsRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } tx, err := a.DB.Begin() if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } defer tx.Rollback() + // Create default dictionary for user if needed + var defaultDictID int + err = tx.QueryRow(` + SELECT id FROM dictionaries WHERE user_id = $1 AND id = 0 + UNION ALL + SELECT id FROM dictionaries WHERE user_id = $1 ORDER BY id LIMIT 1 + `, userID).Scan(&defaultDictID) + if err == sql.ErrNoRows { + // Create default dictionary for user + err = tx.QueryRow(` + INSERT INTO dictionaries (name, user_id) VALUES ('Все слова', $1) RETURNING id + `, userID).Scan(&defaultDictID) + if err != nil { + sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) + return + } + } + stmt, err := tx.Prepare(` INSERT INTO words (name, translation, description, dictionary_id) - VALUES ($1, $2, $3, COALESCE($4, 0)) + VALUES ($1, $2, $3, COALESCE($4, $5)) RETURNING id `) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } defer stmt.Close() @@ -328,25 +894,25 @@ func (a *App) addWordsHandler(w http.ResponseWriter, r *http.Request) { var addedCount int for _, wordReq := range req.Words { var id int - dictionaryID := 0 + dictionaryID := defaultDictID if wordReq.DictionaryID != nil { dictionaryID = *wordReq.DictionaryID } - err := stmt.QueryRow(wordReq.Name, wordReq.Translation, wordReq.Description, dictionaryID).Scan(&id) + err := stmt.QueryRow(wordReq.Name, wordReq.Translation, wordReq.Description, wordReq.DictionaryID, dictionaryID).Scan(&id) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } addedCount++ } if err := tx.Commit(); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } + setCORSHeaders(w) w.Header().Set("Content-Type", "application/json") - w.Header().Set("Access-Control-Allow-Origin", "*") json.NewEncoder(w).Encode(map[string]interface{}{ "message": fmt.Sprintf("Added %d words", addedCount), "added": addedCount, @@ -362,6 +928,12 @@ func (a *App) getTestWordsHandler(w http.ResponseWriter, r *http.Request) { return } + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + // Get config_id from query parameter (required) configIDStr := r.URL.Query().Get("config_id") if configIDStr == "" { @@ -375,9 +947,9 @@ func (a *App) getTestWordsHandler(w http.ResponseWriter, r *http.Request) { return } - // Get words_count from config + // Get words_count from config (verify ownership) var wordsCount int - err = a.DB.QueryRow("SELECT words_count FROM configs WHERE id = $1", configID).Scan(&wordsCount) + err = a.DB.QueryRow("SELECT words_count FROM configs WHERE id = $1 AND user_id = $2", configID, userID).Scan(&wordsCount) if err != nil { if err == sql.ErrNoRows { sendErrorWithCORS(w, "config not found", http.StatusNotFound) @@ -442,10 +1014,11 @@ func (a *App) getTestWordsHandler(w http.ResponseWriter, r *http.Request) { CASE WHEN p.last_success_at IS NOT NULL THEN p.last_success_at::text ELSE NULL END as last_success_at, CASE WHEN p.last_failure_at IS NOT NULL THEN p.last_failure_at::text ELSE NULL END as last_failure_at ` - baseFrom := ` + baseFrom := fmt.Sprintf(` FROM words w - LEFT JOIN progress p ON w.id = p.word_id - WHERE ` + dictFilter + JOIN dictionaries d ON w.dictionary_id = d.id AND d.user_id = %d + LEFT JOIN progress p ON w.id = p.word_id AND p.user_id = %d + WHERE `, userID, userID) + dictFilter // Group 1: success <= 3, sorted by success ASC, then last_success_at ASC (NULL first) group1Query := ` @@ -664,6 +1237,12 @@ func (a *App) updateTestProgressHandler(w http.ResponseWriter, r *http.Request) return } + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + var req TestProgressRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Error decoding request: %v", err) @@ -671,7 +1250,7 @@ func (a *App) updateTestProgressHandler(w http.ResponseWriter, r *http.Request) return } - log.Printf("Received %d word updates, config_id: %v", len(req.Words), req.ConfigID) + log.Printf("Received %d word updates, config_id: %v, user_id: %d", len(req.Words), req.ConfigID, userID) tx, err := a.DB.Begin() if err != nil { @@ -680,10 +1259,13 @@ func (a *App) updateTestProgressHandler(w http.ResponseWriter, r *http.Request) } defer tx.Rollback() + // Create unique constraint for (word_id, user_id) if not exists + tx.Exec("CREATE UNIQUE INDEX IF NOT EXISTS progress_word_user_unique ON progress(word_id, user_id)") + stmt, err := tx.Prepare(` - INSERT INTO progress (word_id, success, failure, last_success_at, last_failure_at) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (word_id) + INSERT INTO progress (word_id, user_id, success, failure, last_success_at, last_failure_at) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (word_id, user_id) DO UPDATE SET success = EXCLUDED.success, failure = EXCLUDED.failure, @@ -724,6 +1306,7 @@ func (a *App) updateTestProgressHandler(w http.ResponseWriter, r *http.Request) _, err := stmt.Exec( wordUpdate.ID, + userID, wordUpdate.Success, wordUpdate.Failure, lastSuccess, @@ -765,7 +1348,7 @@ func (a *App) updateTestProgressHandler(w http.ResponseWriter, r *http.Request) err := a.DB.QueryRow("SELECT try_message FROM configs WHERE id = $1", configID).Scan(&tryMessage) if err == nil && tryMessage.Valid && tryMessage.String != "" { // Process message directly (backend always runs together with frontend) - _, err := a.processMessage(tryMessage.String) + _, err := a.processMessage(tryMessage.String, &userID) if err != nil { log.Printf("Error processing message: %v", err) // Remove from map on error so it can be retried @@ -792,22 +1375,27 @@ func (a *App) updateTestProgressHandler(w http.ResponseWriter, r *http.Request) func (a *App) getConfigsHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + query := ` SELECT id, name, words_count, max_cards, try_message FROM configs + WHERE user_id = $1 ORDER BY id ` - rows, err := a.DB.Query(query) + rows, err := a.DB.Query(query, userID) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } defer rows.Close() @@ -824,7 +1412,7 @@ func (a *App) getConfigsHandler(w http.ResponseWriter, r *http.Request) { &config.TryMessage, ) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } if maxCards.Valid { @@ -834,20 +1422,24 @@ func (a *App) getConfigsHandler(w http.ResponseWriter, r *http.Request) { configs = append(configs, config) } + setCORSHeaders(w) w.Header().Set("Content-Type", "application/json") - w.Header().Set("Access-Control-Allow-Origin", "*") json.NewEncoder(w).Encode(configs) } func (a *App) getDictionariesHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + query := ` SELECT d.id, @@ -855,13 +1447,14 @@ func (a *App) getDictionariesHandler(w http.ResponseWriter, r *http.Request) { COALESCE(COUNT(w.id), 0) as words_count FROM dictionaries d LEFT JOIN words w ON d.id = w.dictionary_id + WHERE d.user_id = $1 GROUP BY d.id, d.name ORDER BY d.id ` - rows, err := a.DB.Query(query) + rows, err := a.DB.Query(query, userID) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } defer rows.Close() @@ -875,46 +1468,47 @@ func (a *App) getDictionariesHandler(w http.ResponseWriter, r *http.Request) { &dict.WordsCount, ) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } dictionaries = append(dictionaries, dict) } + setCORSHeaders(w) w.Header().Set("Content-Type", "application/json") - w.Header().Set("Access-Control-Allow-Origin", "*") json.NewEncoder(w).Encode(dictionaries) } func (a *App) addDictionaryHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + var req DictionaryRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } if req.Name == "" { - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Access-Control-Allow-Origin", "*") - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]string{"error": "Имя словаря обязательно"}) + sendErrorWithCORS(w, "Имя словаря обязательно", http.StatusBadRequest) return } var id int err := a.DB.QueryRow(` - INSERT INTO dictionaries (name) - VALUES ($1) + INSERT INTO dictionaries (name, user_id) + VALUES ($1, $2) RETURNING id - `, req.Name).Scan(&id) + `, req.Name, userID).Scan(&id) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) @@ -937,28 +1531,31 @@ func (a *App) updateDictionaryHandler(w http.ResponseWriter, r *http.Request) { } setCORSHeaders(w) + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + vars := mux.Vars(r) dictionaryID := vars["id"] var req DictionaryRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } if req.Name == "" { - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Access-Control-Allow-Origin", "*") - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]string{"error": "Имя словаря обязательно"}) + sendErrorWithCORS(w, "Имя словаря обязательно", http.StatusBadRequest) return } result, err := a.DB.Exec(` UPDATE dictionaries SET name = $1 - WHERE id = $2 - `, req.Name, dictionaryID) + WHERE id = $2 AND user_id = $3 + `, req.Name, dictionaryID, userID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) @@ -991,15 +1588,26 @@ func (a *App) deleteDictionaryHandler(w http.ResponseWriter, r *http.Request) { } setCORSHeaders(w) + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + vars := mux.Vars(r) dictionaryID := vars["id"] // Prevent deletion of default dictionary (id = 0) if dictionaryID == "0" { - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Access-Control-Allow-Origin", "*") - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]string{"error": "Cannot delete default dictionary"}) + sendErrorWithCORS(w, "Cannot delete default dictionary", http.StatusBadRequest) + return + } + + // Verify ownership + var ownerID int + err := a.DB.QueryRow("SELECT user_id FROM dictionaries WHERE id = $1", dictionaryID).Scan(&ownerID) + if err != nil || ownerID != userID { + sendErrorWithCORS(w, "Dictionary not found", http.StatusNotFound) return } @@ -1105,21 +1713,29 @@ func (a *App) getConfigDictionariesHandler(w http.ResponseWriter, r *http.Reques func (a *App) getTestConfigsAndDictionariesHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } + setCORSHeaders(w) + + userID, ok := getUserIDFromContext(r) + if !ok { + log.Printf("getTestConfigsAndDictionariesHandler: Unauthorized request") + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + log.Printf("getTestConfigsAndDictionariesHandler called, user: %d", userID) // Get configs configsQuery := ` SELECT id, name, words_count, max_cards, try_message FROM configs + WHERE user_id = $1 ORDER BY id ` - configsRows, err := a.DB.Query(configsQuery) + configsRows, err := a.DB.Query(configsQuery, userID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -1156,11 +1772,12 @@ func (a *App) getTestConfigsAndDictionariesHandler(w http.ResponseWriter, r *htt COALESCE(COUNT(w.id), 0) as words_count FROM dictionaries d LEFT JOIN words w ON d.id = w.dictionary_id + WHERE d.user_id = $1 GROUP BY d.id, d.name ORDER BY d.id ` - dictsRows, err := a.DB.Query(dictsQuery) + dictsRows, err := a.DB.Query(dictsQuery, userID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -1194,47 +1811,45 @@ func (a *App) getTestConfigsAndDictionariesHandler(w http.ResponseWriter, r *htt func (a *App) addConfigHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + var req ConfigRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } if req.Name == "" { - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Access-Control-Allow-Origin", "*") - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]string{"message": "Имя обязательно для заполнения"}) + sendErrorWithCORS(w, "Имя обязательно для заполнения", http.StatusBadRequest) return } if req.WordsCount <= 0 { - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Access-Control-Allow-Origin", "*") - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]string{"message": "Количество слов должно быть больше 0"}) + sendErrorWithCORS(w, "Количество слов должно быть больше 0", http.StatusBadRequest) return } tx, err := a.DB.Begin() if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } defer tx.Rollback() var id int err = tx.QueryRow(` - INSERT INTO configs (name, words_count, max_cards, try_message) - VALUES ($1, $2, $3, $4) + INSERT INTO configs (name, words_count, max_cards, try_message, user_id) + VALUES ($1, $2, $3, $4, $5) RETURNING id - `, req.Name, req.WordsCount, req.MaxCards, req.TryMessage).Scan(&id) + `, req.Name, req.WordsCount, req.MaxCards, req.TryMessage, userID).Scan(&id) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) @@ -1283,27 +1898,35 @@ func (a *App) updateConfigHandler(w http.ResponseWriter, r *http.Request) { } setCORSHeaders(w) + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + vars := mux.Vars(r) configID := vars["id"] + // Verify ownership + var ownerID int + err := a.DB.QueryRow("SELECT user_id FROM configs WHERE id = $1", configID).Scan(&ownerID) + if err != nil || ownerID != userID { + sendErrorWithCORS(w, "Config not found", http.StatusNotFound) + return + } + var req ConfigRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } if req.Name == "" { - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Access-Control-Allow-Origin", "*") - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]string{"message": "Имя обязательно для заполнения"}) + sendErrorWithCORS(w, "Имя обязательно для заполнения", http.StatusBadRequest) return } if req.WordsCount <= 0 { - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Access-Control-Allow-Origin", "*") - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]string{"message": "Количество слов должно быть больше 0"}) + sendErrorWithCORS(w, "Количество слов должно быть больше 0", http.StatusBadRequest) return } @@ -1384,10 +2007,16 @@ func (a *App) deleteConfigHandler(w http.ResponseWriter, r *http.Request) { } setCORSHeaders(w) + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + vars := mux.Vars(r) configID := vars["id"] - result, err := a.DB.Exec("DELETE FROM configs WHERE id = $1", configID) + result, err := a.DB.Exec("DELETE FROM configs WHERE id = $1 AND user_id = $2", configID, userID) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return @@ -1418,7 +2047,13 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) { } setCORSHeaders(w) - log.Printf("getWeeklyStatsHandler called from %s, path: %s", r.RemoteAddr, r.URL.Path) + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + log.Printf("getWeeklyStatsHandler called from %s, path: %s, user: %d", r.RemoteAddr, r.URL.Path, userID) // Опционально обновляем materialized view перед запросом // Это можно сделать через query parameter ?refresh=true @@ -1450,12 +2085,12 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) { AND EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER = wr.report_year AND EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER = wr.report_week WHERE - p.deleted = FALSE + p.deleted = FALSE AND p.user_id = $1 ORDER BY total_score DESC ` - rows, err := a.DB.Query(query) + rows, err := a.DB.Query(query, userID) if err != nil { log.Printf("Error querying weekly stats: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) @@ -1806,6 +2441,71 @@ func (a *App) initDB() error { return nil } +func (a *App) initAuthDB() error { + // Create users table + createUsersTable := ` + CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + name VARCHAR(255), + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + is_active BOOLEAN DEFAULT TRUE, + last_login_at TIMESTAMP WITH TIME ZONE + ) + ` + if _, err := a.DB.Exec(createUsersTable); err != nil { + return err + } + + // Create index on email + a.DB.Exec("CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)") + + // Create refresh_tokens table + createRefreshTokensTable := ` + CREATE TABLE IF NOT EXISTS refresh_tokens ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(255) NOT NULL, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + ) + ` + if _, err := a.DB.Exec(createRefreshTokensTable); err != nil { + return err + } + + // Create indexes for refresh_tokens + a.DB.Exec("CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id)") + a.DB.Exec("CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token_hash ON refresh_tokens(token_hash)") + + // Add user_id column to all tables if not exists + tables := []string{"projects", "entries", "nodes", "dictionaries", "words", "progress", "configs", "telegram_integrations", "weekly_goals"} + for _, table := range tables { + alterSQL := fmt.Sprintf("ALTER TABLE %s ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE", table) + if _, err := a.DB.Exec(alterSQL); err != nil { + log.Printf("Warning: Failed to add user_id to %s: %v", table, err) + } + indexSQL := fmt.Sprintf("CREATE INDEX IF NOT EXISTS idx_%s_user_id ON %s(user_id)", table, table) + a.DB.Exec(indexSQL) + } + + // Drop old unique constraint on projects.name (now unique per user, not globally) + a.DB.Exec("ALTER TABLE projects DROP CONSTRAINT IF EXISTS unique_project_name") + + // Drop old unique constraint on progress.word_id (now unique per user) + a.DB.Exec("ALTER TABLE progress DROP CONSTRAINT IF EXISTS progress_word_id_key") + + // Create new unique constraint per user for progress + a.DB.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_progress_word_user_unique ON progress(word_id, user_id)") + + // Clean up expired refresh tokens + a.DB.Exec("DELETE FROM refresh_tokens WHERE expires_at < NOW()") + + return nil +} + func (a *App) initPlayLifeDB() error { // Создаем таблицу projects createProjectsTable := ` @@ -2367,11 +3067,22 @@ func main() { // Telegram бот теперь загружается из БД при необходимости // Webhook будет настроен автоматически при сохранении bot token через UI + // JWT secret from env or generate random + jwtSecret := getEnv("JWT_SECRET", "") + if jwtSecret == "" { + // Generate random secret if not provided (not recommended for production) + b := make([]byte, 32) + rand.Read(b) + jwtSecret = base64.StdEncoding.EncodeToString(b) + log.Printf("WARNING: JWT_SECRET not set, using randomly generated secret. Set JWT_SECRET env var for production.") + } + app := &App{ DB: db, lastWebhookTime: make(map[int]time.Time), telegramBot: nil, // Больше не используем глобальный bot telegramChatID: 0, // Больше не используем глобальный chat_id + jwtSecret: []byte(jwtSecret), } // Пытаемся настроить webhook автоматически при старте, если есть base URL и bot token в БД @@ -2405,6 +3116,12 @@ func main() { } log.Println("Words/dictionaries database initialized successfully") + // Инициализируем таблицы для авторизации + if err := app.initAuthDB(); err != nil { + log.Fatal("Failed to initialize auth database:", err) + } + log.Println("Auth database initialized successfully") + // Запускаем планировщик для автоматической фиксации целей на неделю app.startWeeklyGoalsScheduler() @@ -2412,43 +3129,71 @@ func main() { app.startDailyReportScheduler() r := mux.NewRouter() - r.HandleFunc("/api/words", app.getWordsHandler).Methods("GET", "OPTIONS") - r.HandleFunc("/api/words", app.addWordsHandler).Methods("POST", "OPTIONS") - r.HandleFunc("/api/test/words", app.getTestWordsHandler).Methods("GET", "OPTIONS") - r.HandleFunc("/api/test/progress", app.updateTestProgressHandler).Methods("POST", "OPTIONS") - r.HandleFunc("/api/configs", app.getConfigsHandler).Methods("GET", "OPTIONS") - r.HandleFunc("/api/configs", app.addConfigHandler).Methods("POST", "OPTIONS") - r.HandleFunc("/api/configs/{id}", app.updateConfigHandler).Methods("PUT", "OPTIONS") - r.HandleFunc("/api/configs/{id}", app.deleteConfigHandler).Methods("DELETE", "OPTIONS") - r.HandleFunc("/api/configs/{id}/dictionaries", app.getConfigDictionariesHandler).Methods("GET", "OPTIONS") - r.HandleFunc("/api/dictionaries", app.getDictionariesHandler).Methods("GET", "OPTIONS") - r.HandleFunc("/api/dictionaries", app.addDictionaryHandler).Methods("POST", "OPTIONS") - r.HandleFunc("/api/dictionaries/{id}", app.updateDictionaryHandler).Methods("PUT", "OPTIONS") - r.HandleFunc("/api/dictionaries/{id}", app.deleteDictionaryHandler).Methods("DELETE", "OPTIONS") - r.HandleFunc("/api/test-configs-and-dictionaries", app.getTestConfigsAndDictionariesHandler).Methods("GET", "OPTIONS") - r.HandleFunc("/api/weekly-stats", app.getWeeklyStatsHandler).Methods("GET", "OPTIONS") - r.HandleFunc("/playlife-feed", app.getWeeklyStatsHandler).Methods("GET", "OPTIONS") - r.HandleFunc("/message/post", app.messagePostHandler).Methods("POST", "OPTIONS") + + // Public auth routes (no authentication required) + r.HandleFunc("/api/auth/register", app.registerHandler).Methods("POST", "OPTIONS") + r.HandleFunc("/api/auth/login", app.loginHandler).Methods("POST", "OPTIONS") + r.HandleFunc("/api/auth/refresh", app.refreshTokenHandler).Methods("POST", "OPTIONS") + + // Webhooks - no auth (external services) r.HandleFunc("/webhook/message/post", app.messagePostHandler).Methods("POST", "OPTIONS") r.HandleFunc("/webhook/todoist", app.todoistWebhookHandler).Methods("POST", "OPTIONS") r.HandleFunc("/webhook/telegram", app.telegramWebhookHandler).Methods("POST", "OPTIONS") - r.HandleFunc("/weekly_goals/setup", app.weeklyGoalsSetupHandler).Methods("POST", "OPTIONS") - r.HandleFunc("/daily-report/trigger", app.dailyReportTriggerHandler).Methods("POST", "OPTIONS") - r.HandleFunc("/projects", app.getProjectsHandler).Methods("GET", "OPTIONS") - r.HandleFunc("/project/priority", app.setProjectPriorityHandler).Methods("POST", "OPTIONS") - r.HandleFunc("/project/move", app.moveProjectHandler).Methods("POST", "OPTIONS") - r.HandleFunc("/project/delete", app.deleteProjectHandler).Methods("POST", "OPTIONS") - r.HandleFunc("/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b", app.getFullStatisticsHandler).Methods("GET", "OPTIONS") + + // Admin pages (basic access, consider adding auth later) r.HandleFunc("/admin", app.adminHandler).Methods("GET") r.HandleFunc("/admin.html", app.adminHandler).Methods("GET") - r.HandleFunc("/admin/recreate-mv", app.recreateMaterializedViewHandler).Methods("POST", "OPTIONS") - r.HandleFunc("/api/integrations/telegram", app.getTelegramIntegrationHandler).Methods("GET", "OPTIONS") - r.HandleFunc("/api/integrations/telegram", app.updateTelegramIntegrationHandler).Methods("POST", "OPTIONS") - r.HandleFunc("/api/integrations/todoist/webhook-url", app.getTodoistWebhookURLHandler).Methods("GET", "OPTIONS") + + // Protected routes (require authentication) + protected := r.PathPrefix("/").Subrouter() + protected.Use(app.authMiddleware) + + // Auth routes that need authentication + protected.HandleFunc("/api/auth/logout", app.logoutHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/api/auth/me", app.getMeHandler).Methods("GET", "OPTIONS") + + // Words & dictionaries + protected.HandleFunc("/api/words", app.getWordsHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/api/words", app.addWordsHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/api/test/words", app.getTestWordsHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/api/test/progress", app.updateTestProgressHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/api/dictionaries", app.getDictionariesHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/api/dictionaries", app.addDictionaryHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/api/dictionaries/{id}", app.updateDictionaryHandler).Methods("PUT", "OPTIONS") + protected.HandleFunc("/api/dictionaries/{id}", app.deleteDictionaryHandler).Methods("DELETE", "OPTIONS") + + // Configs + protected.HandleFunc("/api/configs", app.getConfigsHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/api/configs", app.addConfigHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/api/configs/{id}", app.updateConfigHandler).Methods("PUT", "OPTIONS") + protected.HandleFunc("/api/configs/{id}", app.deleteConfigHandler).Methods("DELETE", "OPTIONS") + protected.HandleFunc("/api/configs/{id}/dictionaries", app.getConfigDictionariesHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/api/test-configs-and-dictionaries", app.getTestConfigsAndDictionariesHandler).Methods("GET", "OPTIONS") + + // Projects & stats + protected.HandleFunc("/api/weekly-stats", app.getWeeklyStatsHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/playlife-feed", app.getWeeklyStatsHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/message/post", app.messagePostHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/weekly_goals/setup", app.weeklyGoalsSetupHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/daily-report/trigger", app.dailyReportTriggerHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/projects", app.getProjectsHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/project/priority", app.setProjectPriorityHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/project/move", app.moveProjectHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/project/delete", app.deleteProjectHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b", app.getFullStatisticsHandler).Methods("GET", "OPTIONS") + + // Integrations + protected.HandleFunc("/api/integrations/telegram", app.getTelegramIntegrationHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/api/integrations/telegram", app.updateTelegramIntegrationHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/api/integrations/todoist/webhook-url", app.getTodoistWebhookURLHandler).Methods("GET", "OPTIONS") + + // Admin operations + protected.HandleFunc("/admin/recreate-mv", app.recreateMaterializedViewHandler).Methods("POST", "OPTIONS") port := getEnv("PORT", "8080") log.Printf("Server starting on port %s", port) - log.Printf("Registered routes: /api/words (GET, POST), /api/test/words (GET), /api/test/progress (POST), /api/configs (GET, POST, PUT, DELETE), /api/dictionaries (GET, POST, PUT, DELETE), /api/test-configs-and-dictionaries (GET), /api/weekly-stats (GET), /playlife-feed (GET), /message/post (POST), /webhook/message/post (POST), /webhook/todoist (POST), /webhook/telegram (POST), /weekly_goals/setup (POST), /daily-report/trigger (POST), /projects (GET), /project/priority (POST), /d2dc349a-0d13-49b2-a8f0-1ab094bfba9b (GET), /admin (GET)") + log.Printf("Registered public routes: /api/auth/register, /api/auth/login, /api/auth/refresh, webhooks") + log.Printf("All other routes require authentication via Bearer token") log.Printf("Admin panel available at: http://localhost:%s/admin.html", port) log.Fatal(http.ListenAndServe(":"+port, r)) } @@ -2546,6 +3291,44 @@ type TelegramIntegration struct { } // getTelegramIntegration получает telegram интеграцию из БД +// getTelegramIntegrationForUser gets telegram integration for specific user +func (a *App) getTelegramIntegrationForUser(userID int) (*TelegramIntegration, error) { + var integration TelegramIntegration + var chatID, botToken sql.NullString + + err := a.DB.QueryRow(` + SELECT id, chat_id, bot_token + FROM telegram_integrations + WHERE user_id = $1 + ORDER BY id DESC + LIMIT 1 + `, userID).Scan(&integration.ID, &chatID, &botToken) + + if err == sql.ErrNoRows { + // Если записи нет, создаем новую для этого пользователя + err = a.DB.QueryRow(` + INSERT INTO telegram_integrations (chat_id, bot_token, user_id) + VALUES (NULL, NULL, $1) + RETURNING id + `, userID).Scan(&integration.ID) + if err != nil { + return nil, fmt.Errorf("failed to create telegram integration: %w", err) + } + return &integration, nil + } else if err != nil { + return nil, fmt.Errorf("failed to get telegram integration: %w", err) + } + + if chatID.Valid { + integration.ChatID = &chatID.String + } + if botToken.Valid { + integration.BotToken = &botToken.String + } + + return &integration, nil +} + func (a *App) getTelegramIntegration() (*TelegramIntegration, error) { var integration TelegramIntegration var chatID, botToken sql.NullString @@ -2617,6 +3400,32 @@ func (a *App) saveTelegramBotToken(botToken string) error { return nil } +func (a *App) saveTelegramBotTokenForUser(botToken string, userID int) error { + // Проверяем, есть ли уже запись для этого пользователя + integration, err := a.getTelegramIntegrationForUser(userID) + if err != nil { + // Если записи нет, создаем новую + _, err = a.DB.Exec(` + INSERT INTO telegram_integrations (bot_token, chat_id, user_id) + VALUES ($1, NULL, $2) + `, botToken, userID) + if err != nil { + return fmt.Errorf("failed to create telegram bot token: %w", err) + } + } else { + // Обновляем существующую запись + _, err = a.DB.Exec(` + UPDATE telegram_integrations + SET bot_token = $1 + WHERE id = $2 AND user_id = $3 + `, botToken, integration.ID, userID) + if err != nil { + return fmt.Errorf("failed to update telegram bot token: %w", err) + } + } + return nil +} + // saveTelegramChatID сохраняет chat_id в БД func (a *App) saveTelegramChatID(chatID string) error { // Получаем текущую интеграцию @@ -2837,7 +3646,7 @@ func (a *App) processTelegramMessage(fullText string, entities []TelegramEntity) // Вставляем данные в БД только если есть nodes if len(scoreNodes) > 0 { - err := a.insertMessageData(processedText, createdDate, scoreNodes) + err := a.insertMessageData(processedText, createdDate, scoreNodes, nil) // nil userID for webhook if err != nil { log.Printf("Error inserting message data: %v", err) return nil, fmt.Errorf("error inserting data: %w", err) @@ -2863,18 +3672,18 @@ func (a *App) processTelegramMessage(fullText string, entities []TelegramEntity) } // processMessage обрабатывает текст сообщения: парсит ноды, сохраняет в БД и отправляет в Telegram -func (a *App) processMessage(rawText string) (*ProcessedEntry, error) { - return a.processMessageInternal(rawText, true) +func (a *App) processMessage(rawText string, userID *int) (*ProcessedEntry, error) { + return a.processMessageInternal(rawText, true, userID) } // processMessageWithoutTelegram обрабатывает текст сообщения: парсит ноды, сохраняет в БД, но НЕ отправляет в Telegram -func (a *App) processMessageWithoutTelegram(rawText string) (*ProcessedEntry, error) { - return a.processMessageInternal(rawText, false) +func (a *App) processMessageWithoutTelegram(rawText string, userID *int) (*ProcessedEntry, error) { + return a.processMessageInternal(rawText, false, userID) } // processMessageInternal - внутренняя функция обработки сообщения // sendToTelegram определяет, нужно ли отправлять сообщение в Telegram -func (a *App) processMessageInternal(rawText string, sendToTelegram bool) (*ProcessedEntry, error) { +func (a *App) processMessageInternal(rawText string, sendToTelegram bool, userID *int) (*ProcessedEntry, error) { rawText = strings.TrimSpace(rawText) // Регулярное выражение для поиска **[Project][+| -][Score]** @@ -2934,7 +3743,7 @@ func (a *App) processMessageInternal(rawText string, sendToTelegram bool) (*Proc // Вставляем данные в БД только если есть nodes if len(nodes) > 0 { - err := a.insertMessageData(processedText, createdDate, nodes) + err := a.insertMessageData(processedText, createdDate, nodes, userID) if err != nil { log.Printf("Error inserting message data: %v", err) return nil, fmt.Errorf("error inserting data: %w", err) @@ -2974,6 +3783,12 @@ func (a *App) messagePostHandler(w http.ResponseWriter, r *http.Request) { } setCORSHeaders(w) + // Get user ID from context (may be nil for webhook) + var userIDPtr *int + if userID, ok := getUserIDFromContext(r); ok { + userIDPtr = &userID + } + // Парсим входящий запрос - может быть как {body: {text: ...}}, так и {text: ...} var rawReq map[string]interface{} if err := json.NewDecoder(r.Body).Decode(&rawReq); err != nil { @@ -3004,7 +3819,7 @@ func (a *App) messagePostHandler(w http.ResponseWriter, r *http.Request) { } // Обрабатываем сообщение - response, err := a.processMessage(rawText) + response, err := a.processMessage(rawText, userIDPtr) if err != nil { log.Printf("Error processing message: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) @@ -3015,7 +3830,7 @@ func (a *App) messagePostHandler(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(response) } -func (a *App) insertMessageData(entryText string, createdDate string, nodes []ProcessedNode) error { +func (a *App) insertMessageData(entryText string, createdDate string, nodes []ProcessedNode, userID *int) error { // Начинаем транзакцию tx, err := a.DB.Begin() if err != nil { @@ -3031,24 +3846,53 @@ func (a *App) insertMessageData(entryText string, createdDate string, nodes []Pr // Вставляем проекты for projectName := range projectNames { - _, err := tx.Exec(` - INSERT INTO projects (name, deleted) - VALUES ($1, FALSE) - ON CONFLICT (name) DO UPDATE - SET name = EXCLUDED.name, deleted = FALSE - `, projectName) - if err != nil { - return fmt.Errorf("failed to upsert project %s: %w", projectName, err) + if userID != nil { + _, err := tx.Exec(` + INSERT INTO projects (name, deleted, user_id) + VALUES ($1, FALSE, $2) + ON CONFLICT ON CONSTRAINT unique_project_name DO UPDATE + SET name = EXCLUDED.name, deleted = FALSE + `, projectName, *userID) + if err != nil { + // Try without user constraint for backwards compatibility + _, err = tx.Exec(` + INSERT INTO projects (name, deleted, user_id) + VALUES ($1, FALSE, $2) + ON CONFLICT (name) DO UPDATE + SET name = EXCLUDED.name, deleted = FALSE, user_id = COALESCE(projects.user_id, EXCLUDED.user_id) + `, projectName, *userID) + if err != nil { + return fmt.Errorf("failed to upsert project %s: %w", projectName, err) + } + } + } else { + _, err := tx.Exec(` + INSERT INTO projects (name, deleted) + VALUES ($1, FALSE) + ON CONFLICT (name) DO UPDATE + SET name = EXCLUDED.name, deleted = FALSE + `, projectName) + if err != nil { + return fmt.Errorf("failed to upsert project %s: %w", projectName, err) + } } } // 2. Вставляем entry var entryID int - err = tx.QueryRow(` - INSERT INTO entries (text, created_date) - VALUES ($1, $2) - RETURNING id - `, entryText, createdDate).Scan(&entryID) + if userID != nil { + err = tx.QueryRow(` + INSERT INTO entries (text, created_date, user_id) + VALUES ($1, $2, $3) + RETURNING id + `, entryText, createdDate, *userID).Scan(&entryID) + } else { + err = tx.QueryRow(` + INSERT INTO entries (text, created_date) + VALUES ($1, $2) + RETURNING id + `, entryText, createdDate).Scan(&entryID) + } if err != nil { return fmt.Errorf("failed to insert entry: %w", err) } @@ -3478,6 +4322,12 @@ func (a *App) getProjectsHandler(w http.ResponseWriter, r *http.Request) { } setCORSHeaders(w) + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + query := ` SELECT id AS project_id, @@ -3486,13 +4336,13 @@ func (a *App) getProjectsHandler(w http.ResponseWriter, r *http.Request) { FROM projects WHERE - deleted = FALSE + deleted = FALSE AND user_id = $1 ORDER BY priority ASC NULLS LAST, project_name ` - rows, err := a.DB.Query(query) + rows, err := a.DB.Query(query, userID) if err != nil { log.Printf("Error querying projects: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error querying projects: %v", err), http.StatusInternalServerError) @@ -3536,6 +4386,13 @@ func (a *App) setProjectPriorityHandler(w http.ResponseWriter, r *http.Request) } setCORSHeaders(w) + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + _ = userID // Will be used in SQL queries + // Читаем тело запроса один раз bodyBytes, err := io.ReadAll(r.Body) if err != nil { @@ -3664,14 +4521,14 @@ func (a *App) setProjectPriorityHandler(w http.ResponseWriter, r *http.Request) _, err = tx.Exec(` UPDATE projects SET priority = NULL - WHERE id = $1 - `, project.ID) + WHERE id = $1 AND user_id = $2 + `, project.ID, userID) } else { _, err = tx.Exec(` UPDATE projects SET priority = $1 - WHERE id = $2 - `, *project.Priority, project.ID) + WHERE id = $2 AND user_id = $3 + `, *project.Priority, project.ID, userID) } if err != nil { @@ -3714,6 +4571,13 @@ func (a *App) moveProjectHandler(w http.ResponseWriter, r *http.Request) { } setCORSHeaders(w) + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + _ = userID // Will be used in SQL queries + var req ProjectMoveRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Error decoding move project request: %v", err) @@ -3860,6 +4724,12 @@ func (a *App) deleteProjectHandler(w http.ResponseWriter, r *http.Request) { } setCORSHeaders(w) + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + var req ProjectDeleteRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Error decoding delete project request: %v", err) @@ -3867,6 +4737,14 @@ func (a *App) deleteProjectHandler(w http.ResponseWriter, r *http.Request) { return } + // Verify ownership + var ownerID int + err := a.DB.QueryRow("SELECT user_id FROM projects WHERE id = $1", req.ID).Scan(&ownerID) + if err != nil || ownerID != userID { + sendErrorWithCORS(w, "Project not found", http.StatusNotFound) + return + } + // Начинаем транзакцию tx, err := a.DB.Begin() if err != nil { @@ -4048,7 +4926,7 @@ func (a *App) todoistWebhookHandler(w http.ResponseWriter, r *http.Request) { // Обрабатываем сообщение через существующую логику (без отправки в Telegram) log.Printf("Calling processMessageWithoutTelegram with combined text...") - response, err := a.processMessageWithoutTelegram(combinedText) + response, err := a.processMessageWithoutTelegram(combinedText, nil) // nil userID for webhook if err != nil { log.Printf("ERROR processing Todoist message: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) @@ -4193,6 +5071,12 @@ func (a *App) getFullStatisticsHandler(w http.ResponseWriter, r *http.Request) { } setCORSHeaders(w) + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + query := ` SELECT p.name AS project_name, @@ -4221,14 +5105,15 @@ func (a *App) getFullStatisticsHandler(w http.ResponseWriter, r *http.Request) { -- Присоединяем имя проекта, используя ID из той таблицы, где он не NULL ON p.id = COALESCE(wr.project_id, wg.project_id) WHERE - p.deleted = FALSE + p.deleted = FALSE AND p.user_id = $1 + AND COALESCE(wr.report_year, wg.goal_year) IS NOT NULL ORDER BY report_year DESC, report_week DESC, project_name ` - rows, err := a.DB.Query(query) + rows, err := a.DB.Query(query, userID) if err != nil { log.Printf("Error querying full statistics: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error querying full statistics: %v", err), http.StatusInternalServerError) @@ -4270,7 +5155,13 @@ func (a *App) getTelegramIntegrationHandler(w http.ResponseWriter, r *http.Reque } setCORSHeaders(w) - integration, err := a.getTelegramIntegration() + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + integration, err := a.getTelegramIntegrationForUser(userID) if err != nil { sendErrorWithCORS(w, fmt.Sprintf("Failed to get telegram integration: %v", err), http.StatusInternalServerError) return @@ -4294,6 +5185,12 @@ func (a *App) updateTelegramIntegrationHandler(w http.ResponseWriter, r *http.Re } setCORSHeaders(w) + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + var req TelegramIntegrationUpdateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) @@ -4305,7 +5202,7 @@ func (a *App) updateTelegramIntegrationHandler(w http.ResponseWriter, r *http.Re return } - if err := a.saveTelegramBotToken(req.BotToken); err != nil { + if err := a.saveTelegramBotTokenForUser(req.BotToken, userID); err != nil { sendErrorWithCORS(w, fmt.Sprintf("Failed to save bot token: %v", err), http.StatusInternalServerError) return } @@ -4326,7 +5223,7 @@ func (a *App) updateTelegramIntegrationHandler(w http.ResponseWriter, r *http.Re log.Printf("WARNING: WEBHOOK_BASE_URL not set. Webhook will not be configured automatically.") } - integration, err := a.getTelegramIntegration() + integration, err := a.getTelegramIntegrationForUser(userID) if err != nil { sendErrorWithCORS(w, fmt.Sprintf("Failed to get updated integration: %v", err), http.StatusInternalServerError) return diff --git a/play-life-backend/migrations/009_add_users_and_multitenancy.sql b/play-life-backend/migrations/009_add_users_and_multitenancy.sql new file mode 100644 index 0000000..dc34ef0 --- /dev/null +++ b/play-life-backend/migrations/009_add_users_and_multitenancy.sql @@ -0,0 +1,128 @@ +-- Migration: Add users table and user_id to all tables for multi-tenancy +-- This script adds user authentication and makes all data user-specific +-- All statements use IF NOT EXISTS / IF EXISTS for idempotency + +-- ============================================ +-- Table: users +-- ============================================ +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + name VARCHAR(255), + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + is_active BOOLEAN DEFAULT TRUE, + last_login_at TIMESTAMP WITH TIME ZONE +); + +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); + +-- ============================================ +-- Table: refresh_tokens +-- ============================================ +CREATE TABLE IF NOT EXISTS refresh_tokens ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(255) NOT NULL, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id); +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token_hash ON refresh_tokens(token_hash); + +-- ============================================ +-- Add user_id to projects +-- ============================================ +ALTER TABLE projects +ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE; + +CREATE INDEX IF NOT EXISTS idx_projects_user_id ON projects(user_id); + +-- Drop old unique constraint (name now unique per user, handled in app) +ALTER TABLE projects DROP CONSTRAINT IF EXISTS unique_project_name; + +-- ============================================ +-- Add user_id to entries +-- ============================================ +ALTER TABLE entries +ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE; + +CREATE INDEX IF NOT EXISTS idx_entries_user_id ON entries(user_id); + +-- ============================================ +-- Add user_id to dictionaries +-- ============================================ +ALTER TABLE dictionaries +ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE; + +CREATE INDEX IF NOT EXISTS idx_dictionaries_user_id ON dictionaries(user_id); + +-- ============================================ +-- Add user_id to words +-- ============================================ +ALTER TABLE words +ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE; + +CREATE INDEX IF NOT EXISTS idx_words_user_id ON words(user_id); + +-- ============================================ +-- Add user_id to progress +-- ============================================ +ALTER TABLE progress +ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE; + +CREATE INDEX IF NOT EXISTS idx_progress_user_id ON progress(user_id); + +-- Drop old unique constraint (word_id now unique per user) +ALTER TABLE progress DROP CONSTRAINT IF EXISTS progress_word_id_key; + +-- Create new unique constraint per user +CREATE UNIQUE INDEX IF NOT EXISTS idx_progress_word_user_unique ON progress(word_id, user_id); + +-- ============================================ +-- Add user_id to configs +-- ============================================ +ALTER TABLE configs +ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE; + +CREATE INDEX IF NOT EXISTS idx_configs_user_id ON configs(user_id); + +-- ============================================ +-- Add user_id to telegram_integrations +-- ============================================ +ALTER TABLE telegram_integrations +ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE; + +CREATE INDEX IF NOT EXISTS idx_telegram_integrations_user_id ON telegram_integrations(user_id); + +-- ============================================ +-- Add user_id to weekly_goals +-- ============================================ +ALTER TABLE weekly_goals +ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE; + +CREATE INDEX IF NOT EXISTS idx_weekly_goals_user_id ON weekly_goals(user_id); + +-- ============================================ +-- Add user_id to nodes (score data) +-- ============================================ +ALTER TABLE nodes +ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE; + +CREATE INDEX IF NOT EXISTS idx_nodes_user_id ON nodes(user_id); + +-- ============================================ +-- Comments for documentation +-- ============================================ +COMMENT ON TABLE users IS 'Users table for authentication and multi-tenancy'; +COMMENT ON COLUMN users.email IS 'User email address (unique, used for login)'; +COMMENT ON COLUMN users.password_hash IS 'Bcrypt hashed password'; +COMMENT ON COLUMN users.name IS 'User display name'; +COMMENT ON COLUMN users.is_active IS 'Whether the user account is active'; +COMMENT ON TABLE refresh_tokens IS 'JWT refresh tokens for persistent login'; + +-- Note: The first user who logs in will automatically become the owner of all +-- existing data (projects, entries, dictionaries, words, etc.) that have NULL user_id. +-- This is handled in the application code (claimOrphanedData function). diff --git a/play-life-web/package.json b/play-life-web/package.json index e486bed..9464840 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "1.1.1", + "version": "2.0.0", "type": "module", "scripts": { "dev": "vite", diff --git a/play-life-web/src/App.jsx b/play-life-web/src/App.jsx index 77ac7d0..0037d63 100644 --- a/play-life-web/src/App.jsx +++ b/play-life-web/src/App.jsx @@ -7,13 +7,30 @@ import AddWords from './components/AddWords' import TestConfigSelection from './components/TestConfigSelection' import AddConfig from './components/AddConfig' import TestWords from './components/TestWords' -import Integrations from './components/Integrations' +import Profile from './components/Profile' +import { AuthProvider, useAuth } from './components/auth/AuthContext' +import AuthScreen from './components/auth/AuthScreen' // API endpoints (используем относительные пути, проксирование настроено в nginx/vite) const CURRENT_WEEK_API_URL = '/playlife-feed' const FULL_STATISTICS_API_URL = '/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b' -function App() { +function AppContent() { + const { authFetch, isAuthenticated, loading: authLoading } = useAuth() + + // Show loading while checking auth + if (authLoading) { + return ( +
+
Загрузка...
+
+ ) + } + + // Show auth screen if not authenticated + if (!isAuthenticated) { + return + } const [activeTab, setActiveTab] = useState('current') const [selectedProject, setSelectedProject] = useState(null) const [loadedTabs, setLoadedTabs] = useState({ @@ -25,7 +42,7 @@ function App() { 'test-config': false, 'add-config': false, test: false, - integrations: false, + profile: false, }) // Отслеживаем, какие табы уже были загружены (для предотвращения повторных загрузок) @@ -38,7 +55,7 @@ function App() { 'test-config': false, 'add-config': false, test: false, - integrations: false, + profile: false, }) // Параметры для навигации между вкладками @@ -77,7 +94,7 @@ function App() { try { const savedTab = window.localStorage?.getItem('activeTab') - const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test', 'integrations'] + const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test', 'profile'] if (savedTab && validTabs.includes(savedTab)) { setActiveTab(savedTab) setLoadedTabs(prev => ({ ...prev, [savedTab]: true })) @@ -104,7 +121,7 @@ function App() { } setCurrentWeekError(null) console.log('Fetching current week data from:', CURRENT_WEEK_API_URL) - const response = await fetch(CURRENT_WEEK_API_URL) + const response = await authFetch(CURRENT_WEEK_API_URL) if (!response.ok) { throw new Error('Ошибка загрузки данных') } @@ -149,7 +166,7 @@ function App() { setCurrentWeekLoading(false) } } - }, []) + }, [authFetch]) const fetchFullStatisticsData = useCallback(async (isBackground = false) => { try { @@ -159,7 +176,7 @@ function App() { setFullStatisticsLoading(true) } setFullStatisticsError(null) - const response = await fetch(FULL_STATISTICS_API_URL) + const response = await authFetch(FULL_STATISTICS_API_URL) if (!response.ok) { throw new Error('Ошибка загрузки данных') } @@ -175,7 +192,7 @@ function App() { setFullStatisticsLoading(false) } } - }, []) + }, [authFetch]) // Используем ref для отслеживания инициализации табов (чтобы избежать лишних пересозданий функции) const tabsInitializedRef = useRef({ @@ -187,7 +204,7 @@ function App() { 'test-config': false, 'add-config': false, test: false, - integrations: false, + profile: false, }) // Используем ref для отслеживания кеша (чтобы не зависеть от состояния в useCallback) @@ -476,9 +493,9 @@ function App() { )} - {loadedTabs.integrations && ( -
- + {loadedTabs.profile && ( +
+
)}
@@ -530,22 +547,21 @@ function App() { )} @@ -556,6 +572,14 @@ function App() { ) } +function App() { + return ( + + + + ) +} + export default App diff --git a/play-life-web/src/components/AddConfig.jsx b/play-life-web/src/components/AddConfig.jsx index bc08ad4..84a79ed 100644 --- a/play-life-web/src/components/AddConfig.jsx +++ b/play-life-web/src/components/AddConfig.jsx @@ -1,9 +1,11 @@ import React, { useState, useEffect } from 'react' +import { useAuth } from './auth/AuthContext' import './AddConfig.css' const API_URL = '/api' function AddConfig({ onNavigate, editingConfig: initialEditingConfig }) { + const { authFetch } = useAuth() const [name, setName] = useState('') const [tryMessage, setTryMessage] = useState('') const [wordsCount, setWordsCount] = useState('10') @@ -19,7 +21,7 @@ function AddConfig({ onNavigate, editingConfig: initialEditingConfig }) { const loadDictionaries = async () => { setLoadingDictionaries(true) try { - const response = await fetch(`${API_URL}/test-configs-and-dictionaries`) + const response = await authFetch(`${API_URL}/test-configs-and-dictionaries`) if (!response.ok) { throw new Error('Ошибка при загрузке словарей') } @@ -39,7 +41,7 @@ function AddConfig({ onNavigate, editingConfig: initialEditingConfig }) { const loadSelectedDictionaries = async () => { if (initialEditingConfig?.id) { try { - const response = await fetch(`${API_URL}/configs/${initialEditingConfig.id}/dictionaries`) + const response = await authFetch(`${API_URL}/configs/${initialEditingConfig.id}/dictionaries`) if (response.ok) { const data = await response.json() setSelectedDictionaryIds(Array.isArray(data.dictionary_ids) ? data.dictionary_ids : []) @@ -100,7 +102,7 @@ function AddConfig({ onNavigate, editingConfig: initialEditingConfig }) { : `${API_URL}/configs` const method = initialEditingConfig ? 'PUT' : 'POST' - const response = await fetch(url, { + const response = await authFetch(url, { method: method, headers: { 'Content-Type': 'application/json', diff --git a/play-life-web/src/components/AddWords.jsx b/play-life-web/src/components/AddWords.jsx index ae50346..c717e5e 100644 --- a/play-life-web/src/components/AddWords.jsx +++ b/play-life-web/src/components/AddWords.jsx @@ -1,9 +1,11 @@ import React, { useState } from 'react' +import { useAuth } from './auth/AuthContext' import './AddWords.css' const API_URL = '/api' function AddWords({ onNavigate, dictionaryId, dictionaryName }) { + const { authFetch } = useAuth() const [markdownText, setMarkdownText] = useState('') const [message, setMessage] = useState('') const [loading, setLoading] = useState(false) @@ -81,7 +83,7 @@ function AddWords({ onNavigate, dictionaryId, dictionaryName }) { dictionary_id: dictionaryId !== undefined && dictionaryId !== null ? dictionaryId : undefined })) - const response = await fetch(`${API_URL}/words`, { + const response = await authFetch(`${API_URL}/words`, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/play-life-web/src/components/Integrations.jsx b/play-life-web/src/components/Integrations.jsx deleted file mode 100644 index 9c1fd2f..0000000 --- a/play-life-web/src/components/Integrations.jsx +++ /dev/null @@ -1,57 +0,0 @@ -import React, { useState } from 'react' -import TodoistIntegration from './TodoistIntegration' -import TelegramIntegration from './TelegramIntegration' - -function Integrations({ onNavigate }) { - const [selectedIntegration, setSelectedIntegration] = useState(null) - - const integrations = [ - { id: 'todoist', name: 'TODOist' }, - { id: 'telegram', name: 'Telegram' }, - ] - - if (selectedIntegration) { - if (selectedIntegration === 'todoist') { - return setSelectedIntegration(null)} /> - } else if (selectedIntegration === 'telegram') { - return setSelectedIntegration(null)} /> - } - } - - return ( -
-

Интеграции

-
- {integrations.map((integration) => ( - - ))} -
-
- ) -} - -export default Integrations - diff --git a/play-life-web/src/components/Profile.jsx b/play-life-web/src/components/Profile.jsx new file mode 100644 index 0000000..49e8b58 --- /dev/null +++ b/play-life-web/src/components/Profile.jsx @@ -0,0 +1,124 @@ +import React, { useState } from 'react' +import { useAuth } from './auth/AuthContext' +import TodoistIntegration from './TodoistIntegration' +import TelegramIntegration from './TelegramIntegration' + +function Profile({ onNavigate }) { + const { user, logout } = useAuth() + const [selectedIntegration, setSelectedIntegration] = useState(null) + + const integrations = [ + { id: 'todoist', name: 'TODOist', icon: '✓' }, + { id: 'telegram', name: 'Telegram', icon: '✈️' }, + ] + + const handleLogout = async () => { + if (window.confirm('Вы уверены, что хотите выйти?')) { + await logout() + } + } + + if (selectedIntegration) { + if (selectedIntegration === 'todoist') { + return setSelectedIntegration(null)} /> + } else if (selectedIntegration === 'telegram') { + return setSelectedIntegration(null)} /> + } + } + + return ( +
+ {/* Profile Header */} +
+
+
+ {user?.name ? user.name.charAt(0).toUpperCase() : user?.email?.charAt(0).toUpperCase() || '?'} +
+
+

+ {user?.name || 'Пользователь'} +

+

+ {user?.email} +

+
+
+
+ + {/* Integrations Section */} +
+

Интеграции

+
+ {integrations.map((integration) => ( + + ))} +
+
+ + {/* Account Section */} +
+

Аккаунт

+ +
+ + {/* Version Info */} +
+

Play Life

+
+
+ ) +} + +export default Profile + diff --git a/play-life-web/src/components/ProjectPriorityManager.jsx b/play-life-web/src/components/ProjectPriorityManager.jsx index 9133d89..ab15348 100644 --- a/play-life-web/src/components/ProjectPriorityManager.jsx +++ b/play-life-web/src/components/ProjectPriorityManager.jsx @@ -19,6 +19,7 @@ import { } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { getAllProjectsSorted, getProjectColor } from '../utils/projectUtils' +import { useAuth } from './auth/AuthContext' // API endpoints (используем относительные пути, проксирование настроено в nginx/vite) const PROJECTS_API_URL = '/projects' @@ -46,7 +47,7 @@ function MoveProjectScreen({ project, allProjects, onClose, onSuccess }) { try { const projectId = project.id ?? project.name - const response = await fetch(PROJECT_MOVE_API_URL, { + const response = await authFetch(PROJECT_MOVE_API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -263,6 +264,7 @@ function PrioritySlot({ title, projects, allProjects, onMenuClick, maxItems = nu } function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad, onLoadingChange, onErrorChange, refreshTrigger, onNavigate }) { + const { authFetch } = useAuth() const [projectsLoading, setProjectsLoading] = useState(false) const [projectsError, setProjectsError] = useState(null) const [hasDataCache, setHasDataCache] = useState(false) // Отслеживаем наличие кеша @@ -381,7 +383,7 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad, } setProjectsError(null) - const response = await fetch(PROJECTS_API_URL) + const response = await authFetch(PROJECTS_API_URL) if (!response.ok) { throw new Error('Не удалось загрузить проекты') } @@ -483,7 +485,7 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad, const sendPriorityChanges = useCallback(async (changes) => { if (!changes.length) return try { - await fetch(PRIORITY_UPDATE_API_URL, { + await authFetch(PRIORITY_UPDATE_API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(changes), @@ -723,7 +725,7 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad, try { const projectId = selectedProject.id ?? selectedProject.name - const response = await fetch(`/project/delete`, { + const response = await authFetch(`/project/delete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: projectId }), diff --git a/play-life-web/src/components/TelegramIntegration.jsx b/play-life-web/src/components/TelegramIntegration.jsx index fd60cf8..fbccc5c 100644 --- a/play-life-web/src/components/TelegramIntegration.jsx +++ b/play-life-web/src/components/TelegramIntegration.jsx @@ -1,7 +1,9 @@ import React, { useState, useEffect } from 'react' +import { useAuth } from './auth/AuthContext' import './Integrations.css' function TelegramIntegration({ onBack }) { + const { authFetch } = useAuth() const [botToken, setBotToken] = useState('') const [chatId, setChatId] = useState('') const [loading, setLoading] = useState(true) @@ -16,7 +18,7 @@ function TelegramIntegration({ onBack }) { const fetchIntegration = async () => { try { setLoading(true) - const response = await fetch('/api/integrations/telegram') + const response = await authFetch('/api/integrations/telegram') if (!response.ok) { throw new Error('Ошибка при загрузке интеграции') } @@ -42,7 +44,7 @@ function TelegramIntegration({ onBack }) { setError('') setSuccess('') - const response = await fetch('/api/integrations/telegram', { + const response = await authFetch('/api/integrations/telegram', { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/play-life-web/src/components/TestConfigSelection.jsx b/play-life-web/src/components/TestConfigSelection.jsx index 6b0512e..91579a2 100644 --- a/play-life-web/src/components/TestConfigSelection.jsx +++ b/play-life-web/src/components/TestConfigSelection.jsx @@ -1,9 +1,11 @@ import React, { useState, useEffect, useRef } from 'react' +import { useAuth } from './auth/AuthContext' import './TestConfigSelection.css' const API_URL = '/api' function TestConfigSelection({ onNavigate, refreshTrigger = 0 }) { + const { authFetch } = useAuth() const [configs, setConfigs] = useState([]) const [dictionaries, setDictionaries] = useState([]) const [loading, setLoading] = useState(true) @@ -38,7 +40,7 @@ function TestConfigSelection({ onNavigate, refreshTrigger = 0 }) { setLoading(true) } - const response = await fetch(`${API_URL}/test-configs-and-dictionaries`) + const response = await authFetch(`${API_URL}/test-configs-and-dictionaries`) if (!response.ok) { throw new Error('Ошибка при загрузке конфигураций и словарей') } @@ -92,7 +94,7 @@ function TestConfigSelection({ onNavigate, refreshTrigger = 0 }) { if (!selectedDictionary) return try { - const response = await fetch(`${API_URL}/dictionaries/${selectedDictionary.id}`, { + const response = await authFetch(`${API_URL}/dictionaries/${selectedDictionary.id}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', @@ -119,7 +121,7 @@ function TestConfigSelection({ onNavigate, refreshTrigger = 0 }) { if (!selectedConfig) return try { - const response = await fetch(`${API_URL}/configs/${selectedConfig.id}`, { + const response = await authFetch(`${API_URL}/configs/${selectedConfig.id}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', diff --git a/play-life-web/src/components/TestWords.jsx b/play-life-web/src/components/TestWords.jsx index 775b796..3c5b96d 100644 --- a/play-life-web/src/components/TestWords.jsx +++ b/play-life-web/src/components/TestWords.jsx @@ -1,4 +1,5 @@ import React, { useState, useEffect, useRef } from 'react' +import { useAuth } from './auth/AuthContext' import './TestWords.css' const API_URL = '/api' @@ -6,6 +7,7 @@ const API_URL = '/api' const DEFAULT_TEST_WORD_COUNT = 10 function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialConfigId, maxCards: initialMaxCards }) { + const { authFetch } = useAuth() const wordCount = initialWordCount || DEFAULT_TEST_WORD_COUNT const configId = initialConfigId || null const maxCards = initialMaxCards || null @@ -49,7 +51,7 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC throw new Error('config_id обязателен для запуска теста') } const url = `${API_URL}/test/words?config_id=${configId}` - const response = await fetch(url) + const response = await authFetch(url) if (!response.ok) { throw new Error('Ошибка при загрузке слов') } @@ -176,7 +178,7 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC requestBody }) - const response = await fetch(`${API_URL}/test/progress`, { + const response = await authFetch(`${API_URL}/test/progress`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody), diff --git a/play-life-web/src/components/TodoistIntegration.jsx b/play-life-web/src/components/TodoistIntegration.jsx index f11b96f..5f7d39c 100644 --- a/play-life-web/src/components/TodoistIntegration.jsx +++ b/play-life-web/src/components/TodoistIntegration.jsx @@ -1,7 +1,9 @@ import React, { useState, useEffect } from 'react' +import { useAuth } from './auth/AuthContext' import './Integrations.css' function TodoistIntegration({ onBack }) { + const { authFetch } = useAuth() const [webhookURL, setWebhookURL] = useState('') const [loading, setLoading] = useState(true) const [copied, setCopied] = useState(false) @@ -13,7 +15,7 @@ function TodoistIntegration({ onBack }) { const fetchWebhookURL = async () => { try { setLoading(true) - const response = await fetch('/api/integrations/todoist/webhook-url') + const response = await authFetch('/api/integrations/todoist/webhook-url') if (!response.ok) { throw new Error('Ошибка при загрузке URL webhook') } diff --git a/play-life-web/src/components/WordList.jsx b/play-life-web/src/components/WordList.jsx index d965cbd..e4e284d 100644 --- a/play-life-web/src/components/WordList.jsx +++ b/play-life-web/src/components/WordList.jsx @@ -1,9 +1,11 @@ import React, { useState, useEffect } from 'react' +import { useAuth } from './auth/AuthContext' import './WordList.css' const API_URL = '/api' function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger = 0 }) { + const { authFetch } = useAuth() const [words, setWords] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState('') @@ -44,7 +46,7 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger = const fetchDictionary = async (dictId) => { try { - const response = await fetch(`${API_URL}/dictionaries`) + const response = await authFetch(`${API_URL}/dictionaries`) if (!response.ok) { throw new Error('Ошибка при загрузке словарей') } @@ -74,7 +76,7 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger = try { setLoading(true) const url = `${API_URL}/words?dictionary_id=${dictId}` - const response = await fetch(url) + const response = await authFetch(url) if (!response.ok) { throw new Error('Ошибка при загрузке слов') } @@ -102,7 +104,7 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger = try { if (!hasValidDictionary(currentDictionaryId)) { // Create new dictionary - const response = await fetch(`${API_URL}/dictionaries`, { + const response = await authFetch(`${API_URL}/dictionaries`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -131,7 +133,7 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger = onNavigate?.('words', { dictionaryId: newDictionaryId }) } else if (hasValidDictionary(currentDictionaryId)) { // Update existing dictionary (rename) - const response = await fetch(`${API_URL}/dictionaries/${currentDictionaryId}`, { + const response = await authFetch(`${API_URL}/dictionaries/${currentDictionaryId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', diff --git a/play-life-web/src/components/auth/AuthContext.jsx b/play-life-web/src/components/auth/AuthContext.jsx new file mode 100644 index 0000000..cc5d99e --- /dev/null +++ b/play-life-web/src/components/auth/AuthContext.jsx @@ -0,0 +1,230 @@ +import React, { createContext, useContext, useState, useEffect, useCallback } from 'react' + +const AuthContext = createContext(null) + +const TOKEN_KEY = 'access_token' +const REFRESH_TOKEN_KEY = 'refresh_token' +const USER_KEY = 'user' + +export function AuthProvider({ children }) { + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // Initialize from localStorage + useEffect(() => { + const initAuth = async () => { + const token = localStorage.getItem(TOKEN_KEY) + const savedUser = localStorage.getItem(USER_KEY) + + if (token && savedUser) { + try { + setUser(JSON.parse(savedUser)) + // Verify token is still valid + const response = await fetch('/api/auth/me', { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }) + + if (response.ok) { + const data = await response.json() + setUser(data.user) + localStorage.setItem(USER_KEY, JSON.stringify(data.user)) + } else if (response.status === 401) { + // Try to refresh token + const refreshed = await refreshToken() + if (!refreshed) { + logout() + } + } + } catch (err) { + console.error('Auth init error:', err) + logout() + } + } + setLoading(false) + } + + initAuth() + }, []) + + const login = useCallback(async (email, password) => { + setError(null) + try { + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ email, password }) + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Ошибка входа') + } + + localStorage.setItem(TOKEN_KEY, data.access_token) + localStorage.setItem(REFRESH_TOKEN_KEY, data.refresh_token) + localStorage.setItem(USER_KEY, JSON.stringify(data.user)) + setUser(data.user) + + return true + } catch (err) { + setError(err.message) + return false + } + }, []) + + const register = useCallback(async (email, password, name) => { + setError(null) + try { + const response = await fetch('/api/auth/register', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ email, password, name: name || undefined }) + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Ошибка регистрации') + } + + localStorage.setItem(TOKEN_KEY, data.access_token) + localStorage.setItem(REFRESH_TOKEN_KEY, data.refresh_token) + localStorage.setItem(USER_KEY, JSON.stringify(data.user)) + setUser(data.user) + + return true + } catch (err) { + setError(err.message) + return false + } + }, []) + + const logout = useCallback(async () => { + const token = localStorage.getItem(TOKEN_KEY) + + if (token) { + try { + await fetch('/api/auth/logout', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }) + } catch (err) { + console.error('Logout error:', err) + } + } + + localStorage.removeItem(TOKEN_KEY) + localStorage.removeItem(REFRESH_TOKEN_KEY) + localStorage.removeItem(USER_KEY) + setUser(null) + }, []) + + const refreshToken = useCallback(async () => { + const refresh = localStorage.getItem(REFRESH_TOKEN_KEY) + + if (!refresh) { + return false + } + + try { + const response = await fetch('/api/auth/refresh', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ refresh_token: refresh }) + }) + + if (!response.ok) { + return false + } + + const data = await response.json() + + localStorage.setItem(TOKEN_KEY, data.access_token) + localStorage.setItem(REFRESH_TOKEN_KEY, data.refresh_token) + localStorage.setItem(USER_KEY, JSON.stringify(data.user)) + setUser(data.user) + + return true + } catch (err) { + console.error('Refresh token error:', err) + return false + } + }, []) + + const getToken = useCallback(() => { + return localStorage.getItem(TOKEN_KEY) + }, []) + + // Fetch wrapper that handles auth + const authFetch = useCallback(async (url, options = {}) => { + const token = localStorage.getItem(TOKEN_KEY) + + const headers = { + 'Content-Type': 'application/json', + ...options.headers + } + + if (token) { + headers['Authorization'] = `Bearer ${token}` + } + + let response = await fetch(url, { ...options, headers }) + + // If 401, try to refresh token and retry + if (response.status === 401) { + const refreshed = await refreshToken() + if (refreshed) { + const newToken = localStorage.getItem(TOKEN_KEY) + headers['Authorization'] = `Bearer ${newToken}` + response = await fetch(url, { ...options, headers }) + } else { + logout() + } + } + + return response + }, [refreshToken, logout]) + + const value = { + user, + loading, + error, + login, + register, + logout, + getToken, + authFetch, + isAuthenticated: !!user + } + + return ( + + {children} + + ) +} + +export function useAuth() { + const context = useContext(AuthContext) + if (!context) { + throw new Error('useAuth must be used within an AuthProvider') + } + return context +} + +export default AuthContext + diff --git a/play-life-web/src/components/auth/AuthScreen.jsx b/play-life-web/src/components/auth/AuthScreen.jsx new file mode 100644 index 0000000..c591cef --- /dev/null +++ b/play-life-web/src/components/auth/AuthScreen.jsx @@ -0,0 +1,16 @@ +import React, { useState } from 'react' +import LoginForm from './LoginForm' +import RegisterForm from './RegisterForm' + +function AuthScreen() { + const [mode, setMode] = useState('login') // 'login' or 'register' + + if (mode === 'register') { + return setMode('login')} /> + } + + return setMode('register')} /> +} + +export default AuthScreen + diff --git a/play-life-web/src/components/auth/LoginForm.jsx b/play-life-web/src/components/auth/LoginForm.jsx new file mode 100644 index 0000000..5c7dae4 --- /dev/null +++ b/play-life-web/src/components/auth/LoginForm.jsx @@ -0,0 +1,112 @@ +import React, { useState } from 'react' +import { useAuth } from './AuthContext' + +function LoginForm({ onSwitchToRegister }) { + const { login, error } = useAuth() + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [loading, setLoading] = useState(false) + const [localError, setLocalError] = useState('') + + const handleSubmit = async (e) => { + e.preventDefault() + setLocalError('') + + if (!email.trim()) { + setLocalError('Введите email') + return + } + if (!password) { + setLocalError('Введите пароль') + return + } + + setLoading(true) + const success = await login(email, password) + setLoading(false) + + if (!success) { + setLocalError(error || 'Ошибка входа') + } + } + + return ( +
+
+
+
+

Play Life

+

Войдите в свой аккаунт

+
+ +
+
+ + setEmail(e.target.value)} + className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition" + placeholder="your@email.com" + autoComplete="email" + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition" + placeholder="••••••••" + autoComplete="current-password" + /> +
+ + {(localError || error) && ( +
+ {localError || error} +
+ )} + + +
+ +
+

+ Нет аккаунта?{' '} + +

+
+
+
+
+ ) +} + +export default LoginForm + diff --git a/play-life-web/src/components/auth/RegisterForm.jsx b/play-life-web/src/components/auth/RegisterForm.jsx new file mode 100644 index 0000000..803fa38 --- /dev/null +++ b/play-life-web/src/components/auth/RegisterForm.jsx @@ -0,0 +1,150 @@ +import React, { useState } from 'react' +import { useAuth } from './AuthContext' + +function RegisterForm({ onSwitchToLogin }) { + const { register, error } = useAuth() + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [name, setName] = useState('') + const [loading, setLoading] = useState(false) + const [localError, setLocalError] = useState('') + + const handleSubmit = async (e) => { + e.preventDefault() + setLocalError('') + + if (!email.trim()) { + setLocalError('Введите email') + return + } + if (!password) { + setLocalError('Введите пароль') + return + } + if (password.length < 6) { + setLocalError('Пароль должен быть не менее 6 символов') + return + } + if (password !== confirmPassword) { + setLocalError('Пароли не совпадают') + return + } + + setLoading(true) + const success = await register(email, password, name || undefined) + setLoading(false) + + if (!success) { + setLocalError(error || 'Ошибка регистрации') + } + } + + return ( +
+
+
+
+

Play Life

+

Создайте аккаунт

+
+ +
+
+ + setName(e.target.value)} + className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition" + placeholder="Ваше имя" + autoComplete="name" + /> +
+ +
+ + setEmail(e.target.value)} + className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition" + placeholder="your@email.com" + autoComplete="email" + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition" + placeholder="Минимум 6 символов" + autoComplete="new-password" + /> +
+ +
+ + setConfirmPassword(e.target.value)} + className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition" + placeholder="Повторите пароль" + autoComplete="new-password" + /> +
+ + {(localError || error) && ( +
+ {localError || error} +
+ )} + + +
+ +
+

+ Уже есть аккаунт?{' '} + +

+
+
+
+
+ ) +} + +export default RegisterForm + diff --git a/restore-db.sh b/restore-db.sh index fb374b8..d4487ff 100755 --- a/restore-db.sh +++ b/restore-db.sh @@ -100,15 +100,24 @@ echo " База: $DB_NAME" echo " Хост: $DB_HOST:$DB_PORT" echo " Файл: $FULL_DUMP_PATH" -# Распаковываем, если сжат +# Распаковываем и модифицируем дамп TEMP_DUMP="/tmp/restore_$$.sql" if [[ "$FULL_DUMP_PATH" == *.gz ]]; then - echo " Распаковка дампа..." - gunzip -c "$FULL_DUMP_PATH" > "$TEMP_DUMP" + echo " Распаковка и модификация дампа..." + gunzip -c "$FULL_DUMP_PATH" | \ + sed 's/n8n_user/'"$DB_USER"'/g' | \ + sed '/^\\restrict/d' | \ + sed '/^\\unrestrict/d' > "$TEMP_DUMP" else - cp "$FULL_DUMP_PATH" "$TEMP_DUMP" + echo " Модификация дампа..." + cat "$FULL_DUMP_PATH" | \ + sed 's/n8n_user/'"$DB_USER"'/g' | \ + sed '/^\\restrict/d' | \ + sed '/^\\unrestrict/d' > "$TEMP_DUMP" fi +echo " Владелец таблиц в дампе заменён на: $DB_USER" + # Восстанавливаем через docker-compose, если контейнер запущен if docker-compose ps db 2>/dev/null | grep -q "Up"; then echo " Используется docker-compose..." @@ -132,5 +141,33 @@ fi # Удаляем временный файл rm -f "$TEMP_DUMP" -echo "✅ База данных успешно восстановлена из дампа!" +echo "" +echo "📦 Применение миграций для добавления user_id..." + +# Определяем путь к миграциям +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MIGRATIONS_DIR="$SCRIPT_DIR/play-life-backend/migrations" + +if [ -d "$MIGRATIONS_DIR" ]; then + # Применяем миграцию 009 для добавления user_id + MIGRATION_FILE="$MIGRATIONS_DIR/009_add_users_and_multitenancy.sql" + if [ -f "$MIGRATION_FILE" ]; then + echo " Применяем миграцию: 009_add_users_and_multitenancy.sql" + if docker-compose ps db 2>/dev/null | grep -q "Up"; then + docker-compose exec -T db psql -U "$DB_USER" -d "$DB_NAME" < "$MIGRATION_FILE" 2>/dev/null || true + elif command -v psql &> /dev/null; then + PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" < "$MIGRATION_FILE" 2>/dev/null || true + fi + echo " ✅ Миграция применена" + fi +else + echo " ⚠️ Директория миграций не найдена: $MIGRATIONS_DIR" + echo " Миграции будут применены при запуске бэкенда" +fi + +echo "" +echo "✅ База данных успешно восстановлена из дампа!" +echo "" +echo "📌 ВАЖНО: После первой регистрации/входа пользователя все данные" +echo " будут автоматически привязаны к этому пользователю."