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 ( +
+ {user?.email} +
+Play Life
+Войдите в свой аккаунт
++ Нет аккаунта?{' '} + +
+Создайте аккаунт
++ Уже есть аккаунт?{' '} + +
+