package main import ( "context" "flag" "fmt" "log/slog" "os" "github.com/iudanet/gophkeeper/internal/client/api" "github.com/iudanet/gophkeeper/internal/client/auth" "github.com/iudanet/gophkeeper/internal/client/cli" "github.com/iudanet/gophkeeper/internal/client/data" "github.com/iudanet/gophkeeper/internal/client/iocli" "github.com/iudanet/gophkeeper/internal/client/storage/boltdb" "github.com/iudanet/gophkeeper/internal/client/sync" ) var ( // Version information set via ldflags during build Version = "dev" BuildDate = "unknown" GitCommit = "unknown" ) func main() { // Глобальные флаги showVersion := flag.Bool("version", false, "Show version information") serverURL := flag.String("server", "http://localhost:8080", "Server URL") dbPath := flag.String("db", "gophkeeper-client.db", "Path to local database") masterPassword := flag.String("master-password", "", "Master password (use with caution, prefer env var or file)") masterPasswordFile := flag.String("master-password-file", "", "Path to file containing master password") // TLS flags tlsCA := flag.String("tls-ca", "", "Path to CA certificate for validating self-signed server certificate") insecure := flag.Bool("insecure", false, "Skip TLS certificate verification (development only)") flag.Parse() pass := cli.Passwords{ FromFile: *masterPasswordFile, FromArgs: *masterPassword, } // Show version and exit if requested if *showVersion { printVersion() os.Exit(0) } // Получаем команду args := flag.Args() if len(args) == 0 { cli.PrintUsage() os.Exit(1) } // Создаем контекст ctx := context.Background() // Открываем BoltDB storage boltStorage, err := boltdb.New(ctx, *dbPath) if err != nil { fmt.Fprintf(os.Stderr, "Failed to open database: %v\n", err) os.Exit(1) } defer func() { if err := boltStorage.Close(); err != nil { slog.Error("failed to close database", "error", err) } }() // Создаем logger logger := slog.Default() // Создаем API клиент с TLS настройками apiClient := api.NewClientWithOptions(api.ClientOptions{ BaseURL: *serverURL, CACertPath: *tlsCA, Insecure: *insecure, }) // Создаем сервисы authService := auth.NewAuthService(apiClient, boltStorage) dataService := data.NewService(boltStorage) syncService := sync.NewService(apiClient, boltStorage, boltStorage, logger) // Создаем CLI с сервисами (без прямого доступа к storage) stdio := iocli.NewStdio() commands := cli.New(apiClient, authService, dataService, syncService, stdio, &pass) command := args[0] if command != "login" && command != "register" { errPass := commands.ReadMasterPassword(ctx) if errPass != nil { fmt.Fprintf(os.Stderr, "Failed to read master password: %v\n", errPass) os.Exit(1) } } commands.Run(ctx, args) } func printVersion() { fmt.Printf("GophKeeper Client\n") fmt.Printf("Version: %s\n", Version) fmt.Printf("Build Date: %s\n", BuildDate) fmt.Printf("Git Commit: %s\n", GitCommit) }
package main import ( "context" "crypto/rand" "crypto/tls" "encoding/base64" "flag" "fmt" "log/slog" "net/http" "os" "os/signal" "syscall" "time" "github.com/iudanet/gophkeeper/internal/server/handlers" "github.com/iudanet/gophkeeper/internal/server/middleware" "github.com/iudanet/gophkeeper/internal/server/storage/sqlite" ) var ( // Version information set via ldflags during build Version = "dev" BuildDate = "unknown" GitCommit = "unknown" ) func main() { // Parse flags showVersion := flag.Bool("version", false, "Show version information") port := flag.Int("port", 8080, "Server port") logLevel := flag.String("log-level", "info", "Log level (debug, info, warn, error)") dbPath := flag.String("db", "gophkeeper.db", "Path to SQLite database file") jwtSecret := flag.String("jwt-secret", "", "JWT secret (auto-generated if empty)") // TLS flags tlsCert := flag.String("tls-cert", "", "Path to TLS certificate file") tlsKey := flag.String("tls-key", "", "Path to TLS private key file") insecure := flag.Bool("insecure", false, "Run server without TLS (development only)") flag.Parse() // Show version and exit if requested if *showVersion { printVersion() os.Exit(0) } // Инициализация logger logger := initLogger(*logLevel) logger.Info("Starting GophKeeper Server", slog.String("version", Version), slog.String("build_date", BuildDate), slog.String("git_commit", GitCommit), slog.Int("port", *port), slog.String("db_path", *dbPath), ) // Генерируем JWT secret если не указан secret := *jwtSecret if secret == "" { logger.Warn("JWT secret not provided, generating random secret") secret = generateRandomSecret() logger.Info("Generated JWT secret (save this for production)", slog.String("secret", secret)) } // Инициализация storage ctx := context.Background() // TODO возвращать интерфейс storage, err := sqlite.New(ctx, *dbPath) if err != nil { logger.Error("Failed to initialize storage", slog.Any("error", err)) os.Exit(1) } defer func() { if err := storage.Close(); err != nil { logger.Error("Failed to close storage", slog.Any("error", err)) } }() logger.Info("Storage initialized successfully") // Создание JWT конфигурации // Access token: 15 минут, Refresh token: 30 дней jwtConfig := handlers.JWTConfig{ Secret: []byte(secret), AccessTokenTTL: 15 * time.Minute, RefreshTokenTTL: 30 * 24 * time.Hour, } logger.Info("JWT configuration initialized") // Создание handlers authHandler := handlers.NewAuthHandler(logger, storage, storage, jwtConfig) healthHandler := handlers.NewHealthHandler(logger) syncHandler := handlers.NewSyncHandler(logger, storage) // Настройка роутинга с использованием net/http.ServeMux (Go 1.22+) mux := http.NewServeMux() // Создаем rate limiters для критичных endpoints // Для login, register, getSalt - более строгие лимиты (защита от brute-force) authRateLimit := middleware.RateLimitMiddleware(10, 1*time.Minute, logger) // 10 запросов в минуту // Auth endpoints (с rate limiting для защиты от brute-force) mux.Handle("POST /api/v1/auth/register", authRateLimit(http.HandlerFunc(authHandler.Register))) mux.Handle("GET /api/v1/auth/salt/{username}", authRateLimit(http.HandlerFunc(authHandler.GetSalt))) mux.Handle("POST /api/v1/auth/login", authRateLimit(http.HandlerFunc(authHandler.Login))) mux.HandleFunc("POST /api/v1/auth/refresh", authHandler.Refresh) mux.HandleFunc("POST /api/v1/auth/logout", authHandler.Logout) // Health check (без rate limiting) mux.HandleFunc("GET /api/v1/health", healthHandler.Health) // Sync endpoints (защищены AuthMiddleware, без rate limiting для синхронизации) authMiddleware := middleware.AuthMiddleware(logger, jwtConfig) mux.Handle("GET /api/v1/sync", authMiddleware(http.HandlerFunc(syncHandler.HandleSync))) mux.Handle("POST /api/v1/sync", authMiddleware(http.HandlerFunc(syncHandler.HandleSync))) // Применяем глобальные middleware (порядок важен!) // 1. Recovery - перехватывает паники (самый верхний уровень) // 2. Logging - логирует все запросы var handler http.Handler = mux handler = middleware.LoggingMiddleware(logger)(handler) handler = middleware.RecoveryMiddleware(logger)(handler) logger.Info("Middleware configured", slog.String("recovery", "enabled"), slog.String("logging", "enabled"), slog.String("rate_limit_auth", "10 req/min"), ) // Проверка TLS конфигурации useTLS := !*insecure if useTLS { if *tlsCert == "" || *tlsKey == "" { logger.Error("TLS certificate and key are required when not using --insecure flag") logger.Info("Use --insecure flag for development without TLS, or provide --tls-cert and --tls-key") os.Exit(1) } // Проверяем существование файлов if _, err := os.Stat(*tlsCert); os.IsNotExist(err) { logger.Error("TLS certificate file not found", slog.String("path", *tlsCert)) os.Exit(1) } if _, err := os.Stat(*tlsKey); os.IsNotExist(err) { logger.Error("TLS key file not found", slog.String("path", *tlsKey)) os.Exit(1) } logger.Info("TLS enabled", slog.String("cert", *tlsCert), slog.String("key", *tlsKey), ) } else { logger.Warn("Running in INSECURE mode without TLS - suitable for development only!") } // Создание HTTP сервера addr := fmt.Sprintf(":%d", *port) server := &http.Server{ Addr: addr, Handler: handler, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second, } // Настройка TLS если включен if useTLS { // Загружаем сертификат и ключ cert, err := tls.LoadX509KeyPair(*tlsCert, *tlsKey) if err != nil { logger.Error("Failed to load TLS certificate", slog.Any("error", err)) os.Exit(1) } server.TLSConfig = &tls.Config{ Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12, CipherSuites: []uint16{ tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, }, } } // Запуск сервера в отдельной горутине serverErrors := make(chan error, 1) go func() { protocol := "http" if useTLS { protocol = "https" } logger.Info("Server listening", slog.String("address", addr), slog.String("protocol", protocol), ) if useTLS { // Для TLS используем пустые строки, так как сертификат уже загружен в TLSConfig serverErrors <- server.ListenAndServeTLS("", "") } else { serverErrors <- server.ListenAndServe() } }() // Graceful shutdown shutdown := make(chan os.Signal, 1) signal.Notify(shutdown, os.Interrupt, syscall.SIGTERM) select { case err := <-serverErrors: logger.Error("Server failed to start", slog.Any("error", err)) os.Exit(1) case sig := <-shutdown: logger.Info("Server shutdown initiated", slog.String("signal", sig.String())) // Создаем контекст с таймаутом для graceful shutdown ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if err := server.Shutdown(ctx); err != nil { logger.Error("Server shutdown failed", slog.Any("error", err)) if err := server.Close(); err != nil { logger.Error("Server close failed", slog.Any("error", err)) } os.Exit(1) } logger.Info("Server stopped gracefully") } } // initLogger инициализирует структурированный logger func initLogger(level string) *slog.Logger { var logLevel slog.Level switch level { case "debug": logLevel = slog.LevelDebug case "info": logLevel = slog.LevelInfo case "warn": logLevel = slog.LevelWarn case "error": logLevel = slog.LevelError default: logLevel = slog.LevelInfo } opts := &slog.HandlerOptions{ Level: logLevel, } // Используем JSON handler для production handler := slog.NewJSONHandler(os.Stdout, opts) logger := slog.New(handler) slog.SetDefault(logger) return logger } func printVersion() { fmt.Printf("GophKeeper Server\n") fmt.Printf("Version: %s\n", Version) fmt.Printf("Build Date: %s\n", BuildDate) fmt.Printf("Git Commit: %s\n", GitCommit) } // generateRandomSecret генерирует случайный секрет для JWT func generateRandomSecret() string { bytes := make([]byte, 32) if _, err := rand.Read(bytes); err != nil { panic(fmt.Sprintf("failed to generate random secret: %v", err)) } return base64.StdEncoding.EncodeToString(bytes) }
package api import ( "bytes" "context" "crypto/tls" "crypto/x509" "encoding/json" "fmt" "io" "net/http" "os" "time" "github.com/iudanet/gophkeeper/pkg/api" ) //go:generate moq -out apiclient_mock.go . ClientAPI var _ ClientAPI = (*Client)(nil) // ClientAPI определяет интерфейс методов HTTP клиента для взаимодействия с сервером type ClientAPI interface { // Register регистрирует нового пользователя Register(ctx context.Context, req api.RegisterRequest) (*api.RegisterResponse, error) // GetSalt получает public salt пользователя по username GetSalt(ctx context.Context, username string) (*api.SaltResponse, error) // Login выполняет аутентификацию пользователя Login(ctx context.Context, req api.LoginRequest) (*api.TokenResponse, error) // Refresh обновляет access token используя refresh token Refresh(ctx context.Context, refreshToken string) (*api.TokenResponse, error) // Logout выполняет выход из системы Logout(ctx context.Context, accessToken string) error // Sync выполняет синхронизацию данных с сервером Sync(ctx context.Context, accessToken string, req api.SyncRequest) (*api.SyncResponse, error) } // Client представляет HTTP клиент для взаимодействия с сервером type Client struct { httpClient *http.Client baseURL string } // ClientOptions опции для создания API клиента type ClientOptions struct { BaseURL string CACertPath string // Путь к CA сертификату для проверки самоподписанного сертификата сервера Insecure bool // Пропустить проверку TLS сертификата (только для разработки!) } // NewClient создает новый API клиент с настройками по умолчанию func NewClient(baseURL string) ClientAPI { return NewClientWithOptions(ClientOptions{ BaseURL: baseURL, Insecure: false, }) } // NewClientWithOptions создает новый API клиент с кастомными опциями TLS func NewClientWithOptions(opts ClientOptions) *Client { // Создаем базовый HTTP transport transport := &http.Transport{ TLSClientConfig: &tls.Config{ MinVersion: tls.VersionTLS12, }, } // Если включен insecure режим - отключаем валидацию сертификатов if opts.Insecure { transport.TLSClientConfig.InsecureSkipVerify = true // #nosec G402 - опция для dev окружения } // Если указан CA сертификат - загружаем его для валидации самоподписанных сертификатов if opts.CACertPath != "" && !opts.Insecure { caCert, err := os.ReadFile(opts.CACertPath) if err != nil { // Логируем ошибку, но не падаем - будем использовать системные CA fmt.Fprintf(os.Stderr, "Warning: failed to load CA certificate from %s: %v\n", opts.CACertPath, err) fmt.Fprintf(os.Stderr, "Falling back to system CA certificates\n") } else { caCertPool := x509.NewCertPool() if !caCertPool.AppendCertsFromPEM(caCert) { fmt.Fprintf(os.Stderr, "Warning: failed to parse CA certificate from %s\n", opts.CACertPath) fmt.Fprintf(os.Stderr, "Falling back to system CA certificates\n") } else { // Успешно загрузили CA сертификат transport.TLSClientConfig.RootCAs = caCertPool } } } // Если CA не указан и не insecure - будет использоваться системный CA pool (по умолчанию) return &Client{ baseURL: opts.BaseURL, httpClient: &http.Client{ Timeout: 30 * time.Second, Transport: transport, // Настройка обработки редиректов CheckRedirect: func(req *http.Request, via []*http.Request) error { // Ограничиваем количество редиректов if len(via) >= 10 { return fmt.Errorf("stopped after 10 redirects") } // Копируем заголовки Authorization при редиректе if len(via) > 0 && via[0].Header.Get("Authorization") != "" { req.Header.Set("Authorization", via[0].Header.Get("Authorization")) } return nil }, }, } } // Register регистрирует нового пользователя func (c *Client) Register(ctx context.Context, req api.RegisterRequest) (*api.RegisterResponse, error) { var resp api.RegisterResponse err := c.doRequest(ctx, "POST", "/api/v1/auth/register", req, &resp) if err != nil { return nil, fmt.Errorf("register request failed: %w", err) } return &resp, nil } // GetSalt получает public_salt пользователя func (c *Client) GetSalt(ctx context.Context, username string) (*api.SaltResponse, error) { var resp api.SaltResponse url := fmt.Sprintf("/api/v1/auth/salt/%s", username) err := c.doRequest(ctx, "GET", url, nil, &resp) if err != nil { return nil, fmt.Errorf("get salt request failed: %w", err) } return &resp, nil } // Login выполняет аутентификацию пользователя func (c *Client) Login(ctx context.Context, req api.LoginRequest) (*api.TokenResponse, error) { var resp api.TokenResponse err := c.doRequest(ctx, "POST", "/api/v1/auth/login", req, &resp) if err != nil { return nil, fmt.Errorf("login request failed: %w", err) } return &resp, nil } // Refresh обновляет access token используя refresh token func (c *Client) Refresh(ctx context.Context, refreshToken string) (*api.TokenResponse, error) { var resp api.TokenResponse err := c.doAuthRequest(ctx, "POST", "/api/v1/auth/refresh", refreshToken, nil, &resp) if err != nil { return nil, fmt.Errorf("refresh token request failed: %w", err) } return &resp, nil } // Logout выполняет выход из системы func (c *Client) Logout(ctx context.Context, accessToken string) error { return c.doAuthRequest(ctx, "POST", "/api/v1/auth/logout", accessToken, nil, nil) } // Sync выполняет синхронизацию данных с сервером func (c *Client) Sync(ctx context.Context, accessToken string, req api.SyncRequest) (*api.SyncResponse, error) { var resp api.SyncResponse err := c.doAuthRequest(ctx, "POST", "/api/v1/sync", accessToken, req, &resp) if err != nil { return nil, fmt.Errorf("sync request failed: %w", err) } return &resp, nil } // doAuthRequest выполняет HTTP запрос с авторизацией func (c *Client) doAuthRequest(ctx context.Context, method, path, token string, body, result interface{}) error { url := c.baseURL + path var bodyReader io.Reader if body != nil { jsonData, err := json.Marshal(body) if err != nil { return fmt.Errorf("failed to marshal request body: %w", err) } bodyReader = bytes.NewReader(jsonData) } req, err := http.NewRequestWithContext(ctx, method, url, bodyReader) if err != nil { return fmt.Errorf("failed to create request: %w", err) } // Добавляем Authorization header req.Header.Set("Authorization", "Bearer "+token) if body != nil { req.Header.Set("Content-Type", "application/json") } resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("request failed: %w", err) } defer func() { _ = resp.Body.Close() }() // Читаем тело ответа respBody, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("failed to read response body: %w", err) } // Проверяем статус код if resp.StatusCode < 200 || resp.StatusCode >= 300 { var errResp api.ErrorResponse if err := json.Unmarshal(respBody, &errResp); err == nil { return fmt.Errorf("server error (%d): %s", resp.StatusCode, errResp.Message) } return fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(respBody)) } // Декодируем успешный ответ if result != nil { if err := json.Unmarshal(respBody, result); err != nil { return fmt.Errorf("failed to decode response: %w", err) } } return nil } // doRequest выполняет HTTP запрос func (c *Client) doRequest(ctx context.Context, method, path string, body, result interface{}) error { url := c.baseURL + path var bodyReader io.Reader if body != nil { jsonData, err := json.Marshal(body) if err != nil { return fmt.Errorf("failed to marshal request body: %w", err) } bodyReader = bytes.NewReader(jsonData) } req, err := http.NewRequestWithContext(ctx, method, url, bodyReader) if err != nil { return fmt.Errorf("failed to create request: %w", err) } if body != nil { req.Header.Set("Content-Type", "application/json") } resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("request failed: %w", err) } defer func() { _ = resp.Body.Close() }() // Читаем тело ответа respBody, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("failed to read response body: %w", err) } // Проверяем статус код if resp.StatusCode < 200 || resp.StatusCode >= 300 { var errResp api.ErrorResponse if err := json.Unmarshal(respBody, &errResp); err == nil { return fmt.Errorf("server error (%d): %s", resp.StatusCode, errResp.Message) } return fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(respBody)) } // Декодируем успешный ответ if result != nil { if err := json.Unmarshal(respBody, result); err != nil { return fmt.Errorf("failed to decode response: %w", err) } } return nil }
package auth import ( "context" "encoding/base64" "fmt" "log/slog" "time" "github.com/google/uuid" "github.com/iudanet/gophkeeper/internal/client/storage" "github.com/iudanet/gophkeeper/internal/crypto" "github.com/iudanet/gophkeeper/internal/validation" pkgapi "github.com/iudanet/gophkeeper/pkg/api" ) //go:generate moq -out apiclient_mock.go . APIClient // APIClient определяет интерфейс для HTTP коммуникации с сервером type APIClient interface { Register(ctx context.Context, req pkgapi.RegisterRequest) (*pkgapi.RegisterResponse, error) GetSalt(ctx context.Context, username string) (*pkgapi.SaltResponse, error) Login(ctx context.Context, req pkgapi.LoginRequest) (*pkgapi.TokenResponse, error) Refresh(ctx context.Context, refreshToken string) (*pkgapi.TokenResponse, error) Logout(ctx context.Context, accessToken string) error } // AuthService предоставляет функции авторизации и управления сессией // Ключ шифрования устанавливается через SetEncryptionKey после успешного Login/Register type authService struct { apiClient APIClient storage storage.AuthStorage encryptionKey []byte // опциональный ключ шифрования (устанавливается после login) } // Compile-time check that authService implements Service var _ Service = (*authService)(nil) // NewAuthService создает новый сервис авторизации func NewAuthService(apiClient APIClient, storage storage.AuthStorage) Service { return &authService{ apiClient: apiClient, storage: storage, } } // SetEncryptionKey устанавливает ключ шифрования для работы с хранилищем // Должен быть вызван после успешного Login/Register func (s *authService) SetEncryptionKey(key []byte) { s.encryptionKey = key } // RegisterResult содержит результат регистрации type RegisterResult struct { UserID string // UUID пользователя Username string // username NodeID string // уникальный ID клиента/устройства для CRDT PublicSalt string // public salt (base64) EncryptionKey []byte // ключ шифрования (НЕ сохраняется!) } // Register регистрирует нового пользователя // Возвращает результат с ключом шифрования для использования func (s *authService) Register(ctx context.Context, username, masterPassword string) (*RegisterResult, error) { // Валидация входных данных if err := validation.ValidateUsername(username); err != nil { return nil, fmt.Errorf("invalid username: %w", err) } if err := validation.ValidatePassword(masterPassword); err != nil { return nil, fmt.Errorf("invalid password: %w", err) } // 1. Генерируем публичную соль publicSaltBase64, err := crypto.GenerateSaltBase64() if err != nil { return nil, fmt.Errorf("failed to generate salt: %w", err) } // 2. Деривируем ключи из master password keys, err := crypto.DeriveKeysFromBase64Salt(masterPassword, username, publicSaltBase64) if err != nil { return nil, fmt.Errorf("failed to derive keys: %w", err) } // 3. Хешируем auth_key для отправки на сервер authKeyHash, err := crypto.HashAuthKey(keys.AuthKey) if err != nil { return nil, fmt.Errorf("failed to hash auth key: %w", err) } // 4. Отправляем запрос на регистрацию req := pkgapi.RegisterRequest{ Username: username, AuthKeyHash: authKeyHash, PublicSalt: publicSaltBase64, } resp, err := s.apiClient.Register(ctx, req) if err != nil { return nil, fmt.Errorf("registration failed: %w", err) } // 5. Генерируем уникальный NodeID для этого клиента nodeID := uuid.New().String() // 6. Возвращаем результат return &RegisterResult{ UserID: resp.UserID, Username: username, NodeID: nodeID, PublicSalt: publicSaltBase64, EncryptionKey: keys.EncryptionKey, }, nil } // LoginResult содержит результат авторизации type LoginResult struct { UserID string // User UUID from server AccessToken string RefreshToken string Username string NodeID string // уникальный ID клиента/устройства для CRDT PublicSalt string EncryptionKey []byte ExpiresIn int64 } // Login выполняет аутентификацию пользователя // Возвращает результат с токенами и ключом шифрования func (s *authService) Login(ctx context.Context, username, masterPassword string) (*LoginResult, error) { // Валидация username if err := validation.ValidateUsername(username); err != nil { return nil, fmt.Errorf("invalid username: %w", err) } if err := validation.ValidatePassword(masterPassword); err != nil { return nil, fmt.Errorf("invalid password: %w", err) } // 1. Получаем public_salt с сервера saltResp, err := s.apiClient.GetSalt(ctx, username) if err != nil { return nil, fmt.Errorf("failed to get salt: %w", err) } // 2. Деривируем ключи из master password keys, err := crypto.DeriveKeysFromBase64Salt(masterPassword, username, saltResp.PublicSalt) if err != nil { return nil, fmt.Errorf("failed to derive keys: %w", err) } // 3. Хешируем auth_key authKeyHash, err := crypto.HashAuthKey(keys.AuthKey) if err != nil { return nil, fmt.Errorf("failed to hash auth key: %w", err) } // 4. Отправляем запрос на логин req := pkgapi.LoginRequest{ Username: username, AuthKeyHash: authKeyHash, } resp, err := s.apiClient.Login(ctx, req) if err != nil { return nil, fmt.Errorf("login failed: %w", err) } // 5. Получаем или генерируем NodeID nodeID, err := s.getOrCreateNodeID(ctx) if err != nil { return nil, fmt.Errorf("failed to get or create node ID: %w", err) } // 6. Возвращаем результат return &LoginResult{ UserID: resp.UserID, AccessToken: resp.AccessToken, RefreshToken: resp.RefreshToken, ExpiresIn: resp.ExpiresIn, Username: username, NodeID: nodeID, PublicSalt: saltResp.PublicSalt, EncryptionKey: keys.EncryptionKey, }, nil } // SaveAuth сохраняет незашифрованные auth данные, // сервис сам зашифрует токены и передаст в хранилище func (s *authService) SaveAuth(ctx context.Context, auth *storage.AuthData) error { if auth == nil { return fmt.Errorf("auth data is nil") } if s.encryptionKey == nil { return fmt.Errorf("encryption key not set, call SetEncryptionKey first") } // Шифруем токены encryptedAccessToken, err := crypto.Encrypt([]byte(auth.AccessToken), s.encryptionKey) if err != nil { return fmt.Errorf("failed to encrypt access token: %w", err) } encryptedRefreshToken, err := crypto.Encrypt([]byte(auth.RefreshToken), s.encryptionKey) if err != nil { return fmt.Errorf("failed to encrypt refresh token: %w", err) } // Кодируем шифрованные токены в base64 authCopy := *auth // копируем структуру, чтобы не менять входящую authCopy.AccessToken = base64.StdEncoding.EncodeToString(encryptedAccessToken) authCopy.RefreshToken = base64.StdEncoding.EncodeToString(encryptedRefreshToken) authCopy.ExpiresAt = auth.ExpiresAt // Сохраняем в storage (уже с зашифрованными токенами) return s.storage.SaveAuth(ctx, &authCopy) } // GetAuthDecryptData загружает данные из storage и расшифровывает токены func (s *authService) GetAuthDecryptData(ctx context.Context) (*storage.AuthData, error) { if s.encryptionKey == nil { return nil, fmt.Errorf("encryption key not set, call SetEncryptionKey first") } storedAuth, err := s.storage.GetAuth(ctx) if err != nil { return nil, err } // Декодируем base64 из хранилища encryptedAccessTokenBytes, err := base64.StdEncoding.DecodeString(storedAuth.AccessToken) if err != nil { return nil, fmt.Errorf("failed to base64 decode access token: %w", err) } encryptedRefreshTokenBytes, err := base64.StdEncoding.DecodeString(storedAuth.RefreshToken) if err != nil { return nil, fmt.Errorf("failed to base64 decode refresh token: %w", err) } // Дешифруем accessTokenBytes, err := crypto.Decrypt(encryptedAccessTokenBytes, s.encryptionKey) if err != nil { return nil, fmt.Errorf("failed to decrypt access token: %w", err) } refreshTokenBytes, err := crypto.Decrypt(encryptedRefreshTokenBytes, s.encryptionKey) if err != nil { return nil, fmt.Errorf("failed to decrypt refresh token: %w", err) } // Копируем все в новую структуру, возвращаем с расшифрованными токенами auth := *storedAuth auth.AccessToken = string(accessTokenBytes) auth.RefreshToken = string(refreshTokenBytes) auth.ExpiresAt = storedAuth.ExpiresAt return &auth, nil } // GetAuthEncryptData загружает данные из storage БЕЗ расшифровки токенов // Используется для получения username и public salt без необходимости в ключе func (s *authService) GetAuthEncryptData(ctx context.Context) (*storage.AuthData, error) { return s.storage.GetAuth(ctx) } // DeleteAuth удаляет данные func (s *authService) DeleteAuth(ctx context.Context) error { return s.storage.DeleteAuth(ctx) } // IsAuthenticated проверяет валидность сохраненных данных по сроку действия токена func (s *authService) IsAuthenticated(ctx context.Context) (bool, error) { return s.storage.IsAuthenticated(ctx) } // Logout выполняет выход из системы // Удаляет локальные данные авторизации и опционально уведомляет сервер func (s *authService) Logout(ctx context.Context) error { // 1. Пытаемся получить текущий access token для отправки серверу // Используем расшифровку если ключ установлен var accessToken string if s.encryptionKey != nil { authData, err := s.GetAuthDecryptData(ctx) if err != nil { // Если данных нет, просто логируем и продолжаем slog.Debug("no auth data found during logout", "error", err) } else { accessToken = authData.AccessToken } } // 2. Пытаемся уведомить сервер о logout (best effort) if accessToken != "" { if logoutErr := s.apiClient.Logout(ctx, accessToken); logoutErr != nil { // Не прерываем процесс, если сервер недоступен slog.Warn("failed to logout on server", "error", logoutErr) } } // 3. Всегда удаляем локальные данные, даже если сервер недоступен if err := s.DeleteAuth(ctx); err != nil { return fmt.Errorf("failed to delete local auth data: %w", err) } // 4. Очищаем ключ шифрования s.encryptionKey = nil return nil } // RefreshToken обновляет access token используя refresh token // Автоматически загружает текущий refresh token, запрашивает новую пару токенов // и сохраняет их в хранилище func (s *authService) RefreshToken(ctx context.Context) error { // Проверяем что ключ шифрования установлен if s.encryptionKey == nil { return fmt.Errorf("encryption key not set, call SetEncryptionKey first") } // Получаем текущие auth данные с расшифрованными токенами authData, err := s.GetAuthDecryptData(ctx) if err != nil { return fmt.Errorf("failed to get auth data: %w", err) } // Вызываем API для обновления токена tokenResp, err := s.apiClient.Refresh(ctx, authData.RefreshToken) if err != nil { return fmt.Errorf("failed to refresh token: %w", err) } // Обновляем токены в auth data authData.AccessToken = tokenResp.AccessToken authData.RefreshToken = tokenResp.RefreshToken authData.ExpiresAt = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Unix() // Сохраняем обновлённые данные (токены будут зашифрованы в SaveAuth) if err := s.SaveAuth(ctx, authData); err != nil { return fmt.Errorf("failed to save refreshed tokens: %w", err) } slog.Debug("Access token refreshed successfully", "expires_at", time.Unix(authData.ExpiresAt, 0).Format(time.RFC3339)) return nil } // getOrCreateNodeID возвращает существующий NodeID или создает новый // NodeID должен быть уникальным для каждого физического клиента/устройства func (s *authService) getOrCreateNodeID(ctx context.Context) (string, error) { // Проверяем есть ли уже сохраненный NodeID в auth data authData, err := s.storage.GetAuth(ctx) if err != nil { // Если данных нет (первый login на этом устройстве), создаем новый NodeID if err == storage.ErrAuthNotFound { return uuid.New().String(), nil } return "", fmt.Errorf("failed to get auth data: %w", err) } // Если NodeID уже есть, используем его (повторный login на том же устройстве) if authData.NodeID != "" { return authData.NodeID, nil } // Если NodeID пустой (старая версия базы), создаем новый return uuid.New().String(), nil } // EnsureTokenValid проверяет срок жизни access token и обновляет его при необходимости // Возвращает ошибку, если токен нельзя обновить или не аутентифицирован. func (s *authService) EnsureTokenValid(ctx context.Context) error { if s.encryptionKey == nil { return fmt.Errorf("encryption key not set") } authData, err := s.GetAuthDecryptData(ctx) if err != nil { return fmt.Errorf("failed to get auth data: %w", err) } expiresAt := time.Unix(authData.ExpiresAt, 0) now := time.Now() bufferTime := 60 * time.Second if now.Add(bufferTime).After(expiresAt) { // Токен скоро истекает или истек — обновляем if err := s.RefreshToken(ctx); err != nil { return fmt.Errorf("failed to refresh access token: %w", err) } } return nil }
package cli import ( "context" "fmt" "mime" "net/http" "os" "path/filepath" "strings" "github.com/iudanet/gophkeeper/internal/models" ) var usage = "Usage: gophkeeper add <credential|text|binary|card> [--sync]" func (c *Cli) runAdd(ctx context.Context, args []string) error { // Проверяем подкоманду if len(args) == 0 { return fmt.Errorf("missing data type. %s", usage) } // Парсим флаг --sync syncFlag := false dataType := args[0] // Проверяем наличие флага --sync в аргументах if len(args) > 1 { for _, arg := range args[1:] { if arg == "--sync" { syncFlag = true break } } } switch dataType { case "credential": return c.runAddCredential(ctx, syncFlag) case "text": return c.runAddText(ctx, syncFlag) case "binary": return c.runAddBinary(ctx, syncFlag) case "card": return c.runAddCard(ctx, syncFlag) default: return fmt.Errorf("unknown data type: %s. %s", dataType, usage) } } // runAddCredential спрашивает данные через io, вызывает бизнес-логику и выводит результат func (c *Cli) runAddCredential(ctx context.Context, autoSync bool) error { c.io.Println("=== Add Credential ===") c.io.Println() c.io.Println("Enter credential details:") c.io.Println() name, err := c.io.ReadInput("Name (e.g., 'GitHub', 'Gmail'): ") if err != nil { return fmt.Errorf("failed to read name: %w", err) } if name == "" { return fmt.Errorf("name cannot be empty") } login, err := c.io.ReadInput("Login/Email: ") if err != nil { return fmt.Errorf("failed to read login: %w", err) } if login == "" { return fmt.Errorf("login cannot be empty") } password, err := c.io.ReadPassword("Password: ") if err != nil { return fmt.Errorf("failed to read password: %w", err) } if password == "" { return fmt.Errorf("password cannot be empty") } url, err := c.io.ReadInput("URL (optional): ") if err != nil { return fmt.Errorf("failed to read URL: %w", err) } notes, err := c.io.ReadInput("Notes (optional): ") if err != nil { return fmt.Errorf("failed to read notes: %w", err) } cred := &models.Credential{ Name: name, Login: login, Password: password, URL: url, Notes: notes, Metadata: models.Metadata{ Favorite: false, Tags: []string{}, }, } userID := c.authData.UserID if err := c.dataService.AddCredential(ctx, userID, c.authData.NodeID, c.encryptionKey, cred); err != nil { return fmt.Errorf("failed to add credential: %w", err) } c.io.Println() c.io.Println("✓ Credential added successfully!") c.io.Printf("Name: %s\n", name) c.io.Printf("Login: %s\n", login) c.io.Println() if autoSync { c.io.Println("Syncing with server...") if err := c.runSync(ctx); err != nil { return fmt.Errorf("failed to sync: %w", err) } } else { c.io.Println("Note: Credential is stored locally. Run 'gophkeeper sync' to sync with server.") } return nil } func (c *Cli) runAddText(ctx context.Context, autoSync bool) error { c.io.Println("=== Add Text Data ===") c.io.Println() c.io.Println("Enter text data details:") c.io.Println() name, err := c.io.ReadInput("Name (e.g., 'Secret Note'): ") if err != nil || name == "" { return fmt.Errorf("name cannot be empty") } content, err := c.io.ReadInput("Content: ") if err != nil || content == "" { return fmt.Errorf("content cannot be empty") } textData := &models.TextData{ Name: name, Content: content, Metadata: models.Metadata{ Favorite: false, Tags: []string{}, }, } userID := c.authData.UserID if err := c.dataService.AddTextData(ctx, userID, c.authData.NodeID, c.encryptionKey, textData); err != nil { return fmt.Errorf("failed to add text data: %w", err) } c.io.Println() c.io.Println("✓ Text data added successfully!") c.io.Printf("Name: %s\n", name) c.io.Println() // Автоматическая синхронизация если флаг установлен if autoSync { c.io.Println("Syncing with server...") if err := c.runSync(ctx); err != nil { return fmt.Errorf("failed to sync: %w", err) } } else { c.io.Println("Note: Data is stored locally. Run 'gophkeeper sync' to sync with server.") } return nil } func (c *Cli) runAddCard(ctx context.Context, autoSync bool) error { c.io.Println("=== Add Card Data ===") c.io.Println() c.io.Println("Enter card details:") c.io.Println() name, err := c.io.ReadInput("Card Name (e.g., 'Visa Gold'): ") if err != nil || name == "" { return fmt.Errorf("name cannot be empty") } number, err := c.io.ReadInput("Card Number: ") if err != nil || number == "" { return fmt.Errorf("card number cannot be empty") } holder, err := c.io.ReadInput("Card Holder: ") if err != nil { return fmt.Errorf("failed to read holder: %w", err) } expiry, err := c.io.ReadInput("Expiry (MM/YY): ") if err != nil { return fmt.Errorf("failed to read expiry: %w", err) } cvv, err := c.io.ReadPassword("CVV: ") if err != nil { return fmt.Errorf("failed to read CVV: %w", err) } pin, err := c.io.ReadPassword("PIN (optional): ") if err != nil { return fmt.Errorf("failed to read PIN: %w", err) } cardData := &models.CardData{ Name: name, Number: number, Holder: holder, Expiry: expiry, CVV: cvv, PIN: pin, Metadata: models.Metadata{ Favorite: false, Tags: []string{}, }, } userID := c.authData.UserID if err := c.dataService.AddCardData(ctx, userID, c.authData.NodeID, c.encryptionKey, cardData); err != nil { return fmt.Errorf("failed to add card: %w", err) } c.io.Println() c.io.Println("✓ Card added successfully!") c.io.Printf("Name: %s\n", name) c.io.Println() // Автоматическая синхронизация если флаг установлен if autoSync { c.io.Println("Syncing with server...") if err := c.runSync(ctx); err != nil { return fmt.Errorf("failed to sync: %w", err) } } else { c.io.Println("Note: Card is stored locally. Run 'gophkeeper sync' to sync with server.") } return nil } func (c *Cli) runAddBinary(ctx context.Context, autoSync bool) error { c.io.Println("=== Add Binary Data ===") c.io.Println() c.io.Println("Enter binary file details:") c.io.Println() name, err := c.io.ReadInput("Name (e.g., 'Passport Scan'): ") if err != nil || name == "" { return fmt.Errorf("name cannot be empty") } filePath, err := c.io.ReadInput("File path: ") if err != nil || filePath == "" { return fmt.Errorf("file path cannot be empty") } // Читаем файл content, err := os.ReadFile(filePath) if err != nil { return fmt.Errorf("failed to read file: %w", err) } // Получаем имя файла из пути filename := filepath.Base(filePath) // Определяем MIME type через пакет mime ext := strings.ToLower(filepath.Ext(filename)) mimeType := mime.TypeByExtension(ext) if mimeType == "" { mimeType = http.DetectContentType(content) } binaryData := &models.BinaryData{ Name: name, MimeType: mimeType, Data: content, Metadata: models.Metadata{ Favorite: false, Tags: []string{}, CustomFields: map[string]string{ "filename": filename, }, }, } userID := c.authData.UserID if err := c.dataService.AddBinaryData(ctx, userID, c.authData.NodeID, c.encryptionKey, binaryData); err != nil { return fmt.Errorf("failed to add binary data: %w", err) } c.io.Println() c.io.Println("✓ File added successfully!") c.io.Printf("Name: %s\n", name) if filename, ok := binaryData.Metadata.CustomFields["filename"]; ok { c.io.Printf("Filename: %s\n", filename) } c.io.Printf("Size: %d bytes\n", len(content)) c.io.Println() // Автоматическая синхронизация если флаг установлен if autoSync { c.io.Println("Syncing with server...") if err := c.runSync(ctx); err != nil { return fmt.Errorf("failed to sync: %w", err) } } else { c.io.Println("Note: File is stored locally. Run 'gophkeeper sync' to sync with server.") } return nil }
package cli import ( "context" "fmt" "os" "strings" "text/template" "github.com/iudanet/gophkeeper/internal/client/api" "github.com/iudanet/gophkeeper/internal/client/auth" "github.com/iudanet/gophkeeper/internal/client/data" "github.com/iudanet/gophkeeper/internal/client/iocli" "github.com/iudanet/gophkeeper/internal/client/storage" "github.com/iudanet/gophkeeper/internal/client/sync" "github.com/iudanet/gophkeeper/internal/crypto" "github.com/iudanet/gophkeeper/internal/validation" ) type Passwords struct { FromFile string FromArgs string } type Cli struct { io iocli.IO apiClient api.ClientAPI authService auth.Service dataService data.Service syncService sync.Service authData *storage.AuthData pass *Passwords // временно храним. при возможности удаляем encryptionKey []byte } func New(apiClient api.ClientAPI, authService auth.Service, dataService data.Service, syncService sync.Service, io iocli.IO, pass *Passwords) *Cli { return &Cli{ io: io, apiClient: apiClient, authService: authService, dataService: dataService, syncService: syncService, pass: pass, } } // ReadMasterPassword reads master password from various sources with priority: // 1. Environment variable GOPHKEEPER_MASTER_PASSWORD // 2. File specified in masterPasswordFile parameter // 3. Command-line parameter masterPassword // 4. Interactive prompt (fallback) func (c *Cli) ReadMasterPassword(ctx context.Context) error { // Получаем зашифрованные auth данные для получения username и public salt encryptedAuthData, err := c.authService.GetAuthEncryptData(ctx) if err != nil { if err == storage.ErrAuthNotFound { return fmt.Errorf("not authenticated. Please run 'gophkeeper login' first") } return fmt.Errorf("failed to get auth data: %w", err) } // Получаем master password из различных источников password, err := c.getMasterPassword(*c.pass) if err != nil { return fmt.Errorf("failed to get master password: %w", err) } // очищаем пароль после использования c.pass = nil // Деривируем ключи из master password + username + public salt keys, err := crypto.DeriveKeysFromBase64Salt(password, encryptedAuthData.Username, encryptedAuthData.PublicSalt) if err != nil { return fmt.Errorf("failed to derive keys: %w", err) } // Сохраняем encryption key в памяти для текущей сессии c.encryptionKey = keys.EncryptionKey // Устанавливаем ключ шифрования в authService c.authService.SetEncryptionKey(c.encryptionKey) // Получаем расшифрованные auth данные authData, err := c.authService.GetAuthDecryptData(ctx) if err != nil { return fmt.Errorf("failed to decrypt auth data: %w", err) } c.authData = authData // dataService уже создан в конструкторе, encryption key и nodeID передаются в методы return nil } // getMasterPassword retrieves master password from various sources with priority: // 1. Environment variable GOPHKEEPER_MASTER_PASSWORD // 2. File specified in masterPasswordFile parameter // 3. Command-line parameter masterPassword // 4. Interactive prompt (fallback) func (c *Cli) getMasterPassword(passwords Passwords) (string, error) { // Priority 1: Environment variable if envPassword := os.Getenv("GOPHKEEPER_MASTER_PASSWORD"); envPassword != "" { return envPassword, nil } // Priority 2: File if passwords.FromFile != "" { content, err := os.ReadFile(passwords.FromFile) if err != nil { return "", fmt.Errorf("failed to read password file: %w", err) } // Убираем trailing newline/whitespace password := strings.TrimSpace(string(content)) if password == "" { return "", fmt.Errorf("password file is empty") } return password, nil } // Priority 3: CLI parameter if passwords.FromArgs != "" { return passwords.FromArgs, nil } // Priority 4: Interactive prompt (fallback) password, err := c.io.ReadPassword("Master password: ") if err != nil { return "", fmt.Errorf("failed to read password from stdin: %w", err) } if password == "" { return "", fmt.Errorf("password cannot be empty") } if err := validation.ValidatePassword(password); err != nil { return "", fmt.Errorf("invalid password: %w", err) } return password, nil } func (c *Cli) printTemplate(tmplStr string, data interface{}) error { tmpl, err := template.New("output").Parse(tmplStr) if err != nil { return err } return tmpl.Execute(c.io, data) } func PrintUsage() { tmpl := template.Must(template.New("usage").Parse(usageTemplate)) _ = tmpl.Execute(os.Stdout, nil) }
package cli import ( "context" "fmt" "os" ) func (c *Cli) Run(ctx context.Context, args []string) { command := args[0] switch command { case "register": if err := c.runRegister(ctx); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } case "login": if err := c.runLogin(ctx); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } case "logout": if err := c.runLogout(ctx); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } case "status": if err := c.runStatus(ctx); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } case "add": if err := c.runAdd(ctx, args[1:]); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } case "list": if err := c.runList(ctx, args[1:]); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } case "get": if err := c.runGet(ctx, args[1:]); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } case "delete": if err := c.runDelete(ctx, args[1:]); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } case "sync": if err := c.runSync(ctx); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } default: fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command) PrintUsage() os.Exit(1) } }
package cli import ( "context" "fmt" "github.com/iudanet/gophkeeper/internal/models" ) func (c *Cli) runDelete(ctx context.Context, args []string) error { // Проверяем наличие ID if len(args) == 0 { return fmt.Errorf("missing entry ID. Usage: gophkeeper delete <id>") } entryID := args[0] // Определяем тип entry и удаляем // Пробуем как credential cred, err := c.dataService.GetCredential(ctx, entryID, c.encryptionKey) if err == nil { return c.deleteCredential(ctx, entryID, cred) } // Пробуем как text text, err := c.dataService.GetTextData(ctx, entryID, c.encryptionKey) if err == nil { return c.deleteTextData(ctx, entryID, text) } // Пробуем как binary binary, err := c.dataService.GetBinaryData(ctx, entryID, c.encryptionKey) if err == nil { return c.deleteBinaryData(ctx, entryID, binary) } // Пробуем как card card, err := c.dataService.GetCardData(ctx, entryID, c.encryptionKey) if err == nil { return c.deleteCardData(ctx, entryID, card) } return fmt.Errorf("entry not found with ID: %s", entryID) } func (c *Cli) deleteCredential(ctx context.Context, id string, credential *models.Credential) error { c.io.Println("=== Delete Credential ===") c.io.Println() c.io.Println("About to delete:") c.io.Printf(" Name: %s\n", credential.Name) c.io.Printf(" Login: %s\n", credential.Login) if credential.URL != "" { c.io.Printf(" URL: %s\n", credential.URL) } c.io.Println() confirm, err := c.io.ReadInput("Are you sure you want to delete this credential? (yes/no): ") if err != nil { return fmt.Errorf("failed to read confirmation: %w", err) } if confirm != "yes" && confirm != "y" { c.io.Println() c.io.Println("Deletion cancelled.") return nil } if err := c.dataService.DeleteCredential(ctx, id, c.authData.NodeID); err != nil { return fmt.Errorf("failed to delete credential: %w", err) } c.io.Println() c.io.Println("✓ Credential deleted successfully!") c.io.Println() c.io.Println("Note: Run 'gophkeeper sync' to sync with server.") return nil } func (c *Cli) deleteTextData(ctx context.Context, id string, textData *models.TextData) error { c.io.Println("=== Delete Text Data ===") c.io.Println() c.io.Println("About to delete:") c.io.Printf(" Name: %s\n", textData.Name) preview := textData.Content if len(preview) > 50 { preview = preview[:50] + "..." } c.io.Printf(" Preview: %s\n", preview) c.io.Println() confirm, err := c.io.ReadInput("Are you sure you want to delete this text data? (yes/no): ") if err != nil { return fmt.Errorf("failed to read confirmation: %w", err) } if confirm != "yes" && confirm != "y" { c.io.Println() c.io.Println("Deletion cancelled.") return nil } if err := c.dataService.DeleteTextData(ctx, id, c.authData.NodeID); err != nil { return fmt.Errorf("failed to delete text data: %w", err) } c.io.Println() c.io.Println("✓ Text data deleted successfully!") c.io.Println() c.io.Println("Note: Run 'gophkeeper sync' to sync with server.") return nil } func (c *Cli) deleteBinaryData(ctx context.Context, id string, binaryData *models.BinaryData) error { c.io.Println("=== Delete Binary Data ===") c.io.Println() c.io.Println("About to delete:") c.io.Printf(" Name: %s\n", binaryData.Name) if filename, ok := binaryData.Metadata.CustomFields["filename"]; ok { c.io.Printf(" Filename: %s\n", filename) } c.io.Printf(" Size: %d bytes\n", len(binaryData.Data)) c.io.Println() confirm, err := c.io.ReadInput("Are you sure you want to delete this file? (yes/no): ") if err != nil { return fmt.Errorf("failed to read confirmation: %w", err) } if confirm != "yes" && confirm != "y" { c.io.Println() c.io.Println("Deletion cancelled.") return nil } if err := c.dataService.DeleteBinaryData(ctx, id, c.authData.NodeID); err != nil { return fmt.Errorf("failed to delete binary data: %w", err) } c.io.Println() c.io.Println("✓ File deleted successfully!") c.io.Println() c.io.Println("Note: Run 'gophkeeper sync' to sync with server.") return nil } func (c *Cli) deleteCardData(ctx context.Context, id string, cardData *models.CardData) error { c.io.Println("=== Delete Card Data ===") c.io.Println() c.io.Println("About to delete:") c.io.Printf(" Name: %s\n", cardData.Name) maskedNumber := maskCardNumber(cardData.Number) c.io.Printf(" Number: %s\n", maskedNumber) if cardData.Holder != "" { c.io.Printf(" Holder: %s\n", cardData.Holder) } c.io.Println() confirm, err := c.io.ReadInput("Are you sure you want to delete this card? (yes/no): ") if err != nil { return fmt.Errorf("failed to read confirmation: %w", err) } if confirm != "yes" && confirm != "y" { c.io.Println() c.io.Println("Deletion cancelled.") return nil } if err := c.dataService.DeleteCardData(ctx, id, c.authData.NodeID); err != nil { return fmt.Errorf("failed to delete card data: %w", err) } c.io.Println() c.io.Println("✓ Card deleted successfully!") c.io.Println() c.io.Println("Note: Run 'gophkeeper sync' to sync with server.") return nil }
package cli import ( "context" "fmt" "os" "github.com/iudanet/gophkeeper/internal/models" ) func (c *Cli) runGet(ctx context.Context, args []string) error { // Проверяем наличие ID if len(args) == 0 { return fmt.Errorf("missing entry ID. Usage: gophkeeper get <id>") } entryID := args[0] // Пробуем получить как credential cred, err := c.dataService.GetCredential(ctx, entryID, c.encryptionKey) if err == nil { return c.displayCredential(cred) } // Пробуем получить как text text, err := c.dataService.GetTextData(ctx, entryID, c.encryptionKey) if err == nil { return c.displayTextData(text) } // Пробуем получить как binary binary, err := c.dataService.GetBinaryData(ctx, entryID, c.encryptionKey) if err == nil { return c.displayBinaryData(binary) } // Пробуем получить как card card, err := c.dataService.GetCardData(ctx, entryID, c.encryptionKey) if err == nil { return c.displayCardData(card) } return fmt.Errorf("entry not found with ID: %s", entryID) } func (c *Cli) displayCredential(credential *models.Credential) error { return c.printTemplate(credentialTemplate, credential) } func (c *Cli) displayTextData(textData *models.TextData) error { return c.printTemplate(textDataTemplate, textData) } func (c *Cli) displayBinaryData(binaryData *models.BinaryData) error { err := c.printTemplate(binaryDataTemplate, binaryData) if err != nil { return fmt.Errorf("failed to print template: %w", err) } savePath, err := c.io.ReadInput("Save to file (press Enter to skip): ") if err != nil { return fmt.Errorf("failed to read input: %w", err) } if savePath != "" { if err := os.WriteFile(savePath, binaryData.Data, 0600); err != nil { return fmt.Errorf("failed to save file: %w", err) } c.io.Printf("✓ File saved to: %s\n", savePath) } c.io.Println() return nil } func (c *Cli) displayCardData(card *models.CardData) error { return c.printTemplate(cardDataTemplate, card) }
package cli import ( "context" "fmt" ) func (c *Cli) runList(ctx context.Context, args []string) error { // Проверяем подкоманду if len(args) == 0 { return fmt.Errorf("missing data type. Usage: gophkeeper list <credentials|text|binary|card>") } dataType := args[0] switch dataType { case "credentials", "credential": return c.runListCredentials(ctx) case "text": return c.runListText(ctx) case "binary": return c.runListBinary(ctx) case "card", "cards": return c.runListCards(ctx) default: return fmt.Errorf("unknown data type: %s. Use: credentials, text, binary, or card", dataType) } } func (c *Cli) runListCredentials(ctx context.Context) error { credentials, err := c.dataService.ListCredentials(ctx, c.encryptionKey) if err != nil { return fmt.Errorf("failed to list credentials: %w", err) } return c.printTemplate(credentialsListTemplate, credentials) } func (c *Cli) runListText(ctx context.Context) error { textData, err := c.dataService.ListTextData(ctx, c.encryptionKey) if err != nil { return fmt.Errorf("failed to list text data: %w", err) } return c.printTemplate(textDataListTemplate, textData) } func (c *Cli) runListBinary(ctx context.Context) error { binaryData, err := c.dataService.ListBinaryData(ctx, c.encryptionKey) if err != nil { return fmt.Errorf("failed to list binary data: %w", err) } return c.printTemplate(binaryDataListTemplate, binaryData) } func (c *Cli) runListCards(ctx context.Context) error { cardData, err := c.dataService.ListCardData(ctx, c.encryptionKey) if err != nil { return fmt.Errorf("failed to list card data: %w", err) } // Создаем слайс копий карточек с замаскированным номером type CardView struct { ID string Name string Number string Holder string Expiry string } cardsView := make([]CardView, 0, len(cardData)) for _, card := range cardData { cardsView = append(cardsView, CardView{ ID: card.ID, Name: card.Name, Number: maskCardNumber(card.Number), Holder: card.Holder, Expiry: card.Expiry, }) } return c.printTemplate(cardDataListTemplate, cardsView) } // maskCardNumber masks a card number showing only the last 4 digits func maskCardNumber(number string) string { if len(number) < 4 { return "****-****-****-****" // Полностью маскируем короткие номера } return "****-****-****-" + number[len(number)-4:] }
package cli import ( "context" "fmt" "time" "github.com/iudanet/gophkeeper/internal/client/storage" ) func (c *Cli) runLogin(ctx context.Context) error { c.io.Println("=== Login ===") c.io.Println() // Запрашиваем username username, err := c.io.ReadInput("Username: ") if err != nil { return fmt.Errorf("failed to read username: %w", err) } // Получаем master password из различных источников pass, err := c.getMasterPassword(*c.pass) if err != nil { return fmt.Errorf("failed to get master password: %w", err) } // очищаем пароли так как больше ненадо c.pass = nil c.io.Println() c.io.Println("Authenticating...") // Логин через authService result, err := c.authService.Login(ctx, username, pass) if err != nil { return err } // Устанавливаем ключ шифрования в authService c.authService.SetEncryptionKey(result.EncryptionKey) // Сохраняем токены через authService (теперь с установленным ключом) authData := &storage.AuthData{ Username: result.Username, UserID: result.UserID, // User UUID from server NodeID: result.NodeID, // уникальный ID клиента для CRDT AccessToken: result.AccessToken, // plaintext RefreshToken: result.RefreshToken, // plaintext PublicSalt: result.PublicSalt, ExpiresAt: time.Now().Unix() + result.ExpiresIn, } if err := c.authService.SaveAuth(ctx, authData); err != nil { return fmt.Errorf("failed to save auth data: %w", err) } c.io.Println() c.io.Println("✓ Login successful!") c.io.Printf("Username: %s\n", result.Username) c.io.Printf("Access token expires in: %d seconds\n", result.ExpiresIn) c.io.Println() c.io.Println("Your session has been saved securely.") return nil }
package cli import ( "context" "fmt" ) func (c *Cli) runLogout(ctx context.Context) error { c.io.Println("=== Logout ===") // Выполняем logout через authService if err := c.authService.Logout(ctx); err != nil { return fmt.Errorf("logout failed: %w", err) } c.io.Println("✓ Logout successful!") c.io.Println("Your local session has been deleted.") return nil }
package cli import ( "context" "fmt" ) func (c *Cli) runRegister(ctx context.Context) error { c.io.Println("=== Registration ===") c.io.Println() // Запрашиваем username username, err := c.io.ReadInput("Username: ") if err != nil { return fmt.Errorf("failed to read username: %w", err) } // Запрашиваем master password masterPassword, err := c.io.ReadPassword("Master password (min 12 chars): ") if err != nil { return fmt.Errorf("failed to read password: %w", err) } // Подтверждение пароля confirmPassword, err := c.io.ReadPassword("Confirm master password: ") if err != nil { return fmt.Errorf("failed to read confirmation: %w", err) } if masterPassword != confirmPassword { return fmt.Errorf("passwords do not match") } c.io.Println() c.io.Println("Registering user...") // Регистрация через authService result, err := c.authService.Register(ctx, username, masterPassword) if err != nil { return err } c.io.Println() c.io.Println("✓ Registration successful!") c.io.Printf("User ID: %s\n", result.UserID) c.io.Printf("Username: %s\n", result.Username) c.io.Printf("Device ID: %s\n", result.NodeID) c.io.Println() c.io.Println("⚠️ IMPORTANT: Remember your master password!") c.io.Println(" If you lose it, you will NOT be able to recover your data.") c.io.Println() c.io.Println("Please run 'gophkeeper login' to start using the service.") return nil }
package cli import ( "context" "fmt" "time" ) func (c *Cli) runStatus(ctx context.Context) error { c.io.Println("=== Authentication Status ===") c.io.Println() // Проверяем наличие сохраненной сессии isAuth, err := c.authService.IsAuthenticated(ctx) if err != nil { return fmt.Errorf("failed to check authentication: %w", err) } if !isAuth { c.io.Println("Status: Not authenticated") c.io.Println() c.io.Println("Run 'gophkeeper login' to authenticate.") return nil } // Получаем зашифрованные данные (для отображения username/expiry не нужна расшифровка токенов) authData, err := c.authService.GetAuthEncryptData(ctx) if err != nil { return fmt.Errorf("failed to get auth data: %w", err) } expiresAt := time.Unix(authData.ExpiresAt, 0) remaining := time.Until(expiresAt) c.io.Println("Status: Authenticated") c.io.Printf("Username: %s\n", authData.Username) c.io.Printf("Token expires: %s\n", expiresAt.Format(time.RFC3339)) if remaining > 0 { c.io.Printf("Time remaining: %s\n", remaining.Round(time.Second)) } else { c.io.Println("⚠️ Token has expired. Please login again.") } // Получаем количество записей, ожидающих синхронизации pendingCount, err := c.syncService.GetPendingSyncCount(ctx) if err != nil { // Не прерываем выполнение, просто логируем c.io.Printf("\nWarning: Failed to get pending sync count: %v\n", err) } else { c.io.Println() if pendingCount > 0 { c.io.Printf("⚠️ Pending sync: %d record(s) waiting to be synchronized\n", pendingCount) c.io.Println("Run 'gophkeeper sync' to synchronize with server.") } else { c.io.Println("✓ All data synchronized with server") } } return nil }
package cli import ( "context" "fmt" ) const syncResultTemplate = ` === Synchronization Result === ✓ Synchronization completed successfully! Summary: Pushed to server: {{.PushedEntries}} entries Pulled from server: {{.PulledEntries}} entries Merged locally: {{.MergedEntries}} entries {{- if gt .Conflicts 0 }} Conflicts resolved: {{.Conflicts}} {{- end }} {{- if gt .SkippedEntries 0 }} Skipped (errors): {{.SkippedEntries}} {{- end }} Your data is now synchronized with the server. ` func (c *Cli) runSync(ctx context.Context) error { if c.authData == nil { return fmt.Errorf("not authenticated or encryption key not available") } if err := c.authService.EnsureTokenValid(ctx); err != nil { return fmt.Errorf("%w. Please login again", err) } authData, err := c.authService.GetAuthDecryptData(ctx) if err != nil { return fmt.Errorf("failed to get updated auth data: %w", err) } c.authData = authData c.io.Println("Starting synchronization with server...") result, err := c.syncService.Sync(ctx, c.authData.UserID, c.authData.AccessToken) if err != nil { return fmt.Errorf("synchronization failed: %w", err) } if err := c.printTemplate(syncResultTemplate, result); err != nil { return fmt.Errorf("failed to print sync result: %w", err) } return nil }
package data import ( "context" "encoding/json" "fmt" "time" "github.com/google/uuid" "github.com/iudanet/gophkeeper/internal/client/storage" "github.com/iudanet/gophkeeper/internal/crypto" "github.com/iudanet/gophkeeper/internal/models" ) //go:generate moq -out service_mock.go . Service // service определяет интерфейс для клиентского data сервиса type Service interface { AddCredential(ctx context.Context, userID, nodeID string, encryptionKey []byte, cred *models.Credential) error GetCredential(ctx context.Context, id string, encryptionKey []byte) (*models.Credential, error) ListCredentials(ctx context.Context, encryptionKey []byte) ([]*models.Credential, error) DeleteCredential(ctx context.Context, id, nodeID string) error AddTextData(ctx context.Context, userID, nodeID string, encryptionKey []byte, text *models.TextData) error GetTextData(ctx context.Context, id string, encryptionKey []byte) (*models.TextData, error) ListTextData(ctx context.Context, encryptionKey []byte) ([]*models.TextData, error) DeleteTextData(ctx context.Context, id, nodeID string) error AddBinaryData(ctx context.Context, userID, nodeID string, encryptionKey []byte, binary *models.BinaryData) error GetBinaryData(ctx context.Context, id string, encryptionKey []byte) (*models.BinaryData, error) ListBinaryData(ctx context.Context, encryptionKey []byte) ([]*models.BinaryData, error) DeleteBinaryData(ctx context.Context, id, nodeID string) error AddCardData(ctx context.Context, userID, nodeID string, encryptionKey []byte, card *models.CardData) error GetCardData(ctx context.Context, id string, encryptionKey []byte) (*models.CardData, error) ListCardData(ctx context.Context, encryptionKey []byte) ([]*models.CardData, error) DeleteCardData(ctx context.Context, id, nodeID string) error } // service handles client-side data operations with encryption type service struct { crdtStorage storage.CRDTStorage } // Newservice creates a new data service func NewService(crdtStorage storage.CRDTStorage) Service { return &service{ crdtStorage: crdtStorage, } } // AddCredential adds a new credential to local storage func (s *service) AddCredential(ctx context.Context, userID, nodeID string, encryptionKey []byte, cred *models.Credential) error { // Генерируем ID если не задан if cred.ID == "" { cred.ID = uuid.New().String() } // Сериализуем credential в JSON credJSON, err := json.Marshal(cred) if err != nil { return fmt.Errorf("failed to marshal credential: %w", err) } // Шифруем данные encryptedData, err := crypto.Encrypt(credJSON, encryptionKey) if err != nil { return fmt.Errorf("failed to encrypt credential: %w", err) } // Сериализуем metadata metadataJSON, err := json.Marshal(cred.Metadata) if err != nil { return fmt.Errorf("failed to marshal metadata: %w", err) } // Шифруем metadata encryptedMetadata, err := crypto.Encrypt(metadataJSON, encryptionKey) if err != nil { return fmt.Errorf("failed to encrypt metadata: %w", err) } // Создаем CRDT entry now := time.Now() entry := &models.CRDTEntry{ ID: cred.ID, UserID: userID, Type: models.DataTypeCredential, NodeID: nodeID, Data: encryptedData, Metadata: encryptedMetadata, Version: 1, Timestamp: now.Unix(), Deleted: false, CreatedAt: now, UpdatedAt: now, } // Сохраняем в локальное хранилище if err := s.crdtStorage.SaveEntry(ctx, entry); err != nil { return fmt.Errorf("failed to save entry: %w", err) } return nil } // GetCredential retrieves and decrypts a credential by ID func (s *service) GetCredential(ctx context.Context, id string, encryptionKey []byte) (*models.Credential, error) { // Получаем CRDT entry entry, err := s.crdtStorage.GetEntry(ctx, id) if err != nil { return nil, fmt.Errorf("failed to get entry: %w", err) } // Проверяем тип if entry.Type != models.DataTypeCredential { return nil, fmt.Errorf("entry is not a credential, got type: %s", entry.Type) } // Проверяем что не удалено if entry.Deleted { return nil, fmt.Errorf("credential is deleted") } // Расшифровываем данные decryptedData, err := crypto.Decrypt(entry.Data, encryptionKey) if err != nil { return nil, fmt.Errorf("failed to decrypt credential: %w", err) } // Десериализуем var cred models.Credential if err := json.Unmarshal(decryptedData, &cred); err != nil { return nil, fmt.Errorf("failed to unmarshal credential: %w", err) } return &cred, nil } // ListCredentials returns all credentials for the user func (s *service) ListCredentials(ctx context.Context, encryptionKey []byte) ([]*models.Credential, error) { // Получаем все активные entries типа credential entries, err := s.crdtStorage.GetEntriesByType(ctx, models.DataTypeCredential) if err != nil { return nil, fmt.Errorf("failed to get entries: %w", err) } credentials := make([]*models.Credential, 0, len(entries)) for _, entry := range entries { // Расшифровываем данные decryptedData, err := crypto.Decrypt(entry.Data, encryptionKey) if err != nil { // Пропускаем поврежденные записи continue } // Десериализуем var cred models.Credential if err := json.Unmarshal(decryptedData, &cred); err != nil { // Пропускаем поврежденные записи continue } credentials = append(credentials, &cred) } return credentials, nil } // DeleteCredential marks credential as deleted (soft delete) func (s *service) DeleteCredential(ctx context.Context, id, nodeID string) error { now := time.Now() if err := s.crdtStorage.DeleteEntry(ctx, id, now.Unix(), nodeID); err != nil { return fmt.Errorf("failed to delete credential: %w", err) } return nil } // AddTextData adds new text data to local storage func (s *service) AddTextData(ctx context.Context, userID, nodeID string, encryptionKey []byte, text *models.TextData) error { // Генерируем ID если не задан if text.ID == "" { text.ID = uuid.New().String() } // Сериализуем text data в JSON textJSON, err := json.Marshal(text) if err != nil { return fmt.Errorf("failed to marshal text data: %w", err) } // Шифруем данные encryptedData, err := crypto.Encrypt(textJSON, encryptionKey) if err != nil { return fmt.Errorf("failed to encrypt text data: %w", err) } // Сериализуем metadata metadataJSON, err := json.Marshal(text.Metadata) if err != nil { return fmt.Errorf("failed to marshal metadata: %w", err) } // Шифруем metadata encryptedMetadata, err := crypto.Encrypt(metadataJSON, encryptionKey) if err != nil { return fmt.Errorf("failed to encrypt metadata: %w", err) } // Создаем CRDT entry now := time.Now() entry := &models.CRDTEntry{ ID: text.ID, UserID: userID, Type: models.DataTypeText, NodeID: nodeID, Data: encryptedData, Metadata: encryptedMetadata, Version: 1, Timestamp: now.Unix(), Deleted: false, CreatedAt: now, UpdatedAt: now, } // Сохраняем в локальное хранилище if err := s.crdtStorage.SaveEntry(ctx, entry); err != nil { return fmt.Errorf("failed to save entry: %w", err) } return nil } // GetTextData retrieves and decrypts text data by ID func (s *service) GetTextData(ctx context.Context, id string, encryptionKey []byte) (*models.TextData, error) { // Получаем CRDT entry entry, err := s.crdtStorage.GetEntry(ctx, id) if err != nil { return nil, fmt.Errorf("failed to get entry: %w", err) } // Проверяем тип if entry.Type != models.DataTypeText { return nil, fmt.Errorf("entry is not text data, got type: %s", entry.Type) } // Проверяем что не удалено if entry.Deleted { return nil, fmt.Errorf("text data is deleted") } // Расшифровываем данные decryptedData, err := crypto.Decrypt(entry.Data, encryptionKey) if err != nil { return nil, fmt.Errorf("failed to decrypt text data: %w", err) } // Десериализуем var text models.TextData if err := json.Unmarshal(decryptedData, &text); err != nil { return nil, fmt.Errorf("failed to unmarshal text data: %w", err) } return &text, nil } // ListTextData returns all text data entries for the user func (s *service) ListTextData(ctx context.Context, encryptionKey []byte) ([]*models.TextData, error) { // Получаем все активные entries типа text entries, err := s.crdtStorage.GetEntriesByType(ctx, models.DataTypeText) if err != nil { return nil, fmt.Errorf("failed to get entries: %w", err) } textData := make([]*models.TextData, 0, len(entries)) for _, entry := range entries { // Расшифровываем данные decryptedData, err := crypto.Decrypt(entry.Data, encryptionKey) if err != nil { // Пропускаем поврежденные записи continue } // Десериализуем var text models.TextData if err := json.Unmarshal(decryptedData, &text); err != nil { // Пропускаем поврежденные записи continue } textData = append(textData, &text) } return textData, nil } // DeleteTextData marks text data as deleted (soft delete) func (s *service) DeleteTextData(ctx context.Context, id, nodeID string) error { now := time.Now() if err := s.crdtStorage.DeleteEntry(ctx, id, now.Unix(), nodeID); err != nil { return fmt.Errorf("failed to delete text data: %w", err) } return nil } // AddBinaryData adds new binary data to local storage func (s *service) AddBinaryData(ctx context.Context, userID, nodeID string, encryptionKey []byte, binary *models.BinaryData) error { // Генерируем ID если не задан if binary.ID == "" { binary.ID = uuid.New().String() } // Сериализуем binary data в JSON binaryJSON, err := json.Marshal(binary) if err != nil { return fmt.Errorf("failed to marshal binary data: %w", err) } // Шифруем данные encryptedData, err := crypto.Encrypt(binaryJSON, encryptionKey) if err != nil { return fmt.Errorf("failed to encrypt binary data: %w", err) } // Сериализуем metadata metadataJSON, err := json.Marshal(binary.Metadata) if err != nil { return fmt.Errorf("failed to marshal metadata: %w", err) } // Шифруем metadata encryptedMetadata, err := crypto.Encrypt(metadataJSON, encryptionKey) if err != nil { return fmt.Errorf("failed to encrypt metadata: %w", err) } // Создаем CRDT entry now := time.Now() entry := &models.CRDTEntry{ ID: binary.ID, UserID: userID, Type: models.DataTypeBinary, NodeID: nodeID, Data: encryptedData, Metadata: encryptedMetadata, Version: 1, Timestamp: now.Unix(), Deleted: false, CreatedAt: now, UpdatedAt: now, } // Сохраняем в локальное хранилище if err := s.crdtStorage.SaveEntry(ctx, entry); err != nil { return fmt.Errorf("failed to save entry: %w", err) } return nil } // GetBinaryData retrieves and decrypts binary data by ID func (s *service) GetBinaryData(ctx context.Context, id string, encryptionKey []byte) (*models.BinaryData, error) { // Получаем CRDT entry entry, err := s.crdtStorage.GetEntry(ctx, id) if err != nil { return nil, fmt.Errorf("failed to get entry: %w", err) } // Проверяем тип if entry.Type != models.DataTypeBinary { return nil, fmt.Errorf("entry is not binary data, got type: %s", entry.Type) } // Проверяем что не удалено if entry.Deleted { return nil, fmt.Errorf("binary data is deleted") } // Расшифровываем данные decryptedData, err := crypto.Decrypt(entry.Data, encryptionKey) if err != nil { return nil, fmt.Errorf("failed to decrypt binary data: %w", err) } // Десериализуем var binary models.BinaryData if err := json.Unmarshal(decryptedData, &binary); err != nil { return nil, fmt.Errorf("failed to unmarshal binary data: %w", err) } return &binary, nil } // ListBinaryData returns all binary data entries for the user func (s *service) ListBinaryData(ctx context.Context, encryptionKey []byte) ([]*models.BinaryData, error) { // Получаем все активные entries типа binary entries, err := s.crdtStorage.GetEntriesByType(ctx, models.DataTypeBinary) if err != nil { return nil, fmt.Errorf("failed to get entries: %w", err) } binaryData := make([]*models.BinaryData, 0, len(entries)) for _, entry := range entries { // Расшифровываем данные decryptedData, err := crypto.Decrypt(entry.Data, encryptionKey) if err != nil { // Пропускаем поврежденные записи continue } // Десериализуем var binary models.BinaryData if err := json.Unmarshal(decryptedData, &binary); err != nil { // Пропускаем поврежденные записи continue } binaryData = append(binaryData, &binary) } return binaryData, nil } // DeleteBinaryData marks binary data as deleted (soft delete) func (s *service) DeleteBinaryData(ctx context.Context, id, nodeID string) error { now := time.Now() if err := s.crdtStorage.DeleteEntry(ctx, id, now.Unix(), nodeID); err != nil { return fmt.Errorf("failed to delete binary data: %w", err) } return nil } // AddCardData adds new card data to local storage func (s *service) AddCardData(ctx context.Context, userID, nodeID string, encryptionKey []byte, card *models.CardData) error { // Генерируем ID если не задан if card.ID == "" { card.ID = uuid.New().String() } // Сериализуем card data в JSON cardJSON, err := json.Marshal(card) if err != nil { return fmt.Errorf("failed to marshal card data: %w", err) } // Шифруем данные encryptedData, err := crypto.Encrypt(cardJSON, encryptionKey) if err != nil { return fmt.Errorf("failed to encrypt card data: %w", err) } // Сериализуем metadata metadataJSON, err := json.Marshal(card.Metadata) if err != nil { return fmt.Errorf("failed to marshal metadata: %w", err) } // Шифруем metadata encryptedMetadata, err := crypto.Encrypt(metadataJSON, encryptionKey) if err != nil { return fmt.Errorf("failed to encrypt metadata: %w", err) } // Создаем CRDT entry now := time.Now() entry := &models.CRDTEntry{ ID: card.ID, UserID: userID, Type: models.DataTypeCard, NodeID: nodeID, Data: encryptedData, Metadata: encryptedMetadata, Version: 1, Timestamp: now.Unix(), Deleted: false, CreatedAt: now, UpdatedAt: now, } // Сохраняем в локальное хранилище if err := s.crdtStorage.SaveEntry(ctx, entry); err != nil { return fmt.Errorf("failed to save entry: %w", err) } return nil } // GetCardData retrieves and decrypts card data by ID func (s *service) GetCardData(ctx context.Context, id string, encryptionKey []byte) (*models.CardData, error) { // Получаем CRDT entry entry, err := s.crdtStorage.GetEntry(ctx, id) if err != nil { return nil, fmt.Errorf("failed to get entry: %w", err) } // Проверяем тип if entry.Type != models.DataTypeCard { return nil, fmt.Errorf("entry is not card data, got type: %s", entry.Type) } // Проверяем что не удалено if entry.Deleted { return nil, fmt.Errorf("card data is deleted") } // Расшифровываем данные decryptedData, err := crypto.Decrypt(entry.Data, encryptionKey) if err != nil { return nil, fmt.Errorf("failed to decrypt card data: %w", err) } // Десериализуем var card models.CardData if err := json.Unmarshal(decryptedData, &card); err != nil { return nil, fmt.Errorf("failed to unmarshal card data: %w", err) } return &card, nil } // ListCardData returns all card data entries for the user func (s *service) ListCardData(ctx context.Context, encryptionKey []byte) ([]*models.CardData, error) { // Получаем все активные entries типа card entries, err := s.crdtStorage.GetEntriesByType(ctx, models.DataTypeCard) if err != nil { return nil, fmt.Errorf("failed to get entries: %w", err) } cardData := make([]*models.CardData, 0, len(entries)) for _, entry := range entries { // Расшифровываем данные decryptedData, err := crypto.Decrypt(entry.Data, encryptionKey) if err != nil { // Пропускаем поврежденные записи continue } // Десериализуем var card models.CardData if err := json.Unmarshal(decryptedData, &card); err != nil { // Пропускаем поврежденные записи continue } cardData = append(cardData, &card) } return cardData, nil } // DeleteCardData marks card data as deleted (soft delete) func (s *service) DeleteCardData(ctx context.Context, id, nodeID string) error { now := time.Now() if err := s.crdtStorage.DeleteEntry(ctx, id, now.Unix(), nodeID); err != nil { return fmt.Errorf("failed to delete card data: %w", err) } return nil }
package iocli import ( "bufio" "fmt" "os" "strings" "golang.org/x/term" ) type Stdio struct{} func NewStdio() IO { return &Stdio{} } func (s *Stdio) Println(a ...any) { fmt.Println(a...) } func (s *Stdio) Printf(format string, a ...any) { fmt.Printf(format, a...) } func (s *Stdio) ReadInput(prompt string) (string, error) { s.Printf("%s", prompt) reader := bufio.NewReader(os.Stdin) input, err := reader.ReadString('\n') if err != nil { return "", err } return strings.TrimSpace(input), nil } func (s *Stdio) ReadPassword(prompt string) (string, error) { s.Printf("%s", prompt) fd := int(os.Stdin.Fd()) pwBytes, err := term.ReadPassword(fd) s.Println("") if err != nil { return "", err } return string(pwBytes), nil } func (s *Stdio) Write(p []byte) (n int, err error) { // p содержит порцию байт, возможно без финального \n // Делаем безопасную преобразование и выводим через Println str := string(p) lines := strings.Split(str, "\n") for i, line := range lines { if i < len(lines)-1 { // Для всех строк кроме последней добавляем Println (с переводом строки) s.Println(line) } else { // Последняя может быть неполная, выводим Printf без новой строки if len(line) > 0 { s.Printf("%s", line) } } } return len(p), nil }
package boltdb import ( "context" "encoding/json" "fmt" "time" "go.etcd.io/bbolt" "github.com/iudanet/gophkeeper/internal/client/storage" ) var _ storage.AuthStorage = (*Storage)(nil) // если это реализовано (скорее всего да) var authKey = []byte("current") // SaveAuth сохраняет AuthData в BoltDB как есть, не шифрует токены func (s *Storage) SaveAuth(ctx context.Context, auth *storage.AuthData) error { data, err := json.Marshal(auth) if err != nil { return err } return s.db.Update(func(tx *bbolt.Tx) error { b := tx.Bucket(bucketAuth) if b == nil { return fmt.Errorf("auth bucket not found") } return b.Put(authKey, data) }) } // GetAuth получает данные аутентификации из BoltDB func (s *Storage) GetAuth(ctx context.Context) (*storage.AuthData, error) { var data []byte err := s.db.View(func(tx *bbolt.Tx) error { b := tx.Bucket(bucketAuth) if b == nil { return fmt.Errorf("auth bucket not found") } val := b.Get(authKey) if val == nil { return storage.ErrAuthNotFound } data = append([]byte(nil), val...) return nil }) if err != nil { return nil, err } var auth storage.AuthData if err := json.Unmarshal(data, &auth); err != nil { return nil, err } return &auth, nil } // DeleteAuth удаляет все данные авторизации func (s *Storage) DeleteAuth(ctx context.Context) error { return s.db.Update(func(tx *bbolt.Tx) error { bucket := tx.Bucket(bucketAuth) if bucket == nil { return fmt.Errorf("auth bucket not found") } if bucket.Get(authKey) == nil { return storage.ErrAuthNotFound } return bucket.Delete(authKey) }) } // IsAuthenticated проверяет, что в локальном хранилище есть валидные auth данные (токен не просрочен) func (s *Storage) IsAuthenticated(ctx context.Context) (bool, error) { auth, err := s.GetAuth(ctx) if err != nil { if err == storage.ErrAuthNotFound { return false, nil } return false, err } expiresAt := time.Unix(auth.ExpiresAt, 0) if time.Now().After(expiresAt) { return false, nil } return true, nil }
package boltdb import ( "context" "encoding/json" "fmt" "go.etcd.io/bbolt" "go.etcd.io/bbolt/errors" "github.com/iudanet/gophkeeper/internal/client/storage" "github.com/iudanet/gophkeeper/internal/models" ) var ( // crdtBucket stores CRDT entries crdtBucket = []byte("crdt") ) // SaveEntry stores or updates a CRDT entry in BoltDB func (s *Storage) SaveEntry(ctx context.Context, entry *models.CRDTEntry) error { if s.db == nil { return storage.ErrStorageClosed } // Сериализуем entry в JSON data, err := json.Marshal(entry) if err != nil { return fmt.Errorf("failed to marshal CRDT entry: %w", err) } err = s.db.Update(func(tx *bbolt.Tx) error { bucket, err := tx.CreateBucketIfNotExists(crdtBucket) if err != nil { return fmt.Errorf("failed to create bucket: %w", err) } // Сохраняем по ключу ID if err := bucket.Put([]byte(entry.ID), data); err != nil { return fmt.Errorf("failed to save entry: %w", err) } return nil }) if err != nil { return fmt.Errorf("transaction failed: %w", err) } return nil } // GetEntry retrieves a CRDT entry by ID func (s *Storage) GetEntry(ctx context.Context, id string) (*models.CRDTEntry, error) { if s.db == nil { return nil, storage.ErrStorageClosed } var entry *models.CRDTEntry err := s.db.View(func(tx *bbolt.Tx) error { bucket := tx.Bucket(crdtBucket) if bucket == nil { return storage.ErrEntryNotFound } data := bucket.Get([]byte(id)) if data == nil { return storage.ErrEntryNotFound } // Десериализуем entry = &models.CRDTEntry{} if err := json.Unmarshal(data, entry); err != nil { return fmt.Errorf("failed to unmarshal entry: %w", err) } return nil }) if err != nil { return nil, err } return entry, nil } // GetAllEntries returns all entries (including deleted ones) func (s *Storage) GetAllEntries(ctx context.Context) ([]*models.CRDTEntry, error) { if s.db == nil { return nil, storage.ErrStorageClosed } var entries []*models.CRDTEntry err := s.db.View(func(tx *bbolt.Tx) error { bucket := tx.Bucket(crdtBucket) if bucket == nil { // Нет bucket - возвращаем пустой массив return nil } return bucket.ForEach(func(k, v []byte) error { var entry models.CRDTEntry if err := json.Unmarshal(v, &entry); err != nil { return fmt.Errorf("failed to unmarshal entry: %w", err) } entries = append(entries, &entry) return nil }) }) if err != nil { return nil, fmt.Errorf("failed to get all entries: %w", err) } return entries, nil } // GetEntriesAfterTimestamp returns entries modified after specific timestamp func (s *Storage) GetEntriesAfterTimestamp(ctx context.Context, timestamp int64) ([]*models.CRDTEntry, error) { if s.db == nil { return nil, storage.ErrStorageClosed } var entries []*models.CRDTEntry err := s.db.View(func(tx *bbolt.Tx) error { bucket := tx.Bucket(crdtBucket) if bucket == nil { return nil } return bucket.ForEach(func(k, v []byte) error { var entry models.CRDTEntry if err := json.Unmarshal(v, &entry); err != nil { return fmt.Errorf("failed to unmarshal entry: %w", err) } // Фильтруем по timestamp if entry.Timestamp > timestamp { entries = append(entries, &entry) } return nil }) }) if err != nil { return nil, fmt.Errorf("failed to get entries after timestamp: %w", err) } return entries, nil } // GetActiveEntries returns all non-deleted entries func (s *Storage) GetActiveEntries(ctx context.Context) ([]*models.CRDTEntry, error) { if s.db == nil { return nil, storage.ErrStorageClosed } var entries []*models.CRDTEntry err := s.db.View(func(tx *bbolt.Tx) error { bucket := tx.Bucket(crdtBucket) if bucket == nil { return nil } return bucket.ForEach(func(k, v []byte) error { var entry models.CRDTEntry if err := json.Unmarshal(v, &entry); err != nil { return fmt.Errorf("failed to unmarshal entry: %w", err) } // Фильтруем deleted if !entry.Deleted { entries = append(entries, &entry) } return nil }) }) if err != nil { return nil, fmt.Errorf("failed to get active entries: %w", err) } return entries, nil } // GetEntriesByType returns all non-deleted entries of specific type func (s *Storage) GetEntriesByType(ctx context.Context, dataType string) ([]*models.CRDTEntry, error) { if s.db == nil { return nil, storage.ErrStorageClosed } var entries []*models.CRDTEntry err := s.db.View(func(tx *bbolt.Tx) error { bucket := tx.Bucket(crdtBucket) if bucket == nil { return nil } return bucket.ForEach(func(k, v []byte) error { var entry models.CRDTEntry if err := json.Unmarshal(v, &entry); err != nil { return fmt.Errorf("failed to unmarshal entry: %w", err) } // Фильтруем по типу и deleted if !entry.Deleted && entry.Type == dataType { entries = append(entries, &entry) } return nil }) }) if err != nil { return nil, fmt.Errorf("failed to get entries by type: %w", err) } return entries, nil } // DeleteEntry marks entry as deleted (soft delete) func (s *Storage) DeleteEntry(ctx context.Context, id string, timestamp int64, nodeID string) error { if s.db == nil { return storage.ErrStorageClosed } err := s.db.Update(func(tx *bbolt.Tx) error { bucket := tx.Bucket(crdtBucket) if bucket == nil { return storage.ErrEntryNotFound } // Получаем существующую запись data := bucket.Get([]byte(id)) if data == nil { return storage.ErrEntryNotFound } var entry models.CRDTEntry if err := json.Unmarshal(data, &entry); err != nil { return fmt.Errorf("failed to unmarshal entry: %w", err) } // Помечаем как удаленную entry.Deleted = true entry.Timestamp = timestamp entry.NodeID = nodeID // Сохраняем обратно updatedData, err := json.Marshal(&entry) if err != nil { return fmt.Errorf("failed to marshal updated entry: %w", err) } if err := bucket.Put([]byte(id), updatedData); err != nil { return fmt.Errorf("failed to save deleted entry: %w", err) } return nil }) if err != nil { return fmt.Errorf("delete transaction failed: %w", err) } return nil } // GetMaxTimestamp returns the maximum timestamp in the local store func (s *Storage) GetMaxTimestamp(ctx context.Context) (int64, error) { if s.db == nil { return 0, storage.ErrStorageClosed } var maxTimestamp int64 err := s.db.View(func(tx *bbolt.Tx) error { bucket := tx.Bucket(crdtBucket) if bucket == nil { // Нет bucket - возвращаем 0 return nil } return bucket.ForEach(func(k, v []byte) error { var entry models.CRDTEntry if err := json.Unmarshal(v, &entry); err != nil { return fmt.Errorf("failed to unmarshal entry: %w", err) } if entry.Timestamp > maxTimestamp { maxTimestamp = entry.Timestamp } return nil }) }) if err != nil { return 0, fmt.Errorf("failed to get max timestamp: %w", err) } return maxTimestamp, nil } // Clear removes all entries from storage func (s *Storage) Clear(ctx context.Context) error { if s.db == nil { return storage.ErrStorageClosed } err := s.db.Update(func(tx *bbolt.Tx) error { // Удаляем bucket полностью if err := tx.DeleteBucket(crdtBucket); err != nil && err != errors.ErrBucketNotFound { return fmt.Errorf("failed to delete bucket: %w", err) } // Создаем заново пустой bucket if _, err := tx.CreateBucket(crdtBucket); err != nil { return fmt.Errorf("failed to create bucket: %w", err) } return nil }) if err != nil { return fmt.Errorf("clear transaction failed: %w", err) } return nil }
package boltdb import ( "context" "encoding/binary" "fmt" "go.etcd.io/bbolt" ) const ( keyLastSyncTimestamp = "last_sync_timestamp" ) // SaveLastSyncTimestamp saves the timestamp of the last successful sync func (s *Storage) SaveLastSyncTimestamp(ctx context.Context, timestamp int64) error { return s.db.Update(func(tx *bbolt.Tx) error { bucket := tx.Bucket(bucketMetadata) if bucket == nil { return fmt.Errorf("metadata bucket not found") } // Конвертируем int64 в bytes timestampBytes := make([]byte, 8) binary.BigEndian.PutUint64(timestampBytes, uint64(timestamp)) // Сохраняем timestamp if err := bucket.Put([]byte(keyLastSyncTimestamp), timestampBytes); err != nil { return fmt.Errorf("failed to save last sync timestamp: %w", err) } return nil }) } // GetLastSyncTimestamp retrieves the timestamp of the last successful sync // Returns 0 if no sync has been performed yet func (s *Storage) GetLastSyncTimestamp(ctx context.Context) (int64, error) { var timestamp int64 err := s.db.View(func(tx *bbolt.Tx) error { bucket := tx.Bucket(bucketMetadata) if bucket == nil { return fmt.Errorf("metadata bucket not found") } // Получаем timestamp timestampBytes := bucket.Get([]byte(keyLastSyncTimestamp)) if timestampBytes == nil { // Если timestamp не найден, возвращаем 0 (первая синхронизация) timestamp = 0 return nil } // Конвертируем bytes в int64 timestamp = int64(binary.BigEndian.Uint64(timestampBytes)) return nil }) if err != nil { return 0, fmt.Errorf("failed to get last sync timestamp: %w", err) } return timestamp, nil }
package boltdb import ( "context" "encoding/json" "fmt" "go.etcd.io/bbolt" "github.com/iudanet/gophkeeper/internal/client/storage" ) // SaveSecret stores or updates a secret func (s *Storage) SaveSecret(ctx context.Context, secret *storage.Secret) error { return s.db.Update(func(tx *bbolt.Tx) error { bucket := tx.Bucket(bucketSecrets) if bucket == nil { return fmt.Errorf("secrets bucket not found") } // Сериализуем секрет в JSON data, err := json.Marshal(secret) if err != nil { return fmt.Errorf("failed to marshal secret: %w", err) } // Сохраняем по ID key := []byte(secret.ID) if err := bucket.Put(key, data); err != nil { return fmt.Errorf("failed to save secret: %w", err) } return nil }) } // GetSecret retrieves a secret by ID func (s *Storage) GetSecret(ctx context.Context, id string) (*storage.Secret, error) { var secret *storage.Secret err := s.db.View(func(tx *bbolt.Tx) error { bucket := tx.Bucket(bucketSecrets) if bucket == nil { return fmt.Errorf("secrets bucket not found") } // Получаем данные по ID data := bucket.Get([]byte(id)) if data == nil { return storage.ErrSecretNotFound } // Десериализуем secret = &storage.Secret{} if err := json.Unmarshal(data, secret); err != nil { return fmt.Errorf("failed to unmarshal secret: %w", err) } return nil }) if err != nil { return nil, err } return secret, nil } // ListSecrets returns all non-deleted secrets for the user func (s *Storage) ListSecrets(ctx context.Context, userID string) ([]*storage.Secret, error) { var secrets []*storage.Secret err := s.db.View(func(tx *bbolt.Tx) error { bucket := tx.Bucket(bucketSecrets) if bucket == nil { return fmt.Errorf("secrets bucket not found") } // Итерируемся по всем секретам return bucket.ForEach(func(k, v []byte) error { secret := &storage.Secret{} if err := json.Unmarshal(v, secret); err != nil { return fmt.Errorf("failed to unmarshal secret: %w", err) } // Фильтруем по userID и не удаленным if secret.UserID == userID && secret.DeletedAt == nil { secrets = append(secrets, secret) } return nil }) }) if err != nil { return nil, err } return secrets, nil } // ListSecretsByType returns all non-deleted secrets of specific type func (s *Storage) ListSecretsByType(ctx context.Context, userID string, secretType storage.SecretType) ([]*storage.Secret, error) { var secrets []*storage.Secret err := s.db.View(func(tx *bbolt.Tx) error { bucket := tx.Bucket(bucketSecrets) if bucket == nil { return fmt.Errorf("secrets bucket not found") } // Итерируемся по всем секретам return bucket.ForEach(func(k, v []byte) error { secret := &storage.Secret{} if err := json.Unmarshal(v, secret); err != nil { return fmt.Errorf("failed to unmarshal secret: %w", err) } // Фильтруем по userID, типу и не удаленным if secret.UserID == userID && secret.Type == secretType && secret.DeletedAt == nil { secrets = append(secrets, secret) } return nil }) }) if err != nil { return nil, err } return secrets, nil } // DeleteSecret marks secret as deleted (soft delete for CRDT sync) func (s *Storage) DeleteSecret(ctx context.Context, id string) error { return s.db.Update(func(tx *bbolt.Tx) error { bucket := tx.Bucket(bucketSecrets) if bucket == nil { return fmt.Errorf("secrets bucket not found") } // Получаем существующий секрет data := bucket.Get([]byte(id)) if data == nil { return storage.ErrSecretNotFound } // Десериализуем secret := &storage.Secret{} if err := json.Unmarshal(data, secret); err != nil { return fmt.Errorf("failed to unmarshal secret: %w", err) } // Помечаем как удаленный (soft delete) now := secret.UpdatedAt // используем текущее время обновления secret.DeletedAt = &now secret.Version++ // Сохраняем обратно updatedData, err := json.Marshal(secret) if err != nil { return fmt.Errorf("failed to marshal secret: %w", err) } if err := bucket.Put([]byte(id), updatedData); err != nil { return fmt.Errorf("failed to update secret: %w", err) } return nil }) } // GetSecretsAfterVersion returns secrets modified after specific version (for sync) func (s *Storage) GetSecretsAfterVersion(ctx context.Context, userID string, version int64) ([]*storage.Secret, error) { var secrets []*storage.Secret err := s.db.View(func(tx *bbolt.Tx) error { bucket := tx.Bucket(bucketSecrets) if bucket == nil { return fmt.Errorf("secrets bucket not found") } // Итерируемся по всем секретам return bucket.ForEach(func(k, v []byte) error { secret := &storage.Secret{} if err := json.Unmarshal(v, secret); err != nil { return fmt.Errorf("failed to unmarshal secret: %w", err) } // Фильтруем по userID и версии if secret.UserID == userID && secret.Version > version { secrets = append(secrets, secret) } return nil }) }) if err != nil { return nil, err } return secrets, nil }
package boltdb import ( "context" "fmt" "go.etcd.io/bbolt" ) var ( // BoltDB bucket names bucketAuth = []byte("auth") bucketSecrets = []byte("secrets") bucketMetadata = []byte("metadata") ) // Storage represents BoltDB storage implementation for client type Storage struct { db *bbolt.DB } // New creates a new BoltDB storage instance // dbPath is the path to the BoltDB database file func New(ctx context.Context, dbPath string) (*Storage, error) { // Открываем BoltDB db, err := bbolt.Open(dbPath, 0600, nil) if err != nil { return nil, fmt.Errorf("failed to open boltdb: %w", err) } storage := &Storage{db: db} // Инициализируем buckets if err := storage.initBuckets(); err != nil { _ = db.Close() return nil, fmt.Errorf("failed to initialize buckets: %w", err) } return storage, nil } // Close closes the database connection func (s *Storage) Close() error { if s.db == nil { return nil } err := s.db.Close() s.db = nil // Устанавливаем в nil после закрытия return err } // initBuckets создает необходимые buckets если они не существуют func (s *Storage) initBuckets() error { return s.db.Update(func(tx *bbolt.Tx) error { // Создаем bucket для аутентификационных данных if _, err := tx.CreateBucketIfNotExists(bucketAuth); err != nil { return fmt.Errorf("failed to create auth bucket: %w", err) } // Создаем bucket для секретов if _, err := tx.CreateBucketIfNotExists(bucketSecrets); err != nil { return fmt.Errorf("failed to create secrets bucket: %w", err) } // Создаем bucket для метаданных if _, err := tx.CreateBucketIfNotExists(bucketMetadata); err != nil { return fmt.Errorf("failed to create metadata bucket: %w", err) } return nil }) }
package sync import ( "context" "fmt" "log/slog" httpClient "github.com/iudanet/gophkeeper/internal/client/api" "github.com/iudanet/gophkeeper/internal/client/storage" "github.com/iudanet/gophkeeper/internal/models" "github.com/iudanet/gophkeeper/pkg/api" ) //go:generate moq -out service_mock.go . Service // Service определяет интерфейс для sync.Service type Service interface { // Sync выполняет полную синхронизацию с сервером Sync(ctx context.Context, userID, accessToken string) (*SyncResult, error) // GetPendingSyncCount возвращает количество записей, ожидающих синхронизации GetPendingSyncCount(ctx context.Context) (int, error) } // Service handles synchronization between client and server type service struct { apiClient httpClient.ClientAPI crdtStorage storage.CRDTStorage metadataStorage storage.MetadataStorage logger *slog.Logger } // NewService creates a new sync service func NewService(apiClient httpClient.ClientAPI, crdtStorage storage.CRDTStorage, metadataStorage storage.MetadataStorage, logger *slog.Logger) Service { return &service{ apiClient: apiClient, crdtStorage: crdtStorage, metadataStorage: metadataStorage, logger: logger, } } // SyncResult contains sync operation results type SyncResult struct { PushedEntries int // количество отправленных на сервер записей PulledEntries int // количество полученных с сервера записей MergedEntries int // количество успешно слитых записей Conflicts int // количество разрешённых конфликтов SkippedEntries int // количество пропущенных записей (ошибки мержа) } // Sync performs full synchronization with server // 1. Pushes local changes to server // 2. Pulls server changes // 3. Merges server changes into local storage using CRDT rules func (s *service) Sync(ctx context.Context, userID, accessToken string) (*SyncResult, error) { s.logger.Info("Starting synchronization", "user_id", userID) result := &SyncResult{} // Получаем last known server timestamp из metadata storage lastSyncTimestamp, err := s.metadataStorage.GetLastSyncTimestamp(ctx) if err != nil { s.logger.Warn("Failed to get last sync timestamp, using 0", "error", err) lastSyncTimestamp = 0 } // Получаем локальные изменения после последней синхронизации localEntries, err := s.crdtStorage.GetEntriesAfterTimestamp(ctx, lastSyncTimestamp) if err != nil { return nil, fmt.Errorf("failed to get local entries: %w", err) } s.logger.Info("Collected local changes", "count", len(localEntries)) result.PushedEntries = len(localEntries) // Конвертируем локальные entries в API формат apiEntries := make([]api.CRDTEntry, 0, len(localEntries)) for _, entry := range localEntries { apiEntry := api.CRDTEntry{ ID: entry.ID, UserID: entry.UserID, DataType: entry.Type, Data: entry.Data, Metadata: string(entry.Metadata), Timestamp: entry.Timestamp, Deleted: entry.Deleted, CreatedAt: entry.CreatedAt, UpdatedAt: entry.UpdatedAt, } apiEntries = append(apiEntries, apiEntry) } // Отправляем запрос на синхронизацию syncReq := api.SyncRequest{ Entries: apiEntries, Since: lastSyncTimestamp, } syncResp, err := s.apiClient.Sync(ctx, accessToken, syncReq) if err != nil { return nil, fmt.Errorf("sync request failed: %w", err) } s.logger.Info("Received server response", "server_entries", len(syncResp.Entries), "conflicts", syncResp.Conflicts, "server_timestamp", syncResp.CurrentTimestamp) result.PulledEntries = len(syncResp.Entries) result.Conflicts = syncResp.Conflicts // Мержим изменения с сервера в локальное хранилище for _, apiEntry := range syncResp.Entries { // Конвертируем API entry в models.CRDTEntry entry := &models.CRDTEntry{ ID: apiEntry.ID, UserID: apiEntry.UserID, Type: apiEntry.DataType, Data: apiEntry.Data, Metadata: []byte(apiEntry.Metadata), Timestamp: apiEntry.Timestamp, Deleted: apiEntry.Deleted, CreatedAt: apiEntry.CreatedAt, UpdatedAt: apiEntry.UpdatedAt, // NodeID и Version берутся из серверной записи } // Применяем CRDT merge: SaveEntry использует LWW (Last-Write-Wins) логику // Запись сохранится только если её timestamp больше существующей updated, err := s.mergeEntry(ctx, entry) if err != nil { s.logger.Warn("Failed to merge entry", "entry_id", entry.ID, "error", err) result.SkippedEntries++ continue } if updated { result.MergedEntries++ } } s.logger.Info("Synchronization completed", "pushed", result.PushedEntries, "pulled", result.PulledEntries, "merged", result.MergedEntries, "skipped", result.SkippedEntries, "conflicts", result.Conflicts) // Сохраняем текущий server timestamp для следующей синхронизации if err := s.metadataStorage.SaveLastSyncTimestamp(ctx, syncResp.CurrentTimestamp); err != nil { s.logger.Warn("Failed to save last sync timestamp", "error", err) // Не прерываем синхронизацию из-за ошибки сохранения timestamp } return result, nil } // mergeEntry применяет CRDT правила для слияния записи // Использует LWW-Element-Set с (timestamp, node_id) для разрешения конфликтов // Возвращает (updated bool, err error) где updated указывает была ли запись обновлена func (s *service) mergeEntry(ctx context.Context, newEntry *models.CRDTEntry) (bool, error) { // Пытаемся получить существующую запись existingEntry, err := s.crdtStorage.GetEntry(ctx, newEntry.ID) if err != nil { // Если записи нет - просто сохраняем новую if err == storage.ErrEntryNotFound { return true, s.crdtStorage.SaveEntry(ctx, newEntry) } return false, fmt.Errorf("failed to get existing entry: %w", err) } // Применяем LWW правила: // 1. Сравниваем timestamps // 2. Если timestamps равны - сравниваем NodeID (детерминированно) shouldUpdate := false if newEntry.Timestamp > existingEntry.Timestamp { // Новая запись более свежая shouldUpdate = true } else if newEntry.Timestamp == existingEntry.Timestamp { // Timestamps равны - используем NodeID для детерминированного выбора // Используем лексикографическое сравнение NodeID if newEntry.NodeID > existingEntry.NodeID { shouldUpdate = true } } // Обновляем только если новая запись побеждает if shouldUpdate { s.logger.Debug("Merging entry (new wins)", "entry_id", newEntry.ID, "new_timestamp", newEntry.Timestamp, "old_timestamp", existingEntry.Timestamp) return true, s.crdtStorage.SaveEntry(ctx, newEntry) } s.logger.Debug("Skipping entry (existing is newer)", "entry_id", newEntry.ID, "new_timestamp", newEntry.Timestamp, "old_timestamp", existingEntry.Timestamp) return false, nil } // GetPendingSyncCount возвращает количество записей, ожидающих синхронизации // Использует lastSyncTimestamp из metadata storage для определения несинхронизированных записей func (s *service) GetPendingSyncCount(ctx context.Context) (int, error) { // Получаем last sync timestamp lastSyncTimestamp, err := s.metadataStorage.GetLastSyncTimestamp(ctx) if err != nil { // Если timestamp не найден (первая синхронизация), используем 0 s.logger.Debug("No last sync timestamp found, using 0", "error", err) lastSyncTimestamp = 0 } // Получаем все записи после последней синхронизации entries, err := s.crdtStorage.GetEntriesAfterTimestamp(ctx, lastSyncTimestamp) if err != nil { return 0, fmt.Errorf("failed to get pending entries: %w", err) } return len(entries), nil }
package crdt import ( "sync" "github.com/google/uuid" ) // LamportClock представляет логические часы Лампорта для упорядочивания событий // в распределенной системе без необходимости синхронизации физического времени. type LamportClock struct { nodeID string // уникальный идентификатор узла counter int64 // монотонно возрастающий счетчик mu sync.Mutex // мьютекс для потокобезопасности } // NewLamportClock создает новый экземпляр логических часов Лампорта // с уникальным идентификатором узла (UUID). func NewLamportClock() *LamportClock { return &LamportClock{ counter: 0, nodeID: uuid.New().String(), } } // NewLamportClockWithNodeID создает новый экземпляр логических часов Лампорта // с заданным идентификатором узла. Используется для тестирования или восстановления состояния. func NewLamportClockWithNodeID(nodeID string) *LamportClock { return &LamportClock{ counter: 0, nodeID: nodeID, } } // Tick увеличивает счетчик и возвращает новое значение timestamp. // Используется при создании нового локального события. func (lc *LamportClock) Tick() int64 { lc.mu.Lock() defer lc.mu.Unlock() lc.counter++ return lc.counter } // Update обновляет счетчик на основе полученного удаленного timestamp. // Используется при получении события от другого узла для синхронизации. // Согласно алгоритму Лампорта: counter = max(local_counter, remote_timestamp) + 1 func (lc *LamportClock) Update(remoteTimestamp int64) int64 { lc.mu.Lock() defer lc.mu.Unlock() if remoteTimestamp > lc.counter { lc.counter = remoteTimestamp } lc.counter++ return lc.counter } // GetTimestamp возвращает текущее значение счетчика без его изменения. // Используется для чтения текущего состояния часов. func (lc *LamportClock) GetTimestamp() int64 { lc.mu.Lock() defer lc.mu.Unlock() return lc.counter } // GetNodeID возвращает уникальный идентификатор узла. func (lc *LamportClock) GetNodeID() string { lc.mu.Lock() defer lc.mu.Unlock() return lc.nodeID } // SetTimestamp устанавливает счетчик в заданное значение. // Используется для восстановления состояния часов (например, после перезапуска). func (lc *LamportClock) SetTimestamp(timestamp int64) { lc.mu.Lock() defer lc.mu.Unlock() lc.counter = timestamp }
package crdt import ( "sync" "github.com/iudanet/gophkeeper/internal/models" ) // LWWSet представляет Last-Write-Wins Element Set CRDT. // Это структура данных, которая автоматически разрешает конфликты // при репликации данных между несколькими узлами. type LWWSet struct { elements map[string]*models.CRDTEntry // map[id]entry mu sync.RWMutex // мьютекс для потокобезопасности } // NewLWWSet создает новый экземпляр LWW-Element-Set. func NewLWWSet() *LWWSet { return &LWWSet{ elements: make(map[string]*models.CRDTEntry), } } // Add добавляет новый элемент в set или обновляет существующий, // если новая версия имеет больший timestamp. // Возвращает true, если элемент был добавлен/обновлен. func (s *LWWSet) Add(entry *models.CRDTEntry) bool { s.mu.Lock() defer s.mu.Unlock() existing, exists := s.elements[entry.ID] // Если элемента нет - добавляем if !exists { s.elements[entry.ID] = entry.Clone() return true } // Если новая версия новее - обновляем if entry.IsNewerThan(existing) { s.elements[entry.ID] = entry.Clone() return true } // Существующая версия новее - не обновляем return false } // Update обновляет существующий элемент новыми данными. // Это алиас для Add, так как логика одинаковая. func (s *LWWSet) Update(entry *models.CRDTEntry) bool { return s.Add(entry) } // Remove помечает элемент как удаленный (soft delete). // Физически элемент остается в set, но с флагом Deleted = true. // Возвращает true, если элемент был помечен как удаленный. func (s *LWWSet) Remove(entry *models.CRDTEntry) bool { s.mu.Lock() defer s.mu.Unlock() existing, exists := s.elements[entry.ID] // Если элемента нет - добавляем как удаленный if !exists { deletedEntry := entry.Clone() deletedEntry.Deleted = true s.elements[entry.ID] = deletedEntry return true } // Если новая версия новее - обновляем и помечаем как удаленный if entry.IsNewerThan(existing) { deletedEntry := entry.Clone() deletedEntry.Deleted = true s.elements[entry.ID] = deletedEntry return true } return false } // Get возвращает элемент по ID. // Возвращает nil, если элемент не найден или помечен как удаленный. func (s *LWWSet) Get(id string) *models.CRDTEntry { s.mu.RLock() defer s.mu.RUnlock() entry, exists := s.elements[id] if !exists || entry.Deleted { return nil } return entry.Clone() } // GetAll возвращает все неудаленные элементы. func (s *LWWSet) GetAll() []*models.CRDTEntry { s.mu.RLock() defer s.mu.RUnlock() result := make([]*models.CRDTEntry, 0, len(s.elements)) for _, entry := range s.elements { if !entry.Deleted { result = append(result, entry.Clone()) } } return result } // GetAllIncludingDeleted возвращает все элементы, включая удаленные. // Используется для синхронизации с другими узлами. func (s *LWWSet) GetAllIncludingDeleted() []*models.CRDTEntry { s.mu.RLock() defer s.mu.RUnlock() result := make([]*models.CRDTEntry, 0, len(s.elements)) for _, entry := range s.elements { result = append(result, entry.Clone()) } return result } // Merge объединяет текущий set с другим set. // Для каждого элемента применяется правило LWW (Last-Write-Wins): // - Берется элемент с большим timestamp // - При равных timestamp берется элемент с большим nodeID (для детерминизма) // Операция коммутативна и идемпотентна. func (s *LWWSet) Merge(other *LWWSet) { s.mu.Lock() defer s.mu.Unlock() other.mu.RLock() defer other.mu.RUnlock() for id, otherEntry := range other.elements { existing, exists := s.elements[id] // Если элемента нет - добавляем if !exists { s.elements[id] = otherEntry.Clone() continue } // Если элемент из другого set новее - обновляем if otherEntry.IsNewerThan(existing) { s.elements[id] = otherEntry.Clone() } } } // Size возвращает количество неудаленных элементов в set. func (s *LWWSet) Size() int { s.mu.RLock() defer s.mu.RUnlock() count := 0 for _, entry := range s.elements { if !entry.Deleted { count++ } } return count } // TotalSize возвращает общее количество элементов (включая удаленные). func (s *LWWSet) TotalSize() int { s.mu.RLock() defer s.mu.RUnlock() return len(s.elements) } // Contains проверяет наличие неудаленного элемента с заданным ID. func (s *LWWSet) Contains(id string) bool { s.mu.RLock() defer s.mu.RUnlock() entry, exists := s.elements[id] return exists && !entry.Deleted } // Clear удаляет все элементы из set. // Используется для очистки локального хранилища. func (s *LWWSet) Clear() { s.mu.Lock() defer s.mu.Unlock() s.elements = make(map[string]*models.CRDTEntry) }
package crypto import ( "crypto/aes" "crypto/cipher" "crypto/rand" "encoding/base64" "fmt" ) const ( // NonceSize - размер nonce для AES-GCM (12 bytes стандартный размер) NonceSize = 12 ) // Encrypt шифрует данные с использованием AES-256-GCM // Формат результата: nonce (12 bytes) + ciphertext + auth_tag (16 bytes) // Возвращает зашифрованные данные в виде байтов func Encrypt(plaintext, key []byte) ([]byte, error) { if len(plaintext) == 0 { return nil, fmt.Errorf("plaintext cannot be empty") } if len(key) != 32 { return nil, fmt.Errorf("encryption key must be 32 bytes, got %d", len(key)) } // Создаем AES cipher block block, err := aes.NewCipher(key) if err != nil { return nil, fmt.Errorf("failed to create cipher: %w", err) } // Создаем GCM mode aesGCM, err := cipher.NewGCM(block) if err != nil { return nil, fmt.Errorf("failed to create GCM: %w", err) } // Генерируем случайный nonce nonce := make([]byte, NonceSize) if _, err := rand.Read(nonce); err != nil { return nil, fmt.Errorf("failed to generate nonce: %w", err) } // Шифруем данные // GCM автоматически добавляет authentication tag в конец ciphertext := aesGCM.Seal(nil, nonce, plaintext, nil) // Формируем результат: nonce + ciphertext + auth_tag result := make([]byte, 0, len(nonce)+len(ciphertext)) result = append(result, nonce...) result = append(result, ciphertext...) return result, nil } // EncryptToBase64 шифрует данные и возвращает результат в Base64 // Удобно для передачи по сети и хранения в JSON func EncryptToBase64(plaintext, key []byte) (string, error) { encrypted, err := Encrypt(plaintext, key) if err != nil { return "", err } return base64.StdEncoding.EncodeToString(encrypted), nil } // Decrypt дешифрует данные, зашифрованные с помощью Encrypt // Ожидает формат: nonce (12 bytes) + ciphertext + auth_tag (16 bytes) func Decrypt(encrypted, key []byte) ([]byte, error) { if len(encrypted) < NonceSize { return nil, fmt.Errorf("encrypted data too short") } if len(key) != 32 { return nil, fmt.Errorf("encryption key must be 32 bytes, got %d", len(key)) } // Создаем AES cipher block block, err := aes.NewCipher(key) if err != nil { return nil, fmt.Errorf("failed to create cipher: %w", err) } // Создаем GCM mode aesGCM, err := cipher.NewGCM(block) if err != nil { return nil, fmt.Errorf("failed to create GCM: %w", err) } // Извлекаем nonce из первых 12 bytes nonce := encrypted[:NonceSize] ciphertext := encrypted[NonceSize:] // Дешифруем и проверяем authentication tag plaintext, err := aesGCM.Open(nil, nonce, ciphertext, nil) if err != nil { return nil, fmt.Errorf("failed to decrypt: authentication failed or corrupted data: %w", err) } return plaintext, nil } // DecryptFromBase64 дешифрует данные из Base64 func DecryptFromBase64(encryptedBase64 string, key []byte) ([]byte, error) { encrypted, err := base64.StdEncoding.DecodeString(encryptedBase64) if err != nil { return nil, fmt.Errorf("failed to decode base64: %w", err) } return Decrypt(encrypted, key) }
package crypto import ( "crypto/sha256" "encoding/hex" "fmt" ) // HashAuthKey хеширует auth_key с использованием SHA256 // Используется на клиенте и сервере для детерминированного хеширования // auth_key уже защищен через Argon2id, SHA256 добавляет дополнительный слой func HashAuthKey(authKey []byte) (string, error) { if len(authKey) == 0 { return "", fmt.Errorf("auth key cannot be empty") } // SHA256 хеширование hash := sha256.Sum256(authKey) // Возвращаем hex-encoded строку return hex.EncodeToString(hash[:]), nil } // VerifyAuthKey проверяет, соответствует ли auth_key сохраненному хешу // Используется на сервере для аутентификации пользователя // Просто сравнивает два SHA256 хеша (детерминированные) func VerifyAuthKey(authKey []byte, hashedAuthKey string) error { if len(authKey) == 0 { return fmt.Errorf("auth key cannot be empty") } if hashedAuthKey == "" { return fmt.Errorf("hashed auth key cannot be empty") } // Вычисляем хеш от переданного ключа computedHash, err := HashAuthKey(authKey) if err != nil { return fmt.Errorf("failed to compute auth key hash: %w", err) } // Сравниваем хеши if computedHash != hashedAuthKey { return fmt.Errorf("invalid auth key") } return nil }
package crypto import ( "crypto/rand" "encoding/base64" "fmt" "golang.org/x/crypto/argon2" ) // Keys содержит производные ключи для аутентификации и шифрования type Keys struct { AuthKey []byte // ключ для аутентификации на сервере (32 bytes) EncryptionKey []byte // ключ для шифрования данных (32 bytes) } // Параметры Argon2id согласно технической спецификации const ( // Argon2Time - количество итераций (time cost) Argon2Time = 1 // Argon2Memory - объем памяти в KB (64MB = 64*1024 KB) Argon2Memory = 64 * 1024 // Argon2Threads - количество параллельных потоков Argon2Threads = 4 // Argon2KeyLen - длина выходного ключа в байтах Argon2KeyLen = 32 // SaltSize - размер соли в байтах SaltSize = 32 ) // GenerateSalt генерирует криптографически случайную соль указанного размера func GenerateSalt() ([]byte, error) { salt := make([]byte, SaltSize) _, err := rand.Read(salt) if err != nil { return nil, fmt.Errorf("failed to generate salt: %w", err) } return salt, nil } // GenerateSaltBase64 генерирует криптографически случайную соль и возвращает ее в Base64 func GenerateSaltBase64() (string, error) { salt, err := GenerateSalt() if err != nil { return "", err } return base64.StdEncoding.EncodeToString(salt), nil } // DeriveKeys генерирует два независимых ключа из master password: // - AuthKey для аутентификации на сервере // - EncryptionKey для шифрования данных // Использует Argon2id с разными context strings для независимости ключей func DeriveKeys(masterPassword, username string, salt []byte) (*Keys, error) { if masterPassword == "" { return nil, fmt.Errorf("master password cannot be empty") } if username == "" { return nil, fmt.Errorf("username cannot be empty") } if len(salt) != SaltSize { return nil, fmt.Errorf("salt must be %d bytes, got %d", SaltSize, len(salt)) } // Создаем базовый материал для деривации baseInput := []byte(masterPassword + username) // Генерируем AuthKey с context "auth" authContext := append(baseInput, []byte("auth")...) authKey := argon2.IDKey(authContext, salt, Argon2Time, Argon2Memory, Argon2Threads, Argon2KeyLen) // Генерируем EncryptionKey с context "encrypt" encryptContext := append(baseInput, []byte("encrypt")...) encryptionKey := argon2.IDKey(encryptContext, salt, Argon2Time, Argon2Memory, Argon2Threads, Argon2KeyLen) return &Keys{ AuthKey: authKey, EncryptionKey: encryptionKey, }, nil } // DeriveKeysFromBase64Salt генерирует ключи из Base64-кодированной соли func DeriveKeysFromBase64Salt(masterPassword, username, saltBase64 string) (*Keys, error) { salt, err := base64.StdEncoding.DecodeString(saltBase64) if err != nil { return nil, fmt.Errorf("failed to decode salt: %w", err) } return DeriveKeys(masterPassword, username, salt) }
package models import "time" // CRDTEntry представляет элемент в CRDT (Conflict-free Replicated Data Type). // Используется для синхронизации данных между несколькими клиентами // с автоматическим разрешением конфликтов. type CRDTEntry struct { CreatedAt time.Time `json:"created_at"` // CreatedAt время создания записи (для информации) UpdatedAt time.Time `json:"updated_at"` // UpdatedAt время последнего обновления (для информации) ID string `json:"id"` // ID уникальный идентификатор записи (UUID) UserID string `json:"user_id"` // UserID идентификатор владельца записи Type string `json:"type"` // Type тип данных: "credential", "text", "binary", "card" NodeID string `json:"node_id"` // NodeID идентификатор узла (клиента), создавшего эту версию Data []byte `json:"data"` // Data зашифрованные данные (JSON сериализованный и зашифрованный объект) Metadata []byte `json:"metadata"` // Metadata зашифрованные метаданные Version int64 `json:"version"` // Version монотонно растущая версия записи Timestamp int64 `json:"timestamp"` // Timestamp Lamport timestamp для упорядочивания событий Deleted bool `json:"deleted"` // Deleted флаг soft delete (true = запись удалена) } // DataType константы для типов данных const ( DataTypeCredential = "credential" DataTypeText = "text" DataTypeBinary = "binary" DataTypeCard = "card" ) // IsNewerThan сравнивает две CRDT записи и определяет, какая из них новее. // Согласно алгоритму LWW (Last-Write-Wins): // 1. Сначала сравнивается Timestamp (больший выигрывает) // 2. При равных Timestamp сравнивается NodeID (лексикографически) // Возвращает true, если current запись новее, чем other. func (e *CRDTEntry) IsNewerThan(other *CRDTEntry) bool { if e.Timestamp > other.Timestamp { return true } if e.Timestamp < other.Timestamp { return false } // Timestamps равны - сравниваем NodeID для детерминизма return e.NodeID > other.NodeID } // Clone создает глубокую копию CRDT записи func (e *CRDTEntry) Clone() *CRDTEntry { data := make([]byte, len(e.Data)) copy(data, e.Data) metadata := make([]byte, len(e.Metadata)) copy(metadata, e.Metadata) return &CRDTEntry{ ID: e.ID, UserID: e.UserID, Type: e.Type, Data: data, Metadata: metadata, Version: e.Version, Timestamp: e.Timestamp, NodeID: e.NodeID, Deleted: e.Deleted, CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, } }
package handlers import ( "crypto/sha256" "encoding/hex" "encoding/json" "errors" "log/slog" "net/http" "time" "github.com/google/uuid" "github.com/iudanet/gophkeeper/internal/models" "github.com/iudanet/gophkeeper/internal/server/storage" "github.com/iudanet/gophkeeper/internal/validation" "github.com/iudanet/gophkeeper/pkg/api" ) // AuthHandler обрабатывает запросы авторизации type AuthHandler struct { logger *slog.Logger userStorage storage.UserStorage tokenStorage storage.TokenStorage jwtConfig JWTConfig } // NewAuthHandler создает новый handler для авторизации func NewAuthHandler(logger *slog.Logger, userStorage storage.UserStorage, tokenStorage storage.TokenStorage, jwtConfig JWTConfig) *AuthHandler { return &AuthHandler{ logger: logger, userStorage: userStorage, tokenStorage: tokenStorage, jwtConfig: jwtConfig, } } // Register обрабатывает POST /api/v1/auth/register // Регистрация нового пользователя func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Парсим request body var req api.RegisterRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { h.logger.ErrorContext(ctx, "failed to decode register request", slog.Any("error", err)) h.sendError(w, "invalid request body", http.StatusBadRequest) return } // Валидация username if err := validation.ValidateUsername(req.Username); err != nil { h.logger.WarnContext(ctx, "invalid username", slog.String("username", req.Username), slog.Any("error", err)) h.sendError(w, err.Error(), http.StatusBadRequest) return } // Проверка обязательных полей if req.AuthKeyHash == "" { h.sendError(w, "auth_key_hash is required", http.StatusBadRequest) return } if req.PublicSalt == "" { h.sendError(w, "public_salt is required", http.StatusBadRequest) return } // Генерируем UUID для пользователя userID := uuid.New().String() // Создаем пользователя user := &models.User{ ID: userID, Username: req.Username, AuthKeyHash: req.AuthKeyHash, // SHA256 хеш auth_key от клиента PublicSalt: req.PublicSalt, CreatedAt: time.Now(), } // Сохраняем в БД if err := h.userStorage.CreateUser(ctx, user); err != nil { if errors.Is(err, storage.ErrUserAlreadyExists) { h.logger.WarnContext(ctx, "user already exists", slog.String("username", req.Username)) h.sendError(w, "username already taken", http.StatusConflict) return } h.logger.ErrorContext(ctx, "failed to create user", slog.Any("error", err)) h.sendError(w, "internal server error", http.StatusInternalServerError) return } h.logger.InfoContext(ctx, "user registered successfully", slog.String("username", req.Username), slog.String("user_id", userID)) resp := api.RegisterResponse{ UserID: userID, Message: "User registered successfully", } h.sendJSON(w, resp, http.StatusCreated) } // GetSalt обрабатывает GET /api/v1/auth/salt/{username} // Получение public_salt пользователя func (h *AuthHandler) GetSalt(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Извлекаем username из path parameter (Go 1.22+) username := r.PathValue("username") if username == "" { h.sendError(w, "username is required", http.StatusBadRequest) return } // Валидация username if err := validation.ValidateUsername(username); err != nil { h.logger.WarnContext(ctx, "invalid username", slog.String("username", username), slog.Any("error", err)) h.sendError(w, err.Error(), http.StatusBadRequest) return } // Получаем пользователя из БД user, err := h.userStorage.GetUserByUsername(ctx, username) if err != nil { if errors.Is(err, storage.ErrUserNotFound) { h.logger.WarnContext(ctx, "user not found", slog.String("username", username)) h.sendError(w, "user not found", http.StatusNotFound) return } h.logger.ErrorContext(ctx, "failed to get user", slog.Any("error", err)) h.sendError(w, "internal server error", http.StatusInternalServerError) return } h.logger.InfoContext(ctx, "returning public salt", slog.String("username", username)) resp := api.SaltResponse{ PublicSalt: user.PublicSalt, } h.sendJSON(w, resp, http.StatusOK) } // Login обрабатывает POST /api/v1/auth/login // Аутентификация пользователя func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Парсим request body var req api.LoginRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { h.logger.ErrorContext(ctx, "failed to decode login request", slog.Any("error", err)) h.sendError(w, "invalid request body", http.StatusBadRequest) return } // Валидация username if err := validation.ValidateUsername(req.Username); err != nil { h.logger.WarnContext(ctx, "invalid username", slog.String("username", req.Username), slog.Any("error", err)) h.sendError(w, err.Error(), http.StatusBadRequest) return } // Проверка обязательных полей if req.AuthKeyHash == "" { h.sendError(w, "auth_key_hash is required", http.StatusBadRequest) return } // Получаем пользователя из БД user, err := h.userStorage.GetUserByUsername(ctx, req.Username) if err != nil { if errors.Is(err, storage.ErrUserNotFound) { h.logger.WarnContext(ctx, "login failed: user not found", slog.String("username", req.Username)) h.sendError(w, "invalid credentials", http.StatusUnauthorized) return } h.logger.ErrorContext(ctx, "failed to get user", slog.Any("error", err)) h.sendError(w, "internal server error", http.StatusInternalServerError) return } // Проверяем auth_key_hash // Клиент отправляет SHA256 хеш от auth_key (детерминированный) // Сервер сравнивает с сохраненным хешем (простое строковое сравнение) if user.AuthKeyHash != req.AuthKeyHash { h.logger.WarnContext(ctx, "login failed: invalid auth key", slog.String("username", req.Username)) h.sendError(w, "invalid credentials", http.StatusUnauthorized) return } // Генерируем JWT access token accessToken, expiresIn, err := GenerateAccessToken(h.jwtConfig, user.ID, user.Username) if err != nil { h.logger.ErrorContext(ctx, "failed to generate access token", slog.Any("error", err)) h.sendError(w, "internal server error", http.StatusInternalServerError) return } // Генерируем refresh token refreshToken, expiresAt, err := GenerateRefreshToken(h.jwtConfig) if err != nil { h.logger.ErrorContext(ctx, "failed to generate refresh token", slog.Any("error", err)) h.sendError(w, "internal server error", http.StatusInternalServerError) return } hashedRefreshToken := hashToken(refreshToken) // Сохраняем refresh token в БД token := &models.RefreshToken{ Token: hashedRefreshToken, UserID: user.ID, ExpiresAt: expiresAt, CreatedAt: time.Now(), } if err := h.tokenStorage.SaveRefreshToken(ctx, token); err != nil { h.logger.ErrorContext(ctx, "failed to save refresh token", slog.Any("error", err)) h.sendError(w, "internal server error", http.StatusInternalServerError) return } // Обновляем last_login now := time.Now() if err := h.userStorage.UpdateLastLogin(ctx, user.ID, now); err != nil { // Не критичная ошибка, логируем но не прерываем h.logger.WarnContext(ctx, "failed to update last login", slog.Any("error", err)) } h.logger.InfoContext(ctx, "user logged in successfully", slog.String("username", req.Username), slog.String("user_id", user.ID)) resp := api.TokenResponse{ UserID: user.ID, AccessToken: accessToken, RefreshToken: refreshToken, ExpiresIn: expiresIn, } h.sendJSON(w, resp, http.StatusOK) } // Refresh обрабатывает POST /api/v1/auth/refresh // Обновление access token с помощью refresh token func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Извлекаем refresh token из Authorization header authHeader := r.Header.Get("Authorization") if authHeader == "" { h.sendError(w, "Authorization header is required", http.StatusUnauthorized) return } // Проверяем формат "Bearer <token>" const bearerPrefix = "Bearer " if len(authHeader) < len(bearerPrefix) || authHeader[:len(bearerPrefix)] != bearerPrefix { h.sendError(w, "invalid Authorization header format", http.StatusUnauthorized) return } refreshToken := authHeader[len(bearerPrefix):] if refreshToken == "" { h.sendError(w, "refresh token is required", http.StatusUnauthorized) return } // Проверяем refresh token в БД hashedRefreshToken := hashToken(refreshToken) storedToken, err := h.tokenStorage.GetRefreshToken(ctx, hashedRefreshToken) if err != nil { if errors.Is(err, storage.ErrTokenNotFound) { h.logger.WarnContext(ctx, "refresh token not found") h.sendError(w, "invalid refresh token", http.StatusUnauthorized) return } h.logger.ErrorContext(ctx, "failed to get refresh token", slog.Any("error", err)) h.sendError(w, "internal server error", http.StatusInternalServerError) return } // Проверяем срок действия if time.Now().After(storedToken.ExpiresAt) { h.logger.WarnContext(ctx, "refresh token expired", slog.String("user_id", storedToken.UserID)) h.sendError(w, "refresh token expired", http.StatusUnauthorized) return } // Получаем пользователя для генерации нового access token user, err := h.userStorage.GetUserByID(ctx, storedToken.UserID) if err != nil { h.logger.ErrorContext(ctx, "failed to get user", slog.Any("error", err)) h.sendError(w, "internal server error", http.StatusInternalServerError) return } // Генерируем новый access token newAccessToken, expiresIn, err := GenerateAccessToken(h.jwtConfig, user.ID, user.Username) if err != nil { h.logger.ErrorContext(ctx, "failed to generate access token", slog.Any("error", err)) h.sendError(w, "internal server error", http.StatusInternalServerError) return } // Генерируем новый refresh token newRefreshToken, newExpiresAt, err := GenerateRefreshToken(h.jwtConfig) if err != nil { h.logger.ErrorContext(ctx, "failed to generate refresh token", slog.Any("error", err)) h.sendError(w, "internal server error", http.StatusInternalServerError) return } // Удаляем старый refresh token if err := h.tokenStorage.DeleteRefreshToken(ctx, hashedRefreshToken); err != nil { h.logger.WarnContext(ctx, "failed to delete old refresh token", slog.Any("error", err)) // Продолжаем выполнение } // Сохраняем новый refresh token newHashedRefreshToken := hashToken(newRefreshToken) newToken := &models.RefreshToken{ Token: newHashedRefreshToken, UserID: user.ID, ExpiresAt: newExpiresAt, CreatedAt: time.Now(), } if err := h.tokenStorage.SaveRefreshToken(ctx, newToken); err != nil { h.logger.ErrorContext(ctx, "failed to save refresh token", slog.Any("error", err)) h.sendError(w, "internal server error", http.StatusInternalServerError) return } h.logger.InfoContext(ctx, "tokens refreshed successfully", slog.String("user_id", user.ID)) resp := api.TokenResponse{ UserID: user.ID, AccessToken: newAccessToken, RefreshToken: newRefreshToken, ExpiresIn: expiresIn, } h.sendJSON(w, resp, http.StatusOK) } // Logout обрабатывает POST /api/v1/auth/logout // Выход пользователя (удаление refresh token) // TODO сейчас выходит из всех устройств. надо сделать выход только с 1 например через ID устройства func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Извлекаем access token из Authorization header authHeader := r.Header.Get("Authorization") if authHeader == "" { h.sendError(w, "Authorization header is required", http.StatusUnauthorized) return } // Извлекаем access token из Authorization header const bearerPrefix = "Bearer " if len(authHeader) < len(bearerPrefix) || authHeader[:len(bearerPrefix)] != bearerPrefix { h.sendError(w, "invalid Authorization header format", http.StatusUnauthorized) return } accessToken := authHeader[len(bearerPrefix):] if accessToken == "" { h.sendError(w, "access token is required", http.StatusUnauthorized) return } // Валидируем и парсим access token claims, err := ValidateAccessToken(h.jwtConfig, accessToken) if err != nil { h.logger.WarnContext(ctx, "invalid access token", slog.Any("error", err)) h.sendError(w, "invalid or expired access token", http.StatusUnauthorized) return } // Удаляем все refresh tokens пользователя deletedCount, err := h.tokenStorage.DeleteUserTokens(ctx, claims.UserID) if err != nil { h.logger.ErrorContext(ctx, "failed to delete user tokens", slog.Any("error", err)) h.sendError(w, "internal server error", http.StatusInternalServerError) return } h.logger.InfoContext(ctx, "user logged out successfully", slog.String("user_id", claims.UserID), slog.Int("tokens_deleted", deletedCount)) w.WriteHeader(http.StatusNoContent) } // sendJSON отправляет JSON ответ func (h *AuthHandler) sendJSON(w http.ResponseWriter, data interface{}, statusCode int) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(statusCode) if err := json.NewEncoder(w).Encode(data); err != nil { h.logger.Error("failed to encode JSON response", slog.Any("error", err)) } } // sendError отправляет JSON ответ с ошибкой func (h *AuthHandler) sendError(w http.ResponseWriter, message string, statusCode int) { resp := api.ErrorResponse{ Error: http.StatusText(statusCode), Message: message, } h.sendJSON(w, resp, statusCode) } func hashToken(token string) string { hash := sha256.New() hash.Write([]byte(token)) return hex.EncodeToString(hash.Sum(nil)) }
package handlers import ( "encoding/json" "log/slog" "net/http" ) // HealthHandler обрабатывает health check запросы type HealthHandler struct { logger *slog.Logger } // NewHealthHandler создает новый handler для health check func NewHealthHandler(logger *slog.Logger) *HealthHandler { return &HealthHandler{ logger: logger, } } // HealthResponse представляет ответ health check type HealthResponse struct { Status string `json:"status"` Version string `json:"version,omitempty"` } // Health обрабатывает GET /api/v1/health // Health check endpoint для мониторинга func (h *HealthHandler) Health(w http.ResponseWriter, r *http.Request) { // TODO: Проверить доступность базы данных // TODO: Добавить информацию о версии приложения resp := HealthResponse{ Status: "ok", Version: "dev", // TODO: получать из build-time переменной } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(resp); err != nil { h.logger.Error("failed to encode health response", slog.Any("error", err)) } }
package handlers import ( "crypto/rand" "encoding/base64" "fmt" "time" "github.com/golang-jwt/jwt/v5" ) // CustomClaims представляет JWT claims для нашего приложения type CustomClaims struct { UserID string `json:"user_id"` Username string `json:"username"` jwt.RegisteredClaims } // JWTConfig содержит конфигурацию для JWT type JWTConfig struct { Secret []byte AccessTokenTTL time.Duration RefreshTokenTTL time.Duration } // GenerateAccessToken создает новый JWT access token func GenerateAccessToken(cfg JWTConfig, userID, username string) (string, int64, error) { now := time.Now() expiresAt := now.Add(cfg.AccessTokenTTL) claims := CustomClaims{ UserID: userID, Username: username, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(expiresAt), IssuedAt: jwt.NewNumericDate(now), NotBefore: jwt.NewNumericDate(now), Issuer: "gophkeeper", }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) tokenString, err := token.SignedString(cfg.Secret) if err != nil { return "", 0, fmt.Errorf("failed to sign token: %w", err) } return tokenString, int64(cfg.AccessTokenTTL.Seconds()), nil } // ValidateAccessToken валидирует и парсит JWT access token func ValidateAccessToken(cfg JWTConfig, tokenString string) (*CustomClaims, error) { token, err := jwt.ParseWithClaims(tokenString, &CustomClaims{}, 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 cfg.Secret, nil }) if err != nil { return nil, fmt.Errorf("failed to parse token: %w", err) } if claims, ok := token.Claims.(*CustomClaims); ok && token.Valid { return claims, nil } return nil, fmt.Errorf("invalid token") } // GenerateRefreshToken создает новый random refresh token func GenerateRefreshToken(cfg JWTConfig) (string, time.Time, error) { // Генерируем случайные 32 байта tokenBytes := make([]byte, 32) if _, err := rand.Read(tokenBytes); err != nil { return "", time.Time{}, fmt.Errorf("failed to generate random token: %w", err) } // Кодируем в base64 token := base64.URLEncoding.EncodeToString(tokenBytes) expiresAt := time.Now().Add(cfg.RefreshTokenTTL) return token, expiresAt, nil }
package handlers import ( "context" "encoding/json" "fmt" "log/slog" "net/http" "strconv" "github.com/iudanet/gophkeeper/internal/models" "github.com/iudanet/gophkeeper/pkg/api" ) // contextKey тип для ключей контекста type contextKey string const ( // UserIDKey ключ для хранения user_id в контексте UserIDKey contextKey = "user_id" // UsernameKey ключ для хранения username в контексте UsernameKey contextKey = "username" ) // GetUserID извлекает user_id из контекста запроса func GetUserID(ctx context.Context) (string, bool) { userID, ok := ctx.Value(UserIDKey).(string) return userID, ok } // GetUsername извлекает username из контекста запроса func GetUsername(ctx context.Context) (string, bool) { username, ok := ctx.Value(UsernameKey).(string) return username, ok } // DataStorage определяет интерфейс для работы с данными type DataStorage interface { SaveEntry(ctx context.Context, entry *models.CRDTEntry) (bool, error) GetUserEntriesSince(ctx context.Context, userID string, since int64) ([]*models.CRDTEntry, error) } // SyncHandler handles synchronization requests type SyncHandler struct { logger *slog.Logger storage DataStorage } // NewSyncHandler creates a new sync handler func NewSyncHandler(logger *slog.Logger, storage DataStorage) *SyncHandler { return &SyncHandler{ logger: logger, storage: storage, } } // HandleSync обрабатывает GET и POST запросы для синхронизации func (h *SyncHandler) HandleSync(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Получаем user_id из контекста (установлен AuthMiddleware) userID, ok := GetUserID(ctx) if !ok { h.logger.Error("User ID not found in context") http.Error(w, "Unauthorized", http.StatusUnauthorized) return } switch r.Method { case http.MethodGet: h.handleGetSync(w, r, ctx, userID) case http.MethodPost: h.handlePostSync(w, r, ctx, userID) default: http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) } } // handleGetSync обрабатывает GET /api/v1/sync?since=timestamp // Возвращает все изменения с указанного timestamp func (h *SyncHandler) handleGetSync(w http.ResponseWriter, r *http.Request, ctx context.Context, userID string) { // Парсим параметр since sinceStr := r.URL.Query().Get("since") var since int64 if sinceStr != "" { var err error since, err = strconv.ParseInt(sinceStr, 10, 64) if err != nil { h.logger.Warn("Invalid since parameter", "since", sinceStr, "error", err) http.Error(w, "Invalid since parameter", http.StatusBadRequest) return } } h.logger.Info("GET sync request", "user_id", userID, "since", since) // Получаем записи с указанного timestamp entries, err := h.storage.GetUserEntriesSince(ctx, userID, since) if err != nil { h.logger.Error("Failed to get user entries", "error", err, "user_id", userID) http.Error(w, "Internal server error", http.StatusInternalServerError) return } // Конвертируем в API формат apiEntries := make([]api.CRDTEntry, 0, len(entries)) maxTimestamp := since for _, entry := range entries { apiEntry := api.CRDTEntry{ ID: entry.ID, UserID: entry.UserID, DataType: entry.Type, Data: entry.Data, Metadata: string(entry.Metadata), // Конвертируем []byte в string Timestamp: entry.Timestamp, Deleted: entry.Deleted, CreatedAt: entry.CreatedAt, UpdatedAt: entry.UpdatedAt, } apiEntries = append(apiEntries, apiEntry) // Отслеживаем максимальный timestamp if entry.Timestamp > maxTimestamp { maxTimestamp = entry.Timestamp } } // Формируем ответ response := api.SyncResponse{ Entries: apiEntries, CurrentTimestamp: maxTimestamp, Conflicts: 0, // GET не вызывает конфликтов } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(response); err != nil { h.logger.Error("Failed to encode response", "error", err) } h.logger.Info("GET sync completed", "user_id", userID, "entries_count", len(apiEntries)) } // handlePostSync обрабатывает POST /api/v1/sync // Принимает изменения от клиента и возвращает изменения с сервера func (h *SyncHandler) handlePostSync(w http.ResponseWriter, r *http.Request, ctx context.Context, userID string) { var req api.SyncRequest // Парсим request body if err := json.NewDecoder(r.Body).Decode(&req); err != nil { h.logger.Warn("Failed to decode sync request", "error", err) http.Error(w, "Invalid request body", http.StatusBadRequest) return } h.logger.Info("POST sync request", "user_id", userID, "since", req.Since, "entries_count", len(req.Entries)) conflicts := 0 // Сохраняем входящие записи от клиента for i, apiEntry := range req.Entries { // Проверяем что user_id совпадает if apiEntry.UserID != userID { h.logger.Warn("Entry user_id mismatch", "expected", userID, "got", apiEntry.UserID, "entry_id", apiEntry.ID) http.Error(w, fmt.Sprintf("Entry %d: user_id mismatch", i), http.StatusForbidden) return } // Конвертируем в models.CRDTEntry entry := &models.CRDTEntry{ ID: apiEntry.ID, UserID: apiEntry.UserID, Type: apiEntry.DataType, Data: apiEntry.Data, Metadata: []byte(apiEntry.Metadata), // Конвертируем string в []byte Timestamp: apiEntry.Timestamp, Deleted: apiEntry.Deleted, CreatedAt: apiEntry.CreatedAt, UpdatedAt: apiEntry.UpdatedAt, // NodeID и Version устанавливаются на клиенте } // Сохраняем запись saved, err := h.storage.SaveEntry(ctx, entry) if err != nil { h.logger.Error("Failed to save entry", "error", err, "entry_id", entry.ID) http.Error(w, "Internal server error", http.StatusInternalServerError) return } // Если запись не была сохранена (существующая новее) - это конфликт if !saved { conflicts++ h.logger.Debug("Entry not saved (existing is newer)", "entry_id", entry.ID) } } // Получаем записи с сервера с указанного timestamp entries, err := h.storage.GetUserEntriesSince(ctx, userID, req.Since) if err != nil { h.logger.Error("Failed to get user entries", "error", err, "user_id", userID) http.Error(w, "Internal server error", http.StatusInternalServerError) return } // Конвертируем в API формат apiEntries := make([]api.CRDTEntry, 0, len(entries)) maxTimestamp := req.Since for _, entry := range entries { apiEntry := api.CRDTEntry{ ID: entry.ID, UserID: entry.UserID, DataType: entry.Type, Data: entry.Data, Metadata: string(entry.Metadata), Timestamp: entry.Timestamp, Deleted: entry.Deleted, CreatedAt: entry.CreatedAt, UpdatedAt: entry.UpdatedAt, } apiEntries = append(apiEntries, apiEntry) if entry.Timestamp > maxTimestamp { maxTimestamp = entry.Timestamp } } // Формируем ответ response := api.SyncResponse{ Entries: apiEntries, CurrentTimestamp: maxTimestamp, Conflicts: conflicts, } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(response); err != nil { h.logger.Error("Failed to encode response", "error", err) } h.logger.Info("POST sync completed", "user_id", userID, "received_entries", len(req.Entries), "returned_entries", len(apiEntries), "conflicts", conflicts) }
package middleware import ( "context" "log/slog" "net/http" "strings" "github.com/iudanet/gophkeeper/internal/server/handlers" ) // AuthMiddleware создает middleware для проверки JWT токена func AuthMiddleware(logger *slog.Logger, jwtConfig handlers.JWTConfig) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Извлекаем токен из заголовка Authorization authHeader := r.Header.Get("Authorization") if authHeader == "" { logger.Warn("Missing Authorization header") http.Error(w, "Unauthorized: missing token", http.StatusUnauthorized) return } // Ожидаем формат: "Bearer <token>" parts := strings.SplitN(authHeader, " ", 2) if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { logger.Warn("Invalid Authorization header format", "header", authHeader) http.Error(w, "Unauthorized: invalid token format", http.StatusUnauthorized) return } tokenString := parts[1] // Валидируем токен claims, err := handlers.ValidateAccessToken(jwtConfig, tokenString) if err != nil { logger.Warn("Invalid access token", "error", err) http.Error(w, "Unauthorized: invalid token", http.StatusUnauthorized) return } // Добавляем данные из токена в контекст ctx := context.WithValue(r.Context(), handlers.UserIDKey, claims.UserID) ctx = context.WithValue(ctx, handlers.UsernameKey, claims.Username) logger.Debug("User authenticated", "user_id", claims.UserID, "username", claims.Username) // Передаем запрос дальше с обновленным контекстом next.ServeHTTP(w, r.WithContext(ctx)) }) } }
package middleware import ( "log/slog" "net/http" "strings" "time" ) // responseWriter wraps http.ResponseWriter to capture status code type responseWriter struct { http.ResponseWriter statusCode int written int64 } // WriteHeader captures the status code func (rw *responseWriter) WriteHeader(code int) { rw.statusCode = code rw.ResponseWriter.WriteHeader(code) } // Write captures the number of bytes written func (rw *responseWriter) Write(b []byte) (int, error) { n, err := rw.ResponseWriter.Write(b) rw.written += int64(n) return n, err } // LoggingMiddleware создает middleware для логирования HTTP запросов // Логирует метод, путь, статус, время выполнения, размер ответа // НЕ логирует sensitive данные (токены, пароли, ключи) func LoggingMiddleware(logger *slog.Logger) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() // Wrap response writer для захвата статуса и размера wrapped := &responseWriter{ ResponseWriter: w, statusCode: http.StatusOK, // default status written: 0, } // Обрабатываем запрос next.ServeHTTP(wrapped, r) // Вычисляем длительность duration := time.Since(start) // Определяем уровень логирования на основе статуса logLevel := slog.LevelInfo if wrapped.statusCode >= 500 { logLevel = slog.LevelError } else if wrapped.statusCode >= 400 { logLevel = slog.LevelWarn } // Логируем запрос (без sensitive данных) logger.Log(r.Context(), logLevel, "HTTP request", "method", r.Method, "path", sanitizePath(r.URL.Path), "remote_addr", r.RemoteAddr, "user_agent", r.UserAgent(), "status", wrapped.statusCode, "duration_ms", duration.Milliseconds(), "bytes_written", wrapped.written, ) }) } } // sanitizePath удаляет sensitive части из пути (например, токены в URL) // Например: /api/v1/auth/salt/username остается как есть // но /api/v1/reset/TOKEN заменяется на /api/v1/reset/*** func sanitizePath(path string) string { // Список sensitive путей, которые могут содержать токены // В текущей реализации все пути безопасны для логирования // Но на будущее добавляем эту функцию // Пример: если в пути есть /token/ или /reset/, заменяем следующий сегмент if strings.Contains(path, "/token/") || strings.Contains(path, "/reset/") { parts := strings.Split(path, "/") for i, part := range parts { if (part == "token" || part == "reset") && i+1 < len(parts) && parts[i+1] != "" { parts[i+1] = "***" } } return strings.Join(parts, "/") } return path } // LoggingWithSkip создает middleware с возможностью пропуска определенных путей // Полезно для health checks и других эндпоинтов с высокой частотой запросов func LoggingWithSkip(logger *slog.Logger, skipPaths []string) func(http.Handler) http.Handler { skipMap := make(map[string]bool) for _, path := range skipPaths { skipMap[path] = true } return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Проверяем, нужно ли пропустить логирование if skipMap[r.URL.Path] { next.ServeHTTP(w, r) return } // Используем обычный логгер LoggingMiddleware(logger)(next).ServeHTTP(w, r) }) } }
package middleware import ( "log/slog" "net/http" "sync" "time" ) // RateLimiter представляет rate limiter на основе токен-бакета (token bucket) type RateLimiter struct { buckets map[string]*bucket logger *slog.Logger cleanupC chan struct{} rate int window time.Duration mu sync.RWMutex } // bucket представляет bucket для конкретного IP/ключа type bucket struct { lastRefill time.Time tokens int mu sync.Mutex } // NewRateLimiter создает новый rate limiter // rate - максимальное количество запросов в единицу времени // window - временное окно (например, 1 минута) func NewRateLimiter(rate int, window time.Duration, logger *slog.Logger) *RateLimiter { rl := &RateLimiter{ buckets: make(map[string]*bucket), rate: rate, window: window, logger: logger, cleanupC: make(chan struct{}), } // Запускаем периодическую очистку старых buckets go rl.cleanup() return rl } // cleanup периодически удаляет неактивные buckets для экономии памяти func (rl *RateLimiter) cleanup() { ticker := time.NewTicker(rl.window * 2) defer ticker.Stop() for { select { case <-ticker.C: rl.cleanupOldBuckets() case <-rl.cleanupC: return } } } // cleanupOldBuckets удаляет buckets, которые не использовались дольше window func (rl *RateLimiter) cleanupOldBuckets() { rl.mu.Lock() defer rl.mu.Unlock() now := time.Now() for key, b := range rl.buckets { b.mu.Lock() if now.Sub(b.lastRefill) > rl.window*2 { delete(rl.buckets, key) } b.mu.Unlock() } } // Stop останавливает cleanup goroutine func (rl *RateLimiter) Stop() { close(rl.cleanupC) } // Allow проверяет, разрешен ли запрос для данного ключа (обычно IP адрес) func (rl *RateLimiter) Allow(key string) bool { rl.mu.RLock() b, exists := rl.buckets[key] rl.mu.RUnlock() if !exists { // Создаем новый bucket b = &bucket{ tokens: rl.rate, lastRefill: time.Now(), } rl.mu.Lock() rl.buckets[key] = b rl.mu.Unlock() } b.mu.Lock() defer b.mu.Unlock() now := time.Now() elapsed := now.Sub(b.lastRefill) // Пополняем токены на основе прошедшего времени if elapsed >= rl.window { b.tokens = rl.rate b.lastRefill = now } // Проверяем, есть ли доступные токены if b.tokens > 0 { b.tokens-- return true } return false } // RateLimitMiddleware создает middleware для ограничения частоты запросов // rate - максимальное количество запросов // window - временное окно (например, 5 минут) func RateLimitMiddleware(rate int, window time.Duration, logger *slog.Logger) func(http.Handler) http.Handler { limiter := NewRateLimiter(rate, window, logger) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Используем IP адрес как ключ key := getClientIP(r) if !limiter.Allow(key) { logger.Warn("Rate limit exceeded", "ip", key, "method", r.Method, "path", r.URL.Path, ) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusTooManyRequests) _, _ = w.Write([]byte(`{"error":"rate limit exceeded, please try again later"}`)) return } next.ServeHTTP(w, r) }) } } // RateLimitByPath создает middleware с разными лимитами для разных путей type PathRateLimit struct { Path string Rate int Window time.Duration } // RateLimitByPathMiddleware создает middleware с кастомными лимитами для путей func RateLimitByPathMiddleware(limits []PathRateLimit, defaultRate int, defaultWindow time.Duration, logger *slog.Logger) func(http.Handler) http.Handler { // Создаем limiters для каждого пути limiters := make(map[string]*RateLimiter) for _, limit := range limits { limiters[limit.Path] = NewRateLimiter(limit.Rate, limit.Window, logger) } // Дефолтный limiter для всех остальных путей defaultLimiter := NewRateLimiter(defaultRate, defaultWindow, logger) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Выбираем соответствующий limiter limiter, exists := limiters[r.URL.Path] if !exists { limiter = defaultLimiter } key := getClientIP(r) if !limiter.Allow(key) { logger.Warn("Rate limit exceeded", "ip", key, "method", r.Method, "path", r.URL.Path, ) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusTooManyRequests) _, _ = w.Write([]byte(`{"error":"rate limit exceeded, please try again later"}`)) return } next.ServeHTTP(w, r) }) } } // getClientIP извлекает IP адрес клиента из запроса // Проверяет заголовки X-Forwarded-For и X-Real-IP для прокси func getClientIP(r *http.Request) string { // Проверяем X-Forwarded-For (для прокси/load balancers) if xff := r.Header.Get("X-Forwarded-For"); xff != "" { // Берем первый IP из списка (реальный клиент) for idx := 0; idx < len(xff); idx++ { if xff[idx] == ',' { return xff[:idx] } } return xff } // Проверяем X-Real-IP if xri := r.Header.Get("X-Real-IP"); xri != "" { return xri } // Используем RemoteAddr return r.RemoteAddr }
package middleware import ( "fmt" "log/slog" "net/http" "runtime/debug" ) // RecoveryMiddleware создает middleware для восстановления после паники // Перехватывает panic, логирует стек вызовов и возвращает 500 Internal Server Error func RecoveryMiddleware(logger *slog.Logger) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if err := recover(); err != nil { // Получаем стек вызовов для диагностики stackTrace := debug.Stack() // Логируем критическую ошибку со стеком logger.Error("Panic recovered", "error", err, "method", r.Method, "path", r.URL.Path, "remote_addr", r.RemoteAddr, "stack", string(stackTrace), ) // Возвращаем generic ошибку клиенту (не раскрываем детали) http.Error(w, "Internal Server Error", http.StatusInternalServerError) } }() // Передаем управление следующему обработчику next.ServeHTTP(w, r) }) } } // RecoveryWithCustomError создает middleware с кастомным сообщением об ошибке func RecoveryWithCustomError(logger *slog.Logger, errorMessage string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if err := recover(); err != nil { stackTrace := debug.Stack() logger.Error("Panic recovered", "error", err, "method", r.Method, "path", r.URL.Path, "remote_addr", r.RemoteAddr, "stack", string(stackTrace), ) // Формируем JSON ответ с кастомным сообщением w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) _, _ = fmt.Fprintf(w, `{"error":"%s"}`, errorMessage) } }() next.ServeHTTP(w, r) }) } }
package sqlite import ( "context" "database/sql" "errors" "fmt" "time" "github.com/iudanet/gophkeeper/internal/models" "github.com/iudanet/gophkeeper/internal/server/storage" ) // SaveEntry creates or updates a CRDT entry in the storage // Uses CRDT logic: only saves if entry is newer than existing // Returns true if entry was saved (newer), false if existing entry is newer func (s *Storage) SaveEntry(ctx context.Context, entry *models.CRDTEntry) (bool, error) { // Проверяем существующую запись existing, err := s.GetEntry(ctx, entry.ID) if err != nil && !errors.Is(err, storage.ErrEntryNotFound) { return false, fmt.Errorf("failed to check existing entry: %w", err) } // Если запись существует, проверяем по CRDT логике if existing != nil { // Если существующая запись новее - не сохраняем if !entry.IsNewerThan(existing) { return false, nil } // Обновляем существующую запись query := ` UPDATE user_data SET user_id = ?, type = ?, data = ?, metadata = ?, version = ?, timestamp = ?, node_id = ?, deleted = ?, updated_at = ? WHERE id = ? ` _, err := s.db.ExecContext(ctx, query, entry.UserID, entry.Type, entry.Data, entry.Metadata, entry.Version, entry.Timestamp, entry.NodeID, boolToInt(entry.Deleted), entry.UpdatedAt.Unix(), entry.ID, ) if err != nil { return false, fmt.Errorf("failed to update entry: %w", err) } return true, nil } // Создаем новую запись query := ` INSERT INTO user_data ( id, user_id, type, data, metadata, version, timestamp, node_id, deleted, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` _, err = s.db.ExecContext(ctx, query, entry.ID, entry.UserID, entry.Type, entry.Data, entry.Metadata, entry.Version, entry.Timestamp, entry.NodeID, boolToInt(entry.Deleted), entry.CreatedAt.Unix(), entry.UpdatedAt.Unix(), ) if err != nil { return false, fmt.Errorf("failed to insert entry: %w", err) } return true, nil } // GetEntry retrieves a single entry by ID // Returns ErrEntryNotFound if entry doesn't exist or is deleted func (s *Storage) GetEntry(ctx context.Context, id string) (*models.CRDTEntry, error) { query := ` SELECT id, user_id, type, data, metadata, version, timestamp, node_id, deleted, created_at, updated_at FROM user_data WHERE id = ? ` entry := &models.CRDTEntry{} var deleted int var createdAt, updatedAt int64 err := s.db.QueryRowContext(ctx, query, id).Scan( &entry.ID, &entry.UserID, &entry.Type, &entry.Data, &entry.Metadata, &entry.Version, &entry.Timestamp, &entry.NodeID, &deleted, &createdAt, &updatedAt, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, storage.ErrEntryNotFound } return nil, fmt.Errorf("failed to get entry: %w", err) } entry.Deleted = intToBool(deleted) entry.CreatedAt = unixToTime(createdAt) entry.UpdatedAt = unixToTime(updatedAt) // Если запись помечена как удаленная - возвращаем ошибку // (для внешних API, внутри синхронизации используем GetUserEntriesSince) if entry.Deleted { return nil, storage.ErrEntryNotFound } return entry, nil } // GetUserEntries retrieves all non-deleted entries for a user // Returns empty slice if no entries found func (s *Storage) GetUserEntries(ctx context.Context, userID string) ([]*models.CRDTEntry, error) { query := ` SELECT id, user_id, type, data, metadata, version, timestamp, node_id, deleted, created_at, updated_at FROM user_data WHERE user_id = ? AND deleted = 0 ORDER BY created_at DESC ` rows, err := s.db.QueryContext(ctx, query, userID) if err != nil { return nil, fmt.Errorf("failed to query entries: %w", err) } defer func() { if cerr := rows.Close(); cerr != nil { err = cerr } }() return s.scanEntries(rows) } // GetUserEntriesSince retrieves all entries (including deleted) for a user // modified after the given timestamp. Used for synchronization. // Returns empty slice if no entries found func (s *Storage) GetUserEntriesSince(ctx context.Context, userID string, since int64) ([]*models.CRDTEntry, error) { query := ` SELECT id, user_id, type, data, metadata, version, timestamp, node_id, deleted, created_at, updated_at FROM user_data WHERE user_id = ? AND timestamp > ? ORDER BY timestamp ASC ` rows, err := s.db.QueryContext(ctx, query, userID, since) if err != nil { return nil, fmt.Errorf("failed to query entries since timestamp: %w", err) } defer func() { if cerr := rows.Close(); cerr != nil { err = cerr } }() return s.scanEntries(rows) } // GetUserEntriesByType retrieves all non-deleted entries for a user filtered by type // Returns empty slice if no entries found func (s *Storage) GetUserEntriesByType(ctx context.Context, userID string, dataType string) ([]*models.CRDTEntry, error) { query := ` SELECT id, user_id, type, data, metadata, version, timestamp, node_id, deleted, created_at, updated_at FROM user_data WHERE user_id = ? AND type = ? AND deleted = 0 ORDER BY created_at DESC ` rows, err := s.db.QueryContext(ctx, query, userID, dataType) if err != nil { return nil, fmt.Errorf("failed to query entries by type: %w", err) } defer func() { if cerr := rows.Close(); cerr != nil { err = cerr } }() return s.scanEntries(rows) } // DeleteEntry marks entry as deleted (soft delete) with new timestamp // Returns ErrEntryNotFound if entry doesn't exist func (s *Storage) DeleteEntry(ctx context.Context, id string, timestamp int64, nodeID string) error { query := ` UPDATE user_data SET deleted = 1, timestamp = ?, node_id = ?, updated_at = ? WHERE id = ? ` result, err := s.db.ExecContext(ctx, query, timestamp, nodeID, timestamp, id) if err != nil { return fmt.Errorf("failed to delete entry: %w", err) } rows, err := result.RowsAffected() if err != nil { return fmt.Errorf("failed to get rows affected: %w", err) } if rows == 0 { return storage.ErrEntryNotFound } return nil } // scanEntries is a helper function to scan multiple entries from rows func (s *Storage) scanEntries(rows *sql.Rows) ([]*models.CRDTEntry, error) { var entries []*models.CRDTEntry for rows.Next() { entry := &models.CRDTEntry{} var deleted int var createdAt, updatedAt int64 err := rows.Scan( &entry.ID, &entry.UserID, &entry.Type, &entry.Data, &entry.Metadata, &entry.Version, &entry.Timestamp, &entry.NodeID, &deleted, &createdAt, &updatedAt, ) if err != nil { return nil, fmt.Errorf("failed to scan entry: %w", err) } entry.Deleted = intToBool(deleted) entry.CreatedAt = unixToTime(createdAt) entry.UpdatedAt = unixToTime(updatedAt) entries = append(entries, entry) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("rows iteration error: %w", err) } return entries, nil } // Helper functions for bool/int conversion func boolToInt(b bool) int { if b { return 1 } return 0 } func intToBool(i int) bool { return i != 0 } func unixToTime(timestamp int64) time.Time { return time.Unix(timestamp, 0) }
package sqlite import ( "context" "database/sql" "embed" "fmt" "github.com/pressly/goose/v3" _ "modernc.org/sqlite" // SQLite driver ) //go:embed migrations/*.sql var embedMigrations embed.FS // Storage represents SQLite storage implementation type Storage struct { db *sql.DB } // New creates a new SQLite storage instance // dbPath is the path to the SQLite database file // Use ":memory:" for in-memory database (useful for testing) func New(ctx context.Context, dbPath string) (*Storage, error) { // Открываем соединение с БД db, err := sql.Open("sqlite", dbPath) if err != nil { return nil, fmt.Errorf("failed to open database: %w", err) } // Проверяем соединение if err := db.Ping(); err != nil { _ = db.Close() return nil, fmt.Errorf("failed to ping database: %w", err) } // Настраиваем connection pool // SQLite с WAL mode может поддерживать несколько читателей, но только одного писателя db.SetMaxOpenConns(1) db.SetMaxIdleConns(1) // Включаем WAL mode и другие оптимизации pragmas := []string{ "PRAGMA journal_mode = WAL;", "PRAGMA synchronous = NORMAL;", "PRAGMA foreign_keys = ON;", "PRAGMA busy_timeout = 5000;", } for _, pragma := range pragmas { if _, err := db.ExecContext(ctx, pragma); err != nil { _ = db.Close() return nil, fmt.Errorf("failed to set pragma: %w", err) } } storage := &Storage{db: db} // Запускаем миграции if err := storage.runMigrations(); err != nil { _ = db.Close() return nil, fmt.Errorf("failed to run migrations: %w", err) } return storage, nil } // Close closes the database connection func (s *Storage) Close() error { return s.db.Close() } // runMigrations выполняет миграции из embedded FS func (s *Storage) runMigrations() error { // Устанавливаем dialect для SQLite if err := goose.SetDialect("sqlite3"); err != nil { return fmt.Errorf("failed to set goose dialect: %w", err) } // Устанавливаем источник миграций из embedded FS goose.SetBaseFS(embedMigrations) // Запускаем миграции if err := goose.Up(s.db, "migrations"); err != nil { return fmt.Errorf("goose up failed: %w", err) } return nil }
package sqlite import ( "context" "database/sql" "errors" "fmt" "github.com/iudanet/gophkeeper/internal/models" "github.com/iudanet/gophkeeper/internal/server/storage" ) // SaveRefreshToken stores a new refresh token func (s *Storage) SaveRefreshToken(ctx context.Context, token *models.RefreshToken) error { query := ` INSERT OR REPLACE INTO refresh_tokens (token, user_id, expires_at, created_at) VALUES (?, ?, ?, ?) ` _, err := s.db.ExecContext(ctx, query, token.Token, token.UserID, token.ExpiresAt, token.CreatedAt, ) if err != nil { return fmt.Errorf("failed to save refresh token: %w", err) } return nil } // GetRefreshToken retrieves refresh token by token value func (s *Storage) GetRefreshToken(ctx context.Context, token string) (*models.RefreshToken, error) { query := ` SELECT token, user_id, expires_at, created_at FROM refresh_tokens WHERE token = ? ` refreshToken := &models.RefreshToken{} err := s.db.QueryRowContext(ctx, query, token).Scan( &refreshToken.Token, &refreshToken.UserID, &refreshToken.ExpiresAt, &refreshToken.CreatedAt, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, storage.ErrTokenNotFound } return nil, fmt.Errorf("failed to get refresh token: %w", err) } return refreshToken, nil } // GetUserTokens retrieves all refresh tokens for a user func (s *Storage) GetUserTokens(ctx context.Context, userID string) ([]*models.RefreshToken, error) { query := ` SELECT token, user_id, expires_at, created_at FROM refresh_tokens WHERE user_id = ? ORDER BY created_at DESC ` rows, err := s.db.QueryContext(ctx, query, userID) if err != nil { return nil, fmt.Errorf("failed to query user tokens: %w", err) } defer func() { _ = rows.Close() }() var tokens []*models.RefreshToken for rows.Next() { token := &models.RefreshToken{} if err := rows.Scan( &token.Token, &token.UserID, &token.ExpiresAt, &token.CreatedAt, ); err != nil { return nil, fmt.Errorf("failed to scan token: %w", err) } tokens = append(tokens, token) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("rows iteration error: %w", err) } return tokens, nil } // DeleteRefreshToken deletes refresh token by token value func (s *Storage) DeleteRefreshToken(ctx context.Context, token string) error { query := `DELETE FROM refresh_tokens WHERE token = ?` result, err := s.db.ExecContext(ctx, query, token) if err != nil { return fmt.Errorf("failed to delete refresh token: %w", err) } rows, err := result.RowsAffected() if err != nil { return fmt.Errorf("failed to get rows affected: %w", err) } if rows == 0 { return storage.ErrTokenNotFound } return nil } // DeleteUserTokens deletes all refresh tokens for a user func (s *Storage) DeleteUserTokens(ctx context.Context, userID string) (int, error) { query := `DELETE FROM refresh_tokens WHERE user_id = ?` result, err := s.db.ExecContext(ctx, query, userID) if err != nil { return 0, fmt.Errorf("failed to delete user tokens: %w", err) } rows, err := result.RowsAffected() if err != nil { return 0, fmt.Errorf("failed to get rows affected: %w", err) } return int(rows), nil } // DeleteExpiredTokens removes all expired tokens func (s *Storage) DeleteExpiredTokens(ctx context.Context) (int, error) { query := `DELETE FROM refresh_tokens WHERE expires_at < datetime('now')` result, err := s.db.ExecContext(ctx, query) if err != nil { return 0, fmt.Errorf("failed to delete expired tokens: %w", err) } rows, err := result.RowsAffected() if err != nil { return 0, fmt.Errorf("failed to get rows affected: %w", err) } return int(rows), nil }
package sqlite import ( "context" "database/sql" "errors" "fmt" "strings" "time" "github.com/iudanet/gophkeeper/internal/models" "github.com/iudanet/gophkeeper/internal/server/storage" ) // CreateUser creates a new user in the storage func (s *Storage) CreateUser(ctx context.Context, user *models.User) error { query := ` INSERT INTO users (id, username, auth_key_hash, public_salt, created_at, last_login) VALUES (?, ?, ?, ?, ?, ?) ` _, err := s.db.ExecContext(ctx, query, user.ID, user.Username, user.AuthKeyHash, user.PublicSalt, user.CreatedAt, user.LastLogin, ) if err != nil { // Проверяем на duplicate username if isUniqueConstraintError(err, "users.username") { return storage.ErrUserAlreadyExists } return fmt.Errorf("failed to insert user: %w", err) } return nil } // GetUserByUsername retrieves user by username func (s *Storage) GetUserByUsername(ctx context.Context, username string) (*models.User, error) { query := ` SELECT id, username, auth_key_hash, public_salt, created_at, last_login FROM users WHERE username = ? ` user := &models.User{} var lastLogin sql.NullTime err := s.db.QueryRowContext(ctx, query, username).Scan( &user.ID, &user.Username, &user.AuthKeyHash, &user.PublicSalt, &user.CreatedAt, &lastLogin, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, storage.ErrUserNotFound } return nil, fmt.Errorf("failed to get user: %w", err) } if lastLogin.Valid { user.LastLogin = &lastLogin.Time } return user, nil } // GetUserByID retrieves user by ID func (s *Storage) GetUserByID(ctx context.Context, userID string) (*models.User, error) { query := ` SELECT id, username, auth_key_hash, public_salt, created_at, last_login FROM users WHERE id = ? ` user := &models.User{} var lastLogin sql.NullTime err := s.db.QueryRowContext(ctx, query, userID).Scan( &user.ID, &user.Username, &user.AuthKeyHash, &user.PublicSalt, &user.CreatedAt, &lastLogin, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, storage.ErrUserNotFound } return nil, fmt.Errorf("failed to get user: %w", err) } if lastLogin.Valid { user.LastLogin = &lastLogin.Time } return user, nil } // UpdateUser updates user information func (s *Storage) UpdateUser(ctx context.Context, user *models.User) error { query := ` UPDATE users SET username = ?, auth_key_hash = ?, public_salt = ?, last_login = ? WHERE id = ? ` result, err := s.db.ExecContext(ctx, query, user.Username, user.AuthKeyHash, user.PublicSalt, user.LastLogin, user.ID, ) if err != nil { return fmt.Errorf("failed to update user: %w", err) } rows, err := result.RowsAffected() if err != nil { return fmt.Errorf("failed to get rows affected: %w", err) } if rows == 0 { return storage.ErrUserNotFound } return nil } // DeleteUser deletes user by ID func (s *Storage) DeleteUser(ctx context.Context, userID string) error { query := `DELETE FROM users WHERE id = ?` result, err := s.db.ExecContext(ctx, query, userID) if err != nil { return fmt.Errorf("failed to delete user: %w", err) } rows, err := result.RowsAffected() if err != nil { return fmt.Errorf("failed to get rows affected: %w", err) } if rows == 0 { return storage.ErrUserNotFound } return nil } // UpdateLastLogin updates the last login timestamp func (s *Storage) UpdateLastLogin(ctx context.Context, userID string, lastLogin time.Time) error { query := `UPDATE users SET last_login = ? WHERE id = ?` result, err := s.db.ExecContext(ctx, query, lastLogin, userID) if err != nil { return fmt.Errorf("failed to update last login: %w", err) } rows, err := result.RowsAffected() if err != nil { return fmt.Errorf("failed to get rows affected: %w", err) } if rows == 0 { return storage.ErrUserNotFound } return nil } // isUniqueConstraintError checks if the error is a UNIQUE constraint violation for the specified field func isUniqueConstraintError(err error, field string) bool { if err == nil { return false } errMsg := err.Error() return strings.Contains(errMsg, "UNIQUE constraint failed") && strings.Contains(errMsg, field) }
package validation import ( "fmt" "regexp" ) // UsernamePattern определяет допустимый формат username // Только латинские буквы (a-z, A-Z), цифры (0-9), нижнее подчеркивание (_) // Длина: 3-32 символа var UsernamePattern = regexp.MustCompile(`^[a-zA-Z0-9_]{3,32}$`) const ( // MinUsernameLen минимальная длина username MinUsernameLen = 3 // MaxUsernameLen максимальная длина username MaxUsernameLen = 32 ) // ValidateUsername проверяет, что username соответствует требованиям // Формат: только латинские буквы (a-z, A-Z), цифры (0-9), нижнее подчеркивание (_) // Длина: 3-32 символа func ValidateUsername(username string) error { if username == "" { return fmt.Errorf("username cannot be empty") } if len(username) < MinUsernameLen { return fmt.Errorf("username must be at least %d characters long", MinUsernameLen) } if len(username) > MaxUsernameLen { return fmt.Errorf("username must not exceed %d characters", MaxUsernameLen) } if !UsernamePattern.MatchString(username) { return fmt.Errorf("username can only contain letters (a-z, A-Z), numbers (0-9), and underscores (_)") } return nil } // ValidatePassword проверяет минимальные требования к master password // Минимум 12 символов func ValidatePassword(password string) error { const minPasswordLen = 12 if password == "" { return fmt.Errorf("password cannot be empty") } if len(password) < minPasswordLen { return fmt.Errorf("password must be at least %d characters long", minPasswordLen) } return nil }