diff --git a/app/app_manager.py b/app/app_manager.py index 266d59b..7ac0ad2 100644 --- a/app/app_manager.py +++ b/app/app_manager.py @@ -28,6 +28,9 @@ class App: self.assets_dir = os.path.join(execute_dir, "assets") self.process_manager = AsyncProcessManager() self.config_manager = ConfigManager(self.run_path) + self.is_web_mode = False + self.auth_manager = None + self.current_username = None self.content_area = ft.Column( controls=[], expand=True, @@ -56,7 +59,6 @@ class App: self.page.snack_bar_area, ] ) - self.page.add(self.complete_page) self.snack_bar = ShowSnackBar(self.page) self.subprocess_start_up_info = utils.get_startup_info() self.record_card_manager = RecordingCardManager(self) diff --git a/app/auth/__init__.py b/app/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth/auth_manager.py b/app/auth/auth_manager.py new file mode 100644 index 0000000..d3f8c2e --- /dev/null +++ b/app/auth/auth_manager.py @@ -0,0 +1,93 @@ +import hashlib +import secrets +from typing import Optional + +from ..utils.logger import logger + + +class AuthManager: + + def __init__(self, app): + self.app = app + self.config_manager = app.config_manager + self.is_authenticated = False + self.session_token = None + self.active_sessions = {} + + async def initialize(self): + web_auth = self.config_manager.load_web_auth_config() + + if not web_auth.get("users"): + default_username = "admin" + default_password = "admin" + + salt = secrets.token_hex(8) + hashed_password = self._hash_password(default_password, salt) + + web_auth["users"] = [{ + "username": default_username, + "password_hash": hashed_password, + "salt": salt, + "is_admin": True + }] + + await self.config_manager.save_web_auth_config(web_auth) + logger.info("Default account created: admin/admin") + + def _hash_password(self, password: str, salt: str) -> str: + """Use SHA-256 and salt to hash password""" + return hashlib.sha256((password + salt).encode()).hexdigest() + + def _generate_session_token(self) -> str: + """Generate session token""" + return secrets.token_hex(16) + + async def authenticate(self, username: str, password: str) -> tuple[bool, Optional[str]]: + web_auth = self.config_manager.load_web_auth_config() + users = web_auth.get("users", []) + + for user in users: + if user["username"] == username: + salt = user["salt"] + hashed_password = self._hash_password(password, salt) + + if hashed_password == user["password_hash"]: + session_token = self._generate_session_token() + self.active_sessions[session_token] = { + "username": username, + "is_admin": user.get("is_admin", False) + } + return True, session_token + + return False, None + + def validate_session(self, session_token: str) -> bool: + """Validate session token""" + return session_token in self.active_sessions + + def logout(self, session_token: str) -> bool: + if session_token in self.active_sessions: + del self.active_sessions[session_token] + return True + return False + + async def change_password(self, username: str, old_password: str, new_password: str) -> bool: + web_auth = self.config_manager.load_web_auth_config() + users = web_auth.get("users", []) + + for i, user in enumerate(users): + if user["username"] == username: + salt = user["salt"] + hashed_old_password = self._hash_password(old_password, salt) + + if hashed_old_password == user["password_hash"]: + new_salt = secrets.token_hex(8) + hashed_new_password = self._hash_password(new_password, new_salt) + + web_auth["users"][i]["password_hash"] = hashed_new_password + web_auth["users"][i]["salt"] = new_salt + + await self.config_manager.save_web_auth_config(web_auth) + return True + + return False \ No newline at end of file diff --git a/app/core/config_manager.py b/app/core/config_manager.py index 2916a62..387352c 100644 --- a/app/core/config_manager.py +++ b/app/core/config_manager.py @@ -18,6 +18,7 @@ class ConfigManager: self.about_config_path = os.path.join(self.config_path, "version.json") self.recordings_config_path = os.path.join(self.config_path, "recordings.json") self.accounts_config_path = os.path.join(self.config_path, "accounts.json") + self.web_auth_config_path = os.path.join(self.config_path, "web_auth.json") os.makedirs(os.path.dirname(self.default_config_path), exist_ok=True) self.init() @@ -28,6 +29,7 @@ class ConfigManager: self.init_cookies_config() self.init_accounts_config() self.init_recordings_config() + self.init_web_auth_config() @staticmethod def _init_config(config_path, default_config=None): @@ -63,6 +65,10 @@ class ConfigManager: cookies_config = {} self._init_config(self.recordings_config_path, cookies_config) + def init_web_auth_config(self): + cookies_config = {} + self._init_config(self.web_auth_config_path, cookies_config) + @staticmethod def _load_config(config_path, error_message): """Load configuration from a JSON file.""" @@ -104,6 +110,9 @@ class ConfigManager: """Load i18n configuration from a JSON file.""" return self._load_config(path, "An error occurred while loading i18n config") + def load_web_auth_config(self): + return self._load_config(self.web_auth_config_path, "An error occurred while loading web auth config") + @staticmethod async def _save_config(config_path, config, success_message, error_message): """Save configuration to a JSON file.""" @@ -130,6 +139,14 @@ class ConfigManager: error_message="An error occurred while saving accounts config", ) + async def save_web_auth_config(self, config): + await self._save_config( + self.web_auth_config_path, + config, + success_message="Web auth configuration saved.", + error_message="An error occurred while saving web auth config", + ) + async def save_user_config(self, config): await self._save_config( self.user_config_path, diff --git a/app/ui/views/login_view.py b/app/ui/views/login_view.py new file mode 100644 index 0000000..c0228a4 --- /dev/null +++ b/app/ui/views/login_view.py @@ -0,0 +1,180 @@ +from typing import Callable + +import flet as ft + +from ...auth.auth_manager import AuthManager +from ...utils.logger import logger + + +class LoginPage: + + def __init__(self, page: ft.Page, auth_manager: AuthManager, on_login_success: Callable): + self.page = page + self.auth_manager = auth_manager + self.on_login_success = on_login_success + + app = auth_manager.app + language = app.language_manager.language + self._ = language.get("login_page", {}) + + self.page.title = self._["login_title"] + + self.username_field = ft.TextField( + label=self._["username"], + autofocus=True, + width=320, + border_radius=8, + prefix_icon=ft.icons.PERSON, + focused_border_color="#0078d4", + focused_color="#0078d4", + border_color="#d0d0d0", + bgcolor="#f5f5f5", + color="#333333", + label_style=ft.TextStyle(color="#666666"), + ) + + self.password_field = ft.TextField( + label=self._["password"], + password=True, + can_reveal_password=True, + width=320, + border_radius=8, + prefix_icon=ft.icons.LOCK_OUTLINE, + focused_border_color="#0078d4", + focused_color="#0078d4", + border_color="#d0d0d0", + bgcolor="#f5f5f5", + color="#333333", + label_style=ft.TextStyle(color="#666666"), + ) + + self.login_button = ft.ElevatedButton( + text=self._["login_button"], + width=320, + on_click=self.handle_login, + style=ft.ButtonStyle( + shape=ft.RoundedRectangleBorder(radius=8), + color="#ffffff", + bgcolor="#0078d4", + elevation=0, + padding=15, + animation_duration=300, + ), + ) + + self.error_text = ft.Text( + color=ft.colors.RED_500, + size=14, + visible=False, + ) + + self.logo = ft.Image( + src="/icons/loading-animation.png", + width=80, + height=80, + fit=ft.ImageFit.CONTAIN, + ) + + login_card_content = ft.Column( + controls=[ + ft.Container( + content=self.logo, + alignment=ft.alignment.center, + margin=ft.margin.only(bottom=10), + ), + ft.Text( + "StreamCap", + size=28, + weight=ft.FontWeight.BOLD, + color="#0078d4", + text_align=ft.TextAlign.CENTER, + ), + ft.Text( + self._["login_subtitle"], + size=16, + color="#666666", + text_align=ft.TextAlign.CENTER, + ), + ft.Container(height=20), + self.username_field, + ft.Container(height=10), + self.password_field, + ft.Container( + content=self.error_text, + margin=ft.margin.only(top=10), + alignment=ft.alignment.center, + ), + ft.Container(height=20), + self.login_button, + ft.Container(height=10), + ft.Text( + self._["default_account_tip"], + size=12, + color="#999999", + text_align=ft.TextAlign.CENTER, + ), + ], + alignment=ft.MainAxisAlignment.CENTER, + horizontal_alignment=ft.CrossAxisAlignment.CENTER, + spacing=5, + ) + + self.login_card = ft.Container( + content=login_card_content, + width=400, + height=600, + padding=30, + bgcolor=ft.colors.WHITE, + border_radius=12, + shadow=ft.BoxShadow( + spread_radius=1, + blur_radius=15, + color=ft.colors.with_opacity(0.1, "#000000"), + offset=ft.Offset(0, 4), + ), + ) + + self.main_view = ft.Container( + content=self.login_card, + alignment=ft.alignment.center, + expand=True, + bgcolor="#f0f2f5", + ) + + async def handle_login(self, e): + username = self.username_field.value + password = self.password_field.value + + if not username or not password: + self.show_error(self._["input_required"]) + return + + original_text = self.login_button.text + self.login_button.text = self._["login_in_progress"] + self.login_button.disabled = True + self.page.update() + + success, token = await self.auth_manager.authenticate(username, password) + + self.login_button.text = original_text + self.login_button.disabled = False + self.page.update() + + if success: + logger.info(f"Login successful: {username}") + await self.page.client_storage.set_async("session_token", token) + await self.on_login_success(token) + else: + self.show_error(self._["login_failed"]) + + def show_error(self, message: str): + self.error_text.value = message + self.error_text.visible = True + self.page.update() + + def clear_error(self): + self.error_text.visible = False + self.page.update() + + def get_view(self) -> ft.Control: + return self.main_view \ No newline at end of file diff --git a/app/ui/views/settings_view.py b/app/ui/views/settings_view.py index ea39929..7b0bc2e 100644 --- a/app/ui/views/settings_view.py +++ b/app/ui/views/settings_view.py @@ -30,6 +30,7 @@ class SettingsPage(PageBase): self.tab_push = None self.tab_cookies = None self.tab_accounts = None + self.tab_security = None self.has_unsaved_changes = {} self.delay_handler = DelayedTaskExecutor(self.app, self) self.load_language() @@ -37,8 +38,6 @@ class SettingsPage(PageBase): self.page.on_keyboard_event = self.on_keyboard async def load(self): - """Load the settings page content with tabs for different categories.""" - self.content_area.clean() language = self.app.language_manager.language self._ = language["settings_page"] | language["video_quality"] | language["base"] @@ -48,15 +47,21 @@ class SettingsPage(PageBase): self.tab_accounts = self.create_accounts_settings_tab() self.page.on_keyboard_event = self.on_keyboard + tabs = [ + ft.Tab(text=self._["recording_settings"], content=self.tab_recording), + ft.Tab(text=self._["push_settings"], content=self.tab_push), + ft.Tab(text=self._["cookies_settings"], content=self.tab_cookies), + ft.Tab(text=self._["accounts_settings"], content=self.tab_accounts), + ] + + if self.app.page.web: + self.tab_security = self.create_security_settings_tab() + tabs.append(ft.Tab(text=self._["security_settings"], content=self.tab_security)) + settings_tabs = ft.Tabs( selected_index=0, animation_duration=300, - tabs=[ - ft.Tab(text=self._["recording_settings"], content=self.tab_recording), - ft.Tab(text=self._["push_settings"], content=self.tab_push), - ft.Tab(text=self._["cookies_settings"], content=self.tab_cookies), - ft.Tab(text=self._["accounts_settings"], content=self.tab_accounts), - ], + tabs=tabs, ) scrollable_content = ft.Container( @@ -143,12 +148,12 @@ class SettingsPage(PageBase): self.user_config[key] = e.data.lower() == "true" else: self.user_config[key] = e.data - + if key in ["folder_name_platform", "folder_name_author", "folder_name_time", "folder_name_title"]: for recording in self.app.record_manager.recordings: recording.recording_dir = None self.page.run_task(self.app.record_manager.persist_recordings) - + if key == "language": self.load_language() self.app.language_manager.load() @@ -759,7 +764,7 @@ class SettingsPage(PageBase): self._["telegram"], ft.Icons.SMS, "telegram_enabled" ), ] - + if self.app.page.web: return ft.Row( controls=controls, @@ -778,7 +783,7 @@ class SettingsPage(PageBase): ), expand=True, ) - + def create_cookies_settings_tab(self): """Create UI elements for push configuration.""" platforms = [ @@ -848,7 +853,7 @@ class SettingsPage(PageBase): ) def create_accounts_settings_tab(self): - """Create UI elements for push configuration.""" + """Create UI elements for platform accounts configuration.""" return ft.Column( [ self.create_setting_group( @@ -946,7 +951,6 @@ class SettingsPage(PageBase): ) def create_folder_setting_row(self, label): - """Helper method to create a row of checkboxes for folder settings.""" return ft.Row( [ ft.Text(label, width=200, text_align=ft.TextAlign.RIGHT), @@ -1030,7 +1034,8 @@ class SettingsPage(PageBase): def create_setting_row(self, label, control): """Helper method to create a row for each setting.""" - control.on_focus = lambda e: self.set_focused_control(e.control) + if hasattr(control, 'on_focus'): + control.on_focus = lambda e: self.set_focused_control(e.control) return ft.Row( [ft.Text(label, width=200, text_align=ft.TextAlign.RIGHT), control], alignment=ft.MainAxisAlignment.START, @@ -1095,4 +1100,100 @@ class SettingsPage(PageBase): self.app.dialog_area.update() if self.app.current_page == self and e.ctrl and e.key == "S": - self.page.run_task(self.is_changed) \ No newline at end of file + self.page.run_task(self.is_changed) + + def create_security_settings_tab(self): + + async def change_password(_): + old_password = old_password_field.value + new_password = new_password_field.value + confirm_password = confirm_password_field.value + + if not old_password: + await self.app.snack_bar.show_snack_bar(self._["old_password_required"], bgcolor=ft.Colors.RED) + return + + if not new_password: + await self.app.snack_bar.show_snack_bar(self._["new_password_required"], bgcolor=ft.Colors.RED) + return + + if new_password != confirm_password: + await self.app.snack_bar.show_snack_bar(self._["passwords_not_match"], bgcolor=ft.Colors.RED) + return + + _username = self.app.current_username + if _username: + success = await self.app.auth_manager.change_password(_username, old_password, new_password) + + if success: + old_password_field.value = "" + new_password_field.value = "" + confirm_password_field.value = "" + old_password_field.update() + new_password_field.update() + confirm_password_field.update() + + await self.app.snack_bar.show_snack_bar(self._["password_changed"], bgcolor=ft.Colors.GREEN) + else: + await self.app.snack_bar.show_snack_bar(self._["old_password_incorrect"], bgcolor=ft.Colors.RED) + else: + await self.app.snack_bar.show_snack_bar(self._["not_logged_in"], bgcolor=ft.Colors.RED) + + username = self.app.current_username or "admin" + + old_password_field = ft.TextField( + password=True, + width=300, + label=self._["old_password"], + ) + + new_password_field = ft.TextField( + password=True, + width=300, + label=self._["new_password"], + ) + + confirm_password_field = ft.TextField( + password=True, + width=300, + label=self._["confirm_password"], + ) + + change_password_button = ft.ElevatedButton( + text=self._["change_password"], + on_click=change_password, + icon=ft.icons.LOCK_RESET, + ) + + return ft.Column( + [ + self.create_setting_group( + self._["security_settings"], + self._["web_login_configuration"], + [ + self.create_setting_row( + self._["current_username"], + ft.Text(username), + ), + self.create_setting_row( + self._["old_password"], + old_password_field, + ), + self.create_setting_row( + self._["new_password"], + new_password_field, + ), + self.create_setting_row( + self._["confirm_password"], + confirm_password_field, + ), + self.create_setting_row( + "", + change_password_button, + ), + ], + ), + ], + spacing=10, + scroll=ft.ScrollMode.AUTO, + ) diff --git a/locales/en.json b/locales/en.json index 7f93ec3..c4d1066 100644 --- a/locales/en.json +++ b/locales/en.json @@ -144,6 +144,7 @@ "basic_settings": "Basic Settings", "cookies_settings": "Cookie Settings", "accounts_settings": "Account Settings", + "security_settings": "Security Settings", "select": "select", "unsupported_select_path": "Path selection is not supported on the web 📂 Please enter manually", "program_config": "Basic configuration of the program", @@ -178,6 +179,18 @@ "custom_script": "Execute Custom Script After Recording", "script_command": "Custom Script Execution Command", "default_platform_with_proxy": "Default Platform for Recording with Proxy", + "web_login_configuration": "Web Backend Login Configuration", + "current_username": "Current Username", + "old_password": "Old Password", + "new_password": "New Password", + "confirm_password": "Confirm Password", + "change_password": "Change Password", + "old_password_required": "Old password cannot be empty", + "new_password_required": "New password cannot be empty", + "passwords_not_match": "The two passwords do not match", + "password_changed": "Password changed successfully", + "old_password_incorrect": "Old password is incorrect", + "not_logged_in": "You are not logged in", "push_notifications": "Push Notifications", "stream_start_notification_enabled": "Live Status Notification", "open_broadcast_push_enabled": "Broadcast Start Push", @@ -402,5 +415,17 @@ "saving_recordings": "Saving {active_recordings_count} recordings, please wait...", "confirm_exit": "Confirm Exit", "confirm_exit_content": "Are you sure you want to exit the program?" + }, + "login_page": { + "login_title": "StreamCap Login", + "login_subtitle": "Please login to your account", + "username": "Username", + "password": "Password", + "login_button": "Login", + "login_in_progress": "Logging in...", + "default_account_tip": "Default account: admin / Password: admin", + "input_required": "Please enter username and password", + "login_failed": "Username or password is incorrect", + "login_success": "Login successful" } } \ No newline at end of file diff --git a/locales/zh_CN.json b/locales/zh_CN.json index 2eeb01d..947e66a 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -145,6 +145,7 @@ "basic_settings": "基础设置", "cookies_settings": "Cookie设置", "accounts_settings": "账号设置", + "security_settings": "安全设置", "select": "选择", "unsupported_select_path": "Web端不支持选择路径📂请手动输入", "program_config": "程序的基本设置", @@ -179,6 +180,18 @@ "custom_script": "录制完成后执行自定义脚本", "script_command": "自定义脚本执行命令", "default_platform_with_proxy": "默认使用代理录制的平台", + "web_login_configuration": "Web后台登录配置", + "current_username": "当前用户名", + "old_password": "旧密码", + "new_password": "新密码", + "confirm_password": "确认密码", + "change_password": "修改密码", + "old_password_required": "旧密码不能为空", + "new_password_required": "新密码不能为空", + "passwords_not_match": "两次输入的密码不一致", + "password_changed": "密码修改成功", + "old_password_incorrect": "旧密码不正确", + "not_logged_in": "您尚未登录", "push_notifications": "推送通知", "stream_start_notification_enabled": "直播状态推送开关", "open_broadcast_push_enabled": "开播推送开启", @@ -403,5 +416,17 @@ "saving_recordings": "正在保存 {active_recordings_count} 个录制内容,请稍候...", "confirm_exit": "确认退出", "confirm_exit_content": "确定要退出程序吗?" + }, + "login_page": { + "login_title": "StreamCap 登录", + "login_subtitle": "请登录您的账号", + "username": "用户名", + "password": "密码", + "login_button": "登录", + "login_in_progress": "登录中...", + "default_account_tip": "默认账号: admin / 密码: admin", + "input_required": "请输入用户名和密码", + "login_failed": "用户名或密码错误", + "login_success": "登录成功" } } \ No newline at end of file diff --git a/main.py b/main.py index 011fca7..8e0964d 100644 --- a/main.py +++ b/main.py @@ -7,8 +7,10 @@ from dotenv import load_dotenv from screeninfo import get_monitors from app.app_manager import App, execute_dir +from app.auth.auth_manager import AuthManager from app.lifecycle.app_close_handler import handle_app_close from app.ui.components.save_progress_overlay import SaveProgressOverlay +from app.ui.views.login_view import LoginPage from app.utils.logger import logger DEFAULT_HOST = "127.0.0.1" @@ -78,7 +80,7 @@ def handle_disconnect(page: ft.Page) -> callable: return disconnect -def main(page: ft.Page) -> None: +async def main(page: ft.Page) -> None: page.title = "StreamCap" page.window.min_width = MIN_WIDTH @@ -89,6 +91,7 @@ def main(page: ft.Page) -> None: app = App(page) page.data = app + app.is_web_mode = is_web theme_mode = app.settings.user_config.get("theme_mode", "light") if theme_mode == "dark": @@ -98,16 +101,44 @@ def main(page: ft.Page) -> None: save_progress_overlay = SaveProgressOverlay(app) page.overlay.append(save_progress_overlay.overlay) + + async def load_app(): + page.add(app.complete_page) + + page.on_route_change = handle_route_change(page, app) + page.window.prevent_close = True + page.window.on_event = handle_window_event(page, app, save_progress_overlay) - page.on_route_change = handle_route_change(page, app) - page.window.prevent_close = True - page.window.on_event = handle_window_event(page, app, save_progress_overlay) + if is_web: + page.on_disconnect = handle_disconnect(page) + + page.update() + page.on_route_change(ft.RouteChangeEvent(route=page.route)) if is_web: - page.on_disconnect = handle_disconnect(page) - - page.update() - page.on_route_change(ft.RouteChangeEvent(route=page.route)) + auth_manager = AuthManager(app) + app.auth_manager = auth_manager + await auth_manager.initialize() + + session_token = await page.client_storage.get_async("session_token") + if not session_token or not auth_manager.validate_session(session_token): + async def on_login_success(token): + _session_info = auth_manager.active_sessions.get(token, {}) + app.current_username = _session_info.get("username") + + page.clean() + await load_app() + + page.clean() + + login_page = LoginPage(page, auth_manager, on_login_success) + page.add(login_page.get_view()) + return + else: + session_info = auth_manager.active_sessions.get(session_token, {}) + app.current_username = session_info.get("username") + + await load_app() if __name__ == "__main__":