import json import os import uuid import logging from typing import Optional, Dict from datetime import datetime, timedelta logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) class SessionStorage: """Хранилище сессий пользователей в JSON-файле. Назначение: Лёгкое файловое хранилище для сессионных токенов, сопоставленных с `client_id` (например, IP клиента). Предназначено для сред без БД и persistent-кэшей. Обеспечивает базовые операции и авто-очистку протухших сессий. Формат данных: { "sessions": { "": { "username": str, "token": str, "client_id": str, "created_at": ISO8601 str, "expiry": ISO8601 str }, ... } } Политика TTL: Каждая сессия действует 30 минут. При создании/чтении/удалении вызывается `clean_expired_sessions()` для удаления протухших записей. Ограничения: - Нет файловых блокировок: в многопроцессных конфигурациях возможны гонки. - Размещение по умолчанию `/etc/gonets/sessions.json` требует прав на запись. """ def __init__(self, file_path: str = "/etc/gonets/sessions.json"): """Инициализирует хранилище сессий. Args: file_path (str, optional): Путь к JSON-файлу для хранения сессий. По умолчанию "/etc/gonets/sessions.json". Создаёт файл и директории, если они отсутствуют, с правами доступа 600. """ self.file_path = file_path self._ensure_file_exists() def _ensure_file_exists(self): """Создаёт JSON-файл, если он не существует. Создаёт родительские директории и файл с начальной структурой данных, устанавливая права доступа 600. Raises: Exception: Если произошла ошибка при создании файла или директории. """ try: logger.info(f"Проверка существования файла {self.file_path}") if not os.path.exists(self.file_path): logger.info(f"Создание директории {os.path.dirname(self.file_path)}") os.makedirs(os.path.dirname(self.file_path), exist_ok=True) logger.info(f"Создание файла {self.file_path}") with open(self.file_path, "w") as f: json.dump({"sessions": {}}, f) os.chmod(self.file_path, 0o600) logger.info(f"Создан файл {self.file_path}") else: logger.info(f"Файл {self.file_path} уже существует") except Exception as e: logger.error( f"Ошибка при создании файла {self.file_path}: {type(e).__name__} - {str(e)}" ) raise def _read_data(self) -> Dict: """Читает данные из JSON-файла. Returns: Dict: Словарь с данными сессий. Raises: Exception: Если произошла ошибка при чтении файла. """ try: with open(self.file_path, "r") as f: return json.load(f) except Exception as e: logger.error( f"Ошибка при чтении файла {self.file_path}: {type(e).__name__} - {str(e)}" ) raise def _write_data(self, data: Dict): """Записывает данные в JSON-файл. Args: data (Dict): Словарь с данными сессий для записи. Raises: Exception: Если произошла ошибка при записи в файл. """ try: with open(self.file_path, "w") as f: json.dump(data, f, indent=2) except Exception as e: logger.error( f"Ошибка при записи файла {self.file_path}: {type(e).__name__} - {str(e)}" ) raise def create_session(self, username: str, token: str, client_id: str) -> str: """Создаёт новую сессию и возвращает уникальный session_id. Args: username (str): Имя пользователя, для которого создаётся сессия. token (str): Токен аутентификации. client_id (str): Идентификатор клиента. Returns: str: Уникальный идентификатор сессии (session_id). Raises: Exception: Если произошла ошибка при создании или записи сессии. Note: Удаляет истекшие сессии перед созданием новой и устанавливает срок действия 30 минут. """ try: self.clean_expired_sessions() data = self._read_data() session_id = str(uuid.uuid4()) created_at = datetime.utcnow().isoformat() expiry = (datetime.utcnow() + timedelta(minutes=30)).isoformat() data["sessions"][session_id] = { "username": username, "token": token, "client_id": client_id, "created_at": created_at, "expiry": expiry, } self._write_data(data) logger.info( f"Сессия создана для пользователя {username} с session_id {session_id}" ) return session_id except Exception as e: logger.error( f"Ошибка при создании сессии для {username}: {type(e).__name__} - {str(e)}" ) raise def get_latest_session(self, client_id: str) -> Optional[Dict]: """Получает последнюю активную сессию для указанного client_id. Args: client_id (str): Идентификатор клиента. Returns: Optional[Dict]: Словарь с данными последней активной сессии, включая "session_id", или None, если сессия не найдена. Raises: Exception: Если произошла ошибка при чтении или обработке данных. Note: Очищает истекшие сессии перед поиском и возвращает сессию с самым поздним временем создания. """ try: self.clean_expired_sessions() data = self._read_data() latest_session = None latest_time = None for session_id, session in data["sessions"].items(): if session["client_id"] == client_id: expiry = datetime.fromisoformat(session["expiry"]) if datetime.utcnow() > expiry: logger.info(f"Сессия {session_id} истекла") del data["sessions"][session_id] continue created_at = datetime.fromisoformat(session["created_at"]) if latest_time is None or created_at > latest_time: latest_time = created_at latest_session = session latest_session["session_id"] = session_id if latest_session: logger.info(f"Найдена сессия для client_id {client_id}") return latest_session logger.info(f"Сессия для client_id {client_id} не найдена") self._write_data(data) return None except Exception as e: logger.error( f"Ошибка при получении сессии для client_id {client_id}: {type(e).__name__} - {str(e)}" ) raise def delete_session(self, session_id: str): """Удаляет сессию по указанному session_id. Args: session_id (str): Уникальный идентификатор сессии. Raises: Exception: Если произошла ошибка при чтении или записи данных. Note: Очищает истекшие сессии перед удалением. """ try: self.clean_expired_sessions() data = self._read_data() if session_id in data["sessions"]: del data["sessions"][session_id] self._write_data(data) logger.info(f"Сессия с session_id {session_id} удалена") except Exception as e: logger.error( f"Ошибка при удалении сессии {session_id}: {type(e).__name__} - {str(e)}" ) raise def clean_expired_sessions(self): """Удаляет все истекшие сессии из хранилища. Raises: Exception: Если произошла ошибка при чтении или записи данных. Note: Проверяет срок действия каждой сессии и удаляет те, что истекли, записывая обновлённые данные. """ try: data = self._read_data() expired_sessions = [] current_time = datetime.utcnow() for session_id, session in list(data["sessions"].items()): expiry = datetime.fromisoformat(session["expiry"]) if current_time > expiry: expired_sessions.append(session_id) for session_id in expired_sessions: del data["sessions"][session_id] logger.info( f"Удалена истекшая сессия с session_id {session_id}") if expired_sessions: self._write_data(data) logger.info(f"Удалено {len(expired_sessions)} истекших сессий") else: logger.debug("Истекшие сессии не найдены") except Exception as e: logger.error( f"Ошибка при очистке истекших сессий: {type(e).__name__} - {str(e)}" ) raise