diff --git a/trunk/conf/console.conf b/trunk/conf/console.conf index 7a4eb3108..20ca385f9 100644 --- a/trunk/conf/console.conf +++ b/trunk/conf/console.conf @@ -1,37 +1,186 @@ -# no-daemon and write log to console config for srs. -# @see full.conf for detail config. +# docker config for srs. +# @see full.conf for detail config explanation. +############################################################################################# +# Last modified: 2025-05-10 Jason Yang +# Contributor: SRS Team, Jason Yang, Jasper, HyperKNF, Hayden, NPL ITP Team +############################################################################################# -listen 1935; -max_connections 1000; -daemon off; -srs_log_tank all; +############################################################################################# +# Global sections +############################################################################################# +ff_log_dir ./objs; +ff_log_level info; + +srs_log_tank all; +srs_log_file ./objs/srs.log; +# TRACE, DEBUG, INFO, WARN, ERROR +srs_log_level_v2 info; + +max_connections 1000; +daemon off; +utc_time off; + +############################################################################################# +# RTMP sections +############################################################################################# +listen 1935; +chunk_size 60000; + +############################################################################################# +# HTTP sections +############################################################################################# http_api { - enabled on; - listen 1985; + enabled on; + listen 1985; + crossdomain on; + auth { + enabled on; + username python_stats; + password wMePq3ahpoLRzgsVg7BY9eE82uuJHT0YukD2ZE1JfMY2RjP4e6QnUaKg3V9x5s9M; + } + https { + enabled off; + listen 1990; + key ./conf/server.key; + cert ./conf/server.crt; + } } http_server { - enabled on; - listen 8080; + enabled on; + listen 8080; + dir ./objs/nginx/html; + crossdomain on; + https { + enabled off; + listen 8088; + key ./conf/server.key; + cert ./conf/server.crt; + } } + +############################################################################################# +# SRT server section +############################################################################################# +srt_server { + # whether SRT server is enabled. + enabled off; + listen 10080; + + maxbw 1000000000; + mss 1500; + + connect_timeout 4000; + peer_idle_timeout 8000; + + default_app live; + peerlatency 0; + recvlatency 0; + latency 0; + + tsbpdmode off; + tlpktdrop off; + sendbuf 2000000; + recvbuf 2000000; +} + +############################################################################################# +# WebRTC server section +############################################################################################# rtc_server { - enabled on; - listen 8000; # UDP port - # @see https://ossrs.net/lts/zh-cn/docs/v4/doc/webrtc#config-candidate - candidate $CANDIDATE; + enabled on; + listen 8000; + + protocol udp; + tcp { + enabled off; + listen 8000; + } + candidate $CANDIDATE; + ip_family ipv4; + api_as_candidates on; + resolve_api_domain on; + + ecdsa on; + encrypt on; } + +############################################################################################# +# PYTHON_ADDONS sections +############################################################################################# +python_addons { + # Enable or disable Python addon management + enabled on; + + # Python addon definitions + # Each addon block defines a Python script to run + addon { + script "./python/httpbackend_server.py"; + } +} + +############################################################################################# +# VHOST sections +############################################################################################# vhost __defaultVhost__ { + enabled on; + hls { - enabled on; + enabled on; } + srt { + # Whether enable SRT on this vhost. + enabled on; + srt_to_rtmp on; + } + http_remux { - enabled on; - mount [vhost]/[app]/[stream].flv; + enabled on; + mount [vhost]/[app]/[stream].flv; } + rtc { - enabled on; + enabled on; + # @see https://ossrs.net/lts/zh-cn/docs/v4/doc/webrtc#rtmp-to-rtc - rtmp_to_rtc on; + rtmp_to_rtc on; + keep_bframe on; + # opus_bitrate 48000; + # @see https://ossrs.net/lts/zh-cn/docs/v4/doc/webrtc#rtc-to-rtmp - rtc_to_rtmp on; + rtc_to_rtmp on; + pli_for_rtmp 6.0; + aac_bitrate 48000; } -} + + chunk_size 128; + tcp_nodelay on; + min_latency on; + play { + gop_cache off; + queue_length 10; + mw_latency 100; + mw_msgs 0; + } + publish { + mr off; + mr_latency 300; + firstpkt_timeout 20000; + normal_timeout 5000; + } + + security { + enabled off; + allow play all; + allow publish all; + } + + dvr { + enabled on; + dvr_path ./DVR_Record/[stream]/[2006].[01].[02].[15].[04].[05].mp4; + dvr_plan segment; + dvr_duration 30; + dvr_apply /^live\/.*/; + dvr_wait_keyframe on; + time_jitter full; + } +} \ No newline at end of file diff --git a/trunk/livestream_site b/trunk/livestream_site new file mode 160000 index 000000000..348b347bd --- /dev/null +++ b/trunk/livestream_site @@ -0,0 +1 @@ +Subproject commit 348b347bd7e1cf9d59acff0ba1bdf5390f0751d7 diff --git a/trunk/python/analytics.py b/trunk/python/analytics.py deleted file mode 100644 index 7b36439b9..000000000 --- a/trunk/python/analytics.py +++ /dev/null @@ -1,122 +0,0 @@ -#!/usr/bin/env python3 -""" -SRS Analytics Script -This demonstrates another Python process that can run alongside SRS. -""" - -import time -import signal -import sys -import argparse -import logging -import json -from http.server import HTTPServer, BaseHTTPRequestHandler -from threading import Thread -from datetime import datetime - -class AnalyticsHandler(BaseHTTPRequestHandler): - def do_GET(self): - """Handle GET requests for analytics data""" - if self.path == '/stats': - # Return sample analytics data - stats = { - 'timestamp': datetime.now().isoformat(), - 'connections': 42, - 'streams': 5, - 'bandwidth': '1.2 Mbps', - 'uptime': '2h 30m' - } - - self.send_response(200) - self.send_header('Content-type', 'application/json') - self.end_headers() - self.wfile.write(json.dumps(stats, indent=2).encode()) - else: - self.send_response(404) - self.end_headers() - - def log_message(self, format, *args): - """Override to use our logger""" - pass - -class SRSAnalytics: - def __init__(self, port=8888): - self.port = port - self.running = True - self.server = None - self.server_thread = None - - # Set up logging - logging.basicConfig( - level=logging.INFO, - format='[%(asctime)s] [Python Analytics] %(levelname)s: %(message)s', - datefmt='%Y-%m-%d %H:%M:%S' - ) - self.logger = logging.getLogger(__name__) - - # Set up signal handlers - signal.signal(signal.SIGTERM, self.signal_handler) - signal.signal(signal.SIGINT, self.signal_handler) - - def signal_handler(self, signum, frame): - """Handle shutdown signals from SRS""" - self.logger.info(f"Received signal {signum}, shutting down gracefully...") - self.running = False - if self.server: - self.server.shutdown() - - def start_http_server(self): - """Start the HTTP analytics server""" - try: - self.server = HTTPServer(('localhost', self.port), AnalyticsHandler) - self.logger.info(f"Analytics server started on http://localhost:{self.port}") - self.server.serve_forever() - except Exception as e: - self.logger.error(f"Error starting HTTP server: {e}") - - def run(self): - """Main analytics loop""" - self.logger.info(f"SRS Analytics started on port {self.port}") - - try: - # Start HTTP server in a separate thread - self.server_thread = Thread(target=self.start_http_server) - self.server_thread.daemon = True - self.server_thread.start() - - # Main analytics loop - while self.running: - # Simulate analytics work - self.logger.debug("Processing analytics data...") - - # You can add your analytics logic here: - # - Collect stream metrics - # - Process viewer statistics - # - Generate reports - # - Store data to database - - time.sleep(10) # Process every 10 seconds - - except Exception as e: - self.logger.error(f"Error in analytics loop: {e}") - finally: - self.cleanup() - - def cleanup(self): - """Cleanup before shutdown""" - self.logger.info("Cleaning up Analytics server...") - if self.server: - self.server.shutdown() - self.logger.info("Analytics server stopped") - -def main(): - parser = argparse.ArgumentParser(description='SRS Analytics Server') - parser.add_argument('--port', type=int, default=8888, help='HTTP server port') - - args = parser.parse_args() - - analytics = SRSAnalytics(args.port) - analytics.run() - -if __name__ == '__main__': - main() diff --git a/trunk/python/avatar_module.py b/trunk/python/avatar_module.py new file mode 100644 index 000000000..fb97a8c14 --- /dev/null +++ b/trunk/python/avatar_module.py @@ -0,0 +1,616 @@ +import math +from typing import Optional, List + +class AvatarGenerator: + DEFAULT_COLORS = ["#000000", "#8f1414", "#e50e0e", "#f3450f", "#fcac03"] + DEFAULT_SIZE = 80 + DEFAULT_VARIANT = 'marble' + VALID_VARIANTS = {'marble', 'beam', 'pixel', 'sunset', 'ring', 'bauhaus'} + + def __init__(self, default_colors: Optional[List[str]] = None, default_size: Optional[int] = None, default_variant: Optional[str] = None): + self.colors = default_colors if default_colors else self.DEFAULT_COLORS + self.size = default_size if default_size else self.DEFAULT_SIZE + self.variant = default_variant if default_variant else self.DEFAULT_VARIANT + + self.AVATAR_GENERATORS = { + 'marble': self._generate_marble_avatar, + 'beam': self._generate_beam_avatar, + 'pixel': self._generate_pixel_avatar, + 'sunset': self._generate_sunset_avatar, + 'ring': self._generate_ring_avatar, + 'bauhaus': self._generate_bauhaus_avatar, + } + + @staticmethod + def _hash_code(name: str) -> int: + """Generate hash from name string - exact port of hashCode function""" + try: + if not name or not isinstance(name, str): + # Consider raising a TypeError or returning a default hash for invalid input + return 12345 # Fallback for invalid input + hash_val = 0 + for char in name: + hash_val = (hash_val << 5) - hash_val + ord(char) + hash_val &= hash_val # Convert to 32bit integer + return abs(hash_val) + except Exception: + return 12345 # fallback hash value + + @staticmethod + def _get_modulus(num: int, max_val: int) -> int: + """Get modulus""" + return num % max_val + + @staticmethod + def _get_digit(number: int, ntn: int) -> int: + """Get digit at position""" + try: + if not isinstance(number, int) or not isinstance(ntn, int): + return 0 # Fallback for invalid input type + return int((number / (10 ** ntn)) % 10) + except (ZeroDivisionError, ValueError, OverflowError): + return 0 + + @staticmethod + def _get_boolean(number: int, ntn: int) -> bool: + """Get boolean from digit""" + return not ((AvatarGenerator._get_digit(number, ntn)) % 2) + + @staticmethod + def _get_angle(x: float, y: float) -> float: + """Get angle from coordinates""" + return math.atan2(y, x) * 180 / math.pi + + @staticmethod + def _get_unit(number: int, range_val: int, index: int = None) -> int: + """Get unit value with optional negative""" + try: + if not isinstance(number, int) or not isinstance(range_val, int) or range_val == 0: + return 0 # Fallback for invalid input + value = number % range_val + if index and ((AvatarGenerator._get_digit(number, index) % 2) == 0): + return -value + return value + except (ZeroDivisionError, ValueError, TypeError): + return 0 + + @staticmethod + def _get_random_color(number: int, colors: List[str], range_val: int) -> str: + """Get random color from palette""" + try: + if not colors or range_val == 0: + return AvatarGenerator.DEFAULT_COLORS[0] # Fallback + return colors[number % range_val] + except (IndexError, TypeError, ZeroDivisionError): + return AvatarGenerator.DEFAULT_COLORS[0] + + @staticmethod + def _get_contrast(hexcolor: str) -> str: + """Get contrasting color (black or white)""" + try: + if not hexcolor or not isinstance(hexcolor, str): + return '#000000' # Fallback + + # Remove leading # if present + if hexcolor.startswith('#'): + hexcolor = hexcolor[1:] + + # Clean up any remaining quotes or whitespace + hexcolor = hexcolor.strip().replace('"', '').replace("'", "") + + # Ensure we have exactly 6 hex characters + if len(hexcolor) != 6: + return '#000000' # Fallback + + # Validate hex characters + try: + int(hexcolor, 16) + except ValueError: + return '#000000' # Fallback + + # Convert to RGB + r = int(hexcolor[0:2], 16) + g = int(hexcolor[2:4], 16) + b = int(hexcolor[4:6], 16) + + # Get YIQ ratio + yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000 + + # Check contrast + return '#000000' if yiq >= 128 else '#FFFFFF' + except Exception: + return '#000000' + + @staticmethod + def normalize_colors(colors_param: Optional[str]) -> List[str]: + """Normalize color palette from query parameter""" + try: + if not colors_param: + return AvatarGenerator.DEFAULT_COLORS + + # Clean up URL encoding + cleaned = colors_param.replace('%22', '"').replace('%20', ' ').replace('%5B', '[').replace('%5D', ']').strip() + + # Extract colors from different formats + color_list = AvatarGenerator._extract_color_strings(cleaned) + + # Validate and normalize colors + return AvatarGenerator._validate_color_list(color_list) + + except Exception: + return AvatarGenerator.DEFAULT_COLORS + + @staticmethod + def _extract_color_strings(cleaned_colors: str) -> List[str]: + """Extract color strings from cleaned input""" + # Handle array format: ["#xxxxxx", "#xxxxxx", ...] + if cleaned_colors.startswith('[') and cleaned_colors.endswith(']'): + inner_content = cleaned_colors[1:-1].strip() + if not inner_content: + return [] + return [part.strip().replace('"', '').replace("'", '') for part in inner_content.split(',')] + + # Handle comma-separated format: #xxxxxx, #xxxxxx, ... + return [part.strip().replace('"', '').replace("'", '') for part in cleaned_colors.split(',')] + + @staticmethod + def _validate_color_list(color_list: List[str]) -> List[str]: + """Validate and normalize hex colors""" + normalized_colors = [] + + for color in color_list: + color = color.strip() + if not color: + continue + if not color.startswith('#'): + color = '#' + color + if AvatarGenerator._is_valid_hex_color(color): + normalized_colors.append(color) + + return normalized_colors if normalized_colors else AvatarGenerator.DEFAULT_COLORS + + @staticmethod + def _is_valid_hex_color(color: str) -> bool: + """Check if string is valid hex color""" + return len(color) == 7 and all(c in '0123456789ABCDEFabcdef' for c in color[1:]) + + def _generate_marble_avatar(self, name: str, colors: List[str], size: int, square: bool, title: bool) -> str: + """Generate marble variant avatar""" + ELEMENTS = 3 + SIZE = 80 # Intrinsic size of the SVG design + + def generate_colors_marble(name: str, current_colors: List[str]): + num_from_name = self._hash_code(name) + range_val = len(current_colors) if current_colors else len(self.DEFAULT_COLORS) + + elements_properties = [] + for i in range(ELEMENTS): + elements_properties.append({ + 'color': self._get_random_color(num_from_name + i, current_colors, range_val), + 'translateX': self._get_unit(num_from_name * (i + 1), SIZE // 10, 1), + 'translateY': self._get_unit(num_from_name * (i + 1), SIZE // 10, 2), + 'scale': 1.2 + self._get_unit(num_from_name * (i + 1), SIZE // 20) / 10, + 'rotate': self._get_unit(num_from_name * (i + 1), 360, 1) + }) + + return elements_properties + + properties = generate_colors_marble(name, colors) + mask_id = f"mask_{self._hash_code(name)}" + filter_id = f"filter_{self._hash_code(name)}" + + # Use the passed 'size' for width/height, internal 'SIZE' for viewBox + svg = f'''''' + + return svg + + def _generate_beam_avatar(self, name: str, colors: List[str], size: int, square: bool, title: bool) -> str: + """Generate beam variant avatar""" + SIZE = 36 # Intrinsic size of the SVG design + + def generate_data_beam(name: str, current_colors: List[str]): + num_from_name = self._hash_code(name) + range_val = len(current_colors) if current_colors else len(self.DEFAULT_COLORS) + wrapper_color = self._get_random_color(num_from_name, current_colors, range_val) + pre_translate_x = self._get_unit(num_from_name, 10, 1) + wrapper_translate_x = pre_translate_x + SIZE // 9 if pre_translate_x < 5 else pre_translate_x + pre_translate_y = self._get_unit(num_from_name, 10, 2) + wrapper_translate_y = pre_translate_y + SIZE // 9 if pre_translate_y < 5 else pre_translate_y + + data = { + 'wrapperColor': wrapper_color, + 'faceColor': self._get_contrast(wrapper_color), + 'backgroundColor': self._get_random_color(num_from_name + 13, current_colors, range_val), + 'wrapperTranslateX': wrapper_translate_x, + 'wrapperTranslateY': wrapper_translate_y, + 'wrapperRotate': self._get_unit(num_from_name, 360), + 'wrapperScale': 1 + self._get_unit(num_from_name, SIZE // 12) / 10, + 'isMouthOpen': self._get_boolean(num_from_name, 2), + 'isCircle': self._get_boolean(num_from_name, 1), + 'eyeSpread': self._get_unit(num_from_name, 5), + 'mouthSpread': self._get_unit(num_from_name, 3), + 'faceRotate': self._get_unit(num_from_name, 10, 3), + 'faceTranslateX': wrapper_translate_x // 2 if wrapper_translate_x > SIZE // 6 else self._get_unit(num_from_name, 8, 1), + 'faceTranslateY': wrapper_translate_y // 2 if wrapper_translate_y > SIZE // 6 else self._get_unit(num_from_name, 7, 2), + } + + return data + + data = generate_data_beam(name, colors) + mask_id = f"mask_{self._hash_code(name)}" + + svg = f'''''' + + return svg + + def _generate_pixel_avatar(self, name: str, colors: List[str], size: int, square: bool, title: bool) -> str: + """Generate pixel variant avatar""" + ELEMENTS = 64 # 8x8 grid + SIZE = 80 # Intrinsic SVG size + + def generate_colors_pixel(name: str, current_colors: List[str]): + num_from_name = self._hash_code(name) + # Use current_colors if provided, otherwise fallback to instance default, then class default + final_colors = current_colors if current_colors else self.colors + range_val = len(final_colors) + + color_list = [] + for i in range(ELEMENTS): + color_list.append(self._get_random_color(num_from_name + i, final_colors, range_val)) + return color_list + + pixel_colors = generate_colors_pixel(name, colors) + mask_id = f"mask_{self._hash_code(name)}" + + svg = f'''''' + + return svg + + def _generate_sunset_avatar(self, name: str, colors: List[str], size: int, square: bool, title: bool) -> str: + """Generate sunset variant avatar""" + ELEMENTS = 4 # For 4 gradient stops + SIZE = 80 # Intrinsic SVG size + + def generate_colors_sunset(name: str, current_colors: List[str]): + num_from_name = self._hash_code(name) + # Use current_colors if provided, otherwise fallback to instance default, then class default + final_colors = current_colors if current_colors else self.colors + # Ensure we have at least 4 colors for sunset, repeat if necessary + if len(final_colors) < ELEMENTS: + final_colors = (final_colors * (ELEMENTS // len(final_colors) + 1))[:ELEMENTS] + + range_val = len(final_colors) + + color_list = [] + for i in range(ELEMENTS): + color_list.append(self._get_random_color(num_from_name + i, final_colors, range_val)) + return color_list + + sunset_colors = generate_colors_sunset(name, colors) + # name_without_space = name.replace(' ', '').replace('-', '').replace('_', '') # Not used + hash_val = abs(self._hash_code(name)) + mask_id = f"mask_{hash_val}" + gradient_id_1 = f"gradient_paint0_linear_{hash_val}" + gradient_id_2 = f"gradient_paint1_linear_{hash_val}" + + rx_value = '' if square else f'rx="{SIZE * 2}"' + + svg = f'''''' + + return svg + + def _generate_ring_avatar(self, name: str, colors: List[str], size: int, square: bool, title: bool) -> str: + """Generate ring variant avatar""" + SIZE = 90 # Intrinsic SVG size + COLORS_NEEDED = 9 # Number of colors used in the SVG paths + + def generate_colors_ring(name: str, current_colors: List[str]): + num_from_name = self._hash_code(name) + # Use current_colors if provided, otherwise fallback to instance default, then class default + final_colors = current_colors if current_colors else self.colors + # Ensure we have enough colors, repeat if necessary + if len(final_colors) < COLORS_NEEDED: + final_colors = (final_colors * (COLORS_NEEDED // len(final_colors) + 1))[:COLORS_NEEDED] + + range_val = len(final_colors) + + ring_palette = [] + for i in range(COLORS_NEEDED): + ring_palette.append(self._get_random_color(num_from_name + i, final_colors, range_val)) + return ring_palette + + ring_colors = generate_colors_ring(name, colors) + mask_id = f"mask_{self._hash_code(name)}" + + svg = f'''''' + + return svg + + def _generate_bauhaus_avatar(self, name: str, colors: List[str], size: int, square: bool, title: bool) -> str: + """Generate bauhaus variant avatar""" + ELEMENTS = 4 # Number of geometric elements + SIZE = 80 # Intrinsic SVG size + + def generate_colors_bauhaus(name: str, current_colors: List[str]): + num_from_name = self._hash_code(name) + # Use current_colors if provided, otherwise fallback to instance default, then class default + final_colors = current_colors if current_colors else self.colors + range_val = len(final_colors) + + properties = [] + for i in range(ELEMENTS): + properties.append({ + 'color': self._get_random_color(num_from_name + i, final_colors, range_val), + 'translateX': self._get_unit(num_from_name * (i + 1), SIZE // 2 - (i + 17), 1), + 'translateY': self._get_unit(num_from_name * (i + 1), SIZE // 2 - (i + 17), 2), + 'rotate': self._get_unit(num_from_name * (i + 1), 360), + 'isSquare': self._get_boolean(num_from_name, 2) + }) + return properties + + properties = generate_colors_bauhaus(name, colors) + mask_id = f"mask_{self._hash_code(name)}" + + svg = f'''''' + + return svg + + def generate_avatar(self, + name: str, + variant: Optional[str] = None, + colors: Optional[List[str]] = None, # Allow passing colors as list of strings + colors_param: Optional[str] = None, # Allow passing colors as a string parameter + size: Optional[int] = None, + square: bool = False, + title: bool = True) -> str: + """ + Generate an SVG avatar. + + Args: + name: The name to generate the avatar for. + variant: The avatar variant (e.g., 'marble', 'beam'). Uses instance default if None. + colors: A list of hex color strings. Overrides colors_param if provided. + colors_param: A string representation of colors (e.g., "['#FF0000', '#00FF00']" or "#FF0000,#00FF00"). + Used if 'colors' list is not provided. + size: The desired size of the avatar in pixels. Uses instance default if None. + square: If True, generates a square avatar. Otherwise, a circular one. + title: If True, includes a
This is a simple HTTP server running as an SRS addon.
-Status: Running
-Time: {}
- - - '''.format(time.strftime('%Y-%m-%d %H:%M:%S')) - self.wfile.write(response.encode()) - elif self.path == '/status': - self.send_response(200) - self.send_header('Content-type', 'application/json') - self.end_headers() - response = '{"status": "running", "time": "%s"}' % time.strftime('%Y-%m-%d %H:%M:%S') - self.wfile.write(response.encode()) - else: - self.send_response(404) - self.end_headers() - - def log_message(self, format, *args): - """Override to use our logger.""" - logger.info("%s - %s" % (self.address_string(), format % args)) - -class SRSHTTPAddon: - """SRS HTTP Addon main class.""" - - def __init__(self, port=8888): - self.port = port - self.server = None - self.running = False - self.server_thread = None - - def start(self): - """Start the HTTP server.""" - try: - self.server = HTTPServer(('', self.port), SRSAddonHandler) - self.running = True - - # Start server in a separate thread - self.server_thread = threading.Thread(target=self._run_server) - self.server_thread.daemon = True - self.server_thread.start() - - logger.info(f"SRS HTTP addon started on port {self.port}") - return True - except Exception as e: - logger.error(f"Failed to start HTTP addon: {e}") - return False - - def stop(self): - """Stop the HTTP server.""" - if self.server and self.running: - self.running = False - self.server.shutdown() - self.server.server_close() - if self.server_thread: - self.server_thread.join(timeout=5) - logger.info("SRS HTTP addon stopped") - - def _run_server(self): - """Run the HTTP server.""" - try: - self.server.serve_forever() - except Exception as e: - if self.running: - logger.error(f"HTTP server error: {e}") - -def signal_handler(signum, frame): - """Handle termination signals.""" - logger.info(f"Received signal {signum}, shutting down...") - if 'addon' in globals(): - addon.stop() - sys.exit(0) - -def main(): - """Main function.""" - parser = argparse.ArgumentParser(description='SRS HTTP Addon') - parser.add_argument('--port', type=int, default=8888, help='HTTP server port') - parser.add_argument('--verbose', action='store_true', help='Enable verbose logging') - - args = parser.parse_args() - - if args.verbose: - logger.setLevel(logging.DEBUG) - - # Set up signal handlers - signal.signal(signal.SIGTERM, signal_handler) - signal.signal(signal.SIGINT, signal_handler) - - # Create and start the addon - global addon - addon = SRSHTTPAddon(args.port) - - if addon.start(): - logger.info("SRS HTTP addon is running, press Ctrl+C to stop") - try: - while addon.running: - time.sleep(1) - except KeyboardInterrupt: - logger.info("Interrupted by user") - finally: - addon.stop() - else: - logger.error("Failed to start SRS HTTP addon") - sys.exit(1) - -if __name__ == '__main__': - main() \ No newline at end of file diff --git a/trunk/python/httpbackend_server.py b/trunk/python/httpbackend_server.py new file mode 100644 index 000000000..27849e318 --- /dev/null +++ b/trunk/python/httpbackend_server.py @@ -0,0 +1,845 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +FastAPI Authentication API Server +Provides secure authentication endpoints with token-based authentication +""" +from datetime import datetime +from typing import Optional, Dict, Any, List +from contextlib import asynccontextmanager + +from fastapi import FastAPI, HTTPException, Depends, Cookie, Response, Query +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel, EmailStr, Field, field_validator +import uvicorn + +# Import our database and logger +from database import get_database, Database +from srs_logger import get_logger + +# Import Function Class +from security_module import AuthManager +from system_stats_module import SystemStatsManager +from avatar_module import AvatarGenerator + +# ============================================================================ +# Pydantic Models for Request/Response +# ============================================================================ + +class RegisterRequest(BaseModel): + """用户注册请求模型""" + username: str = Field(..., min_length=1, max_length=50, + description="用户名,只能包含字母、数字、下划线和连字符") + name: str = Field(..., min_length=1, max_length=100, description="用户真实姓名") + email: Optional[EmailStr] = Field(None, description="用户邮箱(可选)") + password: str = Field(..., min_length=6, max_length=128, description="用户密码") + + @field_validator('username') + def validate_username(cls, v): + if not v.replace('_', '').replace('-', '').isalnum(): + raise ValueError('用户名只能包含字母、数字、下划线和连字符') + return v + +class LoginRequest(BaseModel): + """用户登录请求模型""" + username_or_email: str = Field(..., min_length=1, description="用户名或邮箱") + password: str = Field(..., min_length=1, description="用户密码") + remember_me: Optional[bool] = Field(True, description="是否记住登录状态(默认为False)") + +class RefreshRequest(BaseModel): + """令牌刷新请求模型""" + auth_key_session_id: Optional[str] = Field(None, description="认证令牌会话ID(可选,用于删除旧的认证会话)") + +class UpdatePasswordRequest(BaseModel): + """更新密码请求模型""" + original_password: str = Field(..., min_length=1, description="原密码") + password: str = Field(..., min_length=6, max_length=128, description="新密码") + +class UserIDRequest(BaseModel): + """用户ID请求模型""" + user_id: str = Field(..., min_length=1, description="传入的用户ID") + +class AuthResponse(BaseModel): + """认证响应模型""" + success: bool = Field(..., description="操作是否成功") + message: str = Field(..., description="响应消息") + auth_key: Optional[str] = Field(None, description="认证令牌") + auth_key_session_id: Optional[str] = Field(None, description="认证令牌会话ID") + refresh_key: Optional[str] = Field(None, description="刷新令牌") + refresh_key_session_id: Optional[str] = Field(None, description="刷新令牌会话ID") + +class SimpleResponse(BaseModel): + """简单响应模型""" + success: bool = Field(..., description="操作是否成功") + message: str = Field(..., description="响应消息") + +class SystemStatsResponse(BaseModel): + """系统统计响应模型""" + system_stats: List[Dict[str, Any]] = Field(..., description="系统统计信息列表,每个元素包含时间戳和统计数据") + +# ============================================================================ +# FastAPI Application Setup +# ============================================================================ + +@asynccontextmanager +async def lifespan(app: FastAPI): + """应用程序生命周期管理""" + logger = get_logger() + logger.info("Starting FastAPI Authentication Server...") + + # 初始化数据库 + db = get_database() + logger.info("Database initialized") + + # 创建认证管理器 + auth_manager = AuthManager(db) + system_stats_manager = SystemStatsManager(db) + avatar_generator = AvatarGenerator() + + app.state.auth_manager = auth_manager + logger.info("Auth manager initialized") + system_stats_manager.start_polling() # 启动系统统计轮询 + app.state.system_stats_manager = system_stats_manager + logger.info("System stats manager initialized") + app.state.avatar_generator = avatar_generator + logger.info("Avatar generator initialized") + app.state.db = db + logger.info("Database connection established") + + yield + + logger.info("Shutting down FastAPI Authentication Server...") + +# 创建FastAPI应用 +app = FastAPI( + title="认证API服务器", + description="基于FastAPI的安全认证系统,提供用户注册、登录、令牌刷新和登出功能", + version="1.0.0", + docs_url="/api/docs", + redoc_url="/api/redoc", + lifespan=lifespan +) + +# 添加CORS中间件 +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # 在生产环境中应该限制允许的源 + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# 获取认证管理器 +def get_auth_manager() -> AuthManager: + return app.state.auth_manager + +def get_system_stats_manager() -> SystemStatsManager: + return app.state.system_stats_manager + +def get_avatar_generator() -> AvatarGenerator: + return app.state.avatar_generator + +def get_db() -> Database: + return app.state.db + +# HTTP Bearer认证方案 +security = HTTPBearer() + +# 认证依赖项 +async def verify_auth_token( + credentials: HTTPAuthorizationCredentials = Depends(security), + auth_manager: AuthManager = Depends(get_auth_manager) +) -> Dict[str, Any]: + """验证Bearer令牌并返回认证会话信息""" + try: + # Bearer token格式: "auth_key:auth_key_session_id" + token_parts = credentials.credentials.split(':') + if len(token_parts) != 2: + raise HTTPException( + status_code=401, + detail="无效的Bearer令牌格式,应为 'auth_key:auth_key_session_id'" + ) + + auth_key, auth_key_session_id = token_parts + + # 获取认证会话 + auth_manager.db.cleanup_expired_sessions() # 清理过期会话 + auth_session = auth_manager.db.get_auth_session(auth_key_session_id) + if not auth_session: + raise HTTPException( + status_code=401, + detail="认证会话无效或已过期" + ) + + # 验证认证令牌 + if not auth_manager.verify_token(auth_key, auth_session['salt'], auth_session['hashed_authkey']): + raise HTTPException( + status_code=401, + detail="认证令牌无效" + ) + + # 获取用户信息以获取用户组 + user_data = auth_manager.db.get_user_by_id(auth_session['user_id']) + auth_session['user_group'] = user_data.get('user_group', ['user']) + + # 更新用户最后活跃时间 + auth_manager.db.update_user_last_active(auth_session['user_id']) + + return auth_session + + except HTTPException: + raise + except Exception as e: + auth_manager.logger.exception(f"Token verification error: {e}") + raise HTTPException( + status_code=401, + detail="令牌验证失败" + ) + +# ============================================================================ +# Cookie Configuration +# ============================================================================ + +def set_auth_cookies(response: Response, tokens: Dict[str, str]) -> None: + """设置安全的认证cookie""" + response.set_cookie( + key="refresh_key", + value=tokens['refresh_key'], + httponly=True, + secure=False, + samesite="strict", + max_age=604800 # 7天 + ) + + response.set_cookie( + key="refresh_key_session_id", + value=tokens['refresh_key_session_id'], + httponly=True, + secure=False, + samesite="strict", + max_age=604800 # 7天 + ) + +def clear_auth_cookies(response: Response) -> None: + """清除认证cookie""" + response.delete_cookie(key="refresh_key", httponly=True, secure=True, samesite="strict") + response.delete_cookie(key="refresh_key_session_id", httponly=True, secure=True, samesite="strict") + +# ============================================================================ +# Authentication API Endpoints +# ============================================================================ + +@app.post("/api/register", response_model=SimpleResponse, + tags=["Authentication API Endpoints"], summary="用户注册", description="注册新用户账户") +async def register(request: RegisterRequest, auth_manager: AuthManager = Depends(get_auth_manager)): + """ + 用户注册端点 + + - **username**: 用户名(必需,只能包含字母、数字、下划线和连字符) + - **name**: 用户真实姓名(必需) + - **email**: 用户邮箱(可选) + - **password**: 用户密码(必需,最少6个字符) + + 返回注册是否成功的布尔值 + """ + try: + success = auth_manager.register_user( + username=request.username, + name=request.name, + email=request.email, + password=request.password + ) + + if success: + return SimpleResponse(success=True, message="用户注册成功") + else: + raise HTTPException( + status_code=400, + detail="用户注册失败,用户名或邮箱可能已存在" + ) + + except HTTPException: + raise + except Exception as e: + auth_manager.logger.exception(f"Register endpoint error: {e}") + raise HTTPException(status_code=500, detail="服务器内部错误") + + +@app.post("/api/login", response_model=AuthResponse, + tags=["Authentication API Endpoints"], summary="用户登录", description="用户登录并获取认证令牌") +async def login(request: LoginRequest, response: Response, + auth_manager: AuthManager = Depends(get_auth_manager)): + """ + 用户登录端点 + + - **username_or_email**: 用户名或邮箱(必需) + - **password**: 用户密码(必需) + + 成功登录后返回认证令牌和刷新令牌,并设置安全cookie + """ + try: + # 认证用户 + user_data = auth_manager.authenticate_user( + username_or_email=request.username_or_email, + password=request.password + ) + + if not user_data: + raise HTTPException( + status_code=401, + detail="用户名/邮箱或密码错误" + ) + + # 创建认证令牌 + tokens = auth_manager.create_auth_tokens(user_data['user_id']) + if not tokens: + raise HTTPException( + status_code=500, + detail="创建认证令牌失败" + ) + + # 设置安全cookie + if request.remember_me: + set_auth_cookies(response, tokens) + + return AuthResponse( + success=True, + message="登录成功", + auth_key=tokens['auth_key'], + auth_key_session_id=tokens['auth_key_session_id'], + refresh_key=tokens['refresh_key'], + refresh_key_session_id=tokens['refresh_key_session_id'] + ) + + except HTTPException: + raise + except Exception as e: + auth_manager.logger.exception(f"Login endpoint error: {e}") + raise HTTPException(status_code=500, detail="服务器内部错误") + + +@app.post("/api/refresh", response_model=AuthResponse, + tags=["Authentication API Endpoints"], summary="刷新令牌", description="使用刷新令牌获取新的认证令牌") +async def refresh_tokens(request: RefreshRequest, response: Response, + refresh_key: Optional[str] = Cookie(None), + refresh_key_session_id: Optional[str] = Cookie(None), + auth_manager: AuthManager = Depends(get_auth_manager)): + """ + 令牌刷新端点 + + - **auth_key_session_id**: 认证令牌会话ID(可选,用于删除旧的认证会话) + + refresh_key和refresh_key_session_id从httponly Cookie中自动获取 + 验证刷新令牌后返回新的认证令牌和刷新令牌,并更新cookie + """ + try: + # 检查Cookie中的刷新令牌信息 + if not refresh_key or not refresh_key_session_id: + raise HTTPException( + status_code=401, + detail="未找到刷新令牌,请重新登录" + ) + + # 刷新令牌 + new_tokens = auth_manager.refresh_tokens( + refresh_key_session_id=refresh_key_session_id, + refresh_key=refresh_key, + auth_key_session_id=request.auth_key_session_id + ) + + if not new_tokens: + # 清除cookie + clear_auth_cookies(response) + raise HTTPException( + status_code=401, + detail="刷新令牌无效或已过期" + ) + + # 设置新的安全cookie + set_auth_cookies(response, new_tokens) + + return AuthResponse( + success=True, + message="令牌刷新成功", + auth_key=new_tokens['auth_key'], + auth_key_session_id=new_tokens['auth_key_session_id'], + refresh_key=new_tokens['refresh_key'], + refresh_key_session_id=new_tokens['refresh_key_session_id'] + ) + + except HTTPException: + raise + except Exception as e: + auth_manager.logger.exception(f"Refresh endpoint error: {e}") + raise HTTPException(status_code=500, detail="服务器内部错误") + + +@app.post("/api/logout", response_model=SimpleResponse, + tags=["Authentication API Endpoints"], summary="登出", description="登出当前会话") +async def logout(response: Response, + refresh_key_session_id: Optional[str] = Cookie(None), + auth_session: Dict[str, Any] = Depends(verify_auth_token), + auth_manager: AuthManager = Depends(get_auth_manager)): + """ + 登出端点 + + 需要在Authorization头中提供Bearer令牌,格式为: Bearer auth_key:auth_key_session_id + refresh_key_session_id从httponly Cookie中自动获取 + + 登出指定的会话并清除相关cookie + """ + try: + # 检查Cookie中的刷新令牌会话ID + if not refresh_key_session_id: + # 清除cookie(如果有的话) + clear_auth_cookies(response) + raise HTTPException( + status_code=401, + detail="未找到刷新令牌会话ID" + ) + + success = auth_manager.logout_session( + refresh_key_session_id=refresh_key_session_id, + auth_key_session_id=auth_session['session_id'] + ) + + # 清除cookie + clear_auth_cookies(response) + + if success: + return SimpleResponse(success=True, message="登出成功") + else: + return SimpleResponse(success=False, message="会话不存在或已过期") + + except Exception as e: + auth_manager.logger.exception(f"Logout endpoint error: {e}") + raise HTTPException(status_code=500, detail="服务器内部错误") + + +@app.post("/api/logout-all", response_model=SimpleResponse, + tags=["Authentication API Endpoints"], summary="登出所有会话", description="登出用户的所有会话") +async def logout_all(response: Response, + auth_session: Dict[str, Any] = Depends(verify_auth_token), + auth_manager: AuthManager = Depends(get_auth_manager)): + """ + 登出所有会话端点 + + 需要在Authorization头中提供Bearer令牌,格式为: Bearer auth_key:auth_key_session_id + + 登出用户的所有会话并清除相关cookie + """ + try: + success = auth_manager.logout_all_sessions(auth_session['user_id']) + + # 清除cookie + clear_auth_cookies(response) + + if success: + return SimpleResponse(success=True, message="所有会话已登出") + else: + return SimpleResponse(success=False, message="会话不存在或已过期") + + except Exception as e: + auth_manager.logger.exception(f"Logout all endpoint error: {e}") + raise HTTPException(status_code=500, detail="服务器内部错误") + + +@app.get("/api/profile", response_model=Dict[str, Any], + tags=["Authentication API Endpoints"], summary="获取用户信息", description="获取当前认证用户的基本信息") +async def get_profile( + auth_session: Dict[str, Any] = Depends(verify_auth_token), + auth_manager: AuthManager = Depends(get_auth_manager) +): + """ + 获取用户信息端点 + + 需要在Authorization头中提供Bearer令牌,格式为: Bearer auth_key:auth_key_session_id + + 返回当前认证用户的基本信息 + """ + try: + user_data = auth_manager.db.get_user_by_id(auth_session['user_id']) + if not user_data: + raise HTTPException(status_code=404, detail="用户未找到") + + return { + "user_id": user_data['user_id'], + "username": user_data['username'], + "name": user_data['name'], + "email": user_data.get('email', None), + 'user_group': user_data.get('user_group', ['user']), + "is_activated": user_data.get('is_activated', False), + "last_active": user_data.get('last_active', None) + } + + except HTTPException: + raise + except Exception as e: + auth_manager.logger.exception(f"Get profile endpoint error: {e}") + raise HTTPException(status_code=500, detail="服务器内部错误") + + +@app.put("/api/update-password", response_model=SimpleResponse, + tags=["Authentication API Endpoints"], summary="更新密码", description="更新当前用户的密码") +async def update_password( + request: UpdatePasswordRequest, + response: Response, + auth_session: Dict[str, Any] = Depends(verify_auth_token), + auth_manager: AuthManager = Depends(get_auth_manager) +): + """ + 更新密码端点 + + 需要在Authorization头中提供Bearer令牌,格式为: Bearer auth_key:auth_key_session_id + + - **original_password**: 原密码(必需) + - **password**: 新密码(必需,最少6个字符) + + 验证原密码后更新为新密码,并自动登出所有会话 + """ + try: + user_id = auth_session['user_id'] + + # 调用AuthManager处理密码更新逻辑 + result = auth_manager.update_user_password_with_verification( + user_id=user_id, + original_password=request.original_password, + new_password=request.password + ) + + if result["success"]: + # 如果密码更新成功且包含logout_all标志,清除cookie + if result.get("logout_all", False): + clear_auth_cookies(response) + + return SimpleResponse(success=True, message=result["message"]) + else: + # 根据错误类型返回相应的HTTP状态码 + if "用户未找到" in result["message"]: + raise HTTPException(status_code=404, detail=result["message"]) + elif "原密码错误" in result["message"]: + raise HTTPException(status_code=401, detail=result["message"]) + else: + raise HTTPException(status_code=500, detail=result["message"]) + + except HTTPException: + raise + except Exception as e: + auth_manager.logger.exception(f"Update password endpoint error: {e}") + raise HTTPException(status_code=500, detail="服务器内部错误") + +@app.delete("/api/delete-user", response_model=SimpleResponse, + tags=["Authentication API Endpoints"], summary="删除自己的账户", description="删除当前登录用户的账户") +async def delete_own_account( + response: Response, + auth_session: Dict[str, Any] = Depends(verify_auth_token), + auth_manager: AuthManager = Depends(get_auth_manager) +): + """ + 删除自己账户端点 + + 需要在Authorization头中提供Bearer令牌,格式为: Bearer auth_key:auth_key_session_id + 只能删除当前登录用户本人账户,删除成功后将清除所有认证cookie + """ + try: + user_id = auth_session['user_id'] + # 调用AuthManager处理自己账户删除逻辑 + result = auth_manager.delete_own_account(user_id) + + if result["success"]: + # 删除成功后清除认证cookie + clear_auth_cookies(response) + return SimpleResponse(success=True, message=result["message"]) + else: + # 根据错误类型返回相应的HTTP状态码 + if "未找到" in result["message"]: + raise HTTPException(status_code=404, detail=result["message"]) + else: + raise HTTPException(status_code=500, detail=result["message"]) + + except HTTPException: + raise + except Exception as e: + auth_manager.logger.exception(f"Delete own account endpoint error: {e}") + raise HTTPException(status_code=500, detail="服务器内部错误") + + +@app.delete("/api/admin/delete-user", response_model=SimpleResponse, + tags=["Admin API Endpoints"], summary="管理员删除用户", description="管理员删除指定用户账户") +async def admin_delete_user( + request: UserIDRequest, + auth_session: Dict[str, Any] = Depends(verify_auth_token), + auth_manager: AuthManager = Depends(get_auth_manager) +): + """ + 管理员删除用户端点 + + 需要在Authorization头中提供Bearer令牌,格式为: Bearer auth_key:auth_key_session_id + 需要管理员权限(manager或admin用户组) + - **user_id**: 要删除的用户ID(必需) + """ + try: + admin_user_id = auth_session['user_id'] + target_user_id = request.user_id + + # 调用AuthManager处理管理员删除用户逻辑 + result = auth_manager.admin_delete_user( + admin_user_id=admin_user_id, + admin_user_groups=auth_session.get('user_group', ["user"]), + target_user_id=target_user_id + ) + + if result["success"]: + return SimpleResponse(success=True, message=result["message"]) + else: + # 根据错误类型返回相应的HTTP状态码 + if "未找到" in result["message"]: + raise HTTPException(status_code=404, detail=result["message"]) + elif "权限不足" in result["message"]: + raise HTTPException(status_code=403, detail=result["message"]) + else: + raise HTTPException(status_code=400, detail=result["message"]) + + except HTTPException: + raise + except Exception as e: + auth_manager.logger.exception(f"Admin delete user endpoint error: {e}") + raise HTTPException(status_code=500, detail="服务器内部错误") + +# ============================================================================ +# Avatar Generation Endpoint +# ============================================================================ + +@app.get("/avatar/{variant}", + tags=["Avatar API Endpoints"], summary="生成头像", description="根据变体生成头像") +async def generate_avatar_variant_only( + variant: str, + auth_session: Dict[str, Any] = Depends(verify_auth_token), + name: Optional[str] = Query(default="John Doe", description="Name to generate avatar for"), + colors: Optional[str] = Query(default=None, description="Comma-separated hex colors"), + size: int = Query(default=80, description="Avatar size"), + square: bool = Query(default=False, description="Square avatar"), + title: bool = Query(default=False, description="Include title element"), + + avatar_generator: AvatarGenerator = Depends(get_avatar_generator), +): + """Generate avatar with variant only""" + try: + if variant not in avatar_generator.VALID_VARIANTS: + raise HTTPException(status_code=400, detail=f"Invalid variant. Must be one of: {', '.join(avatar_generator.VALID_VARIANTS)}") + + # Validate and sanitize inputs + if not name or not isinstance(name, str): + name = auth_session['user_id'] + + if not isinstance(size, int) or size < 10 or size > 1000: + size = avatar_generator.DEFAULT_SIZE + + color_palette = avatar_generator.normalize_colors(colors) + generator = avatar_generator.AVATAR_GENERATORS[variant] + svg_content = generator(name, color_palette, size, square, title) + + return Response( + content=svg_content, + media_type="image/svg+xml", + headers={ + "Cache-Control": "s-max-age=1, stale-while-revalidate", + "Content-Type": "image/svg+xml" + } + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Avatar generation failed: {str(e)}") + +@app.get("/avatar/{variant}/{size}", + tags=["Avatar API Endpoints"], summary="生成头像", description="根据变体生成头像") +async def generate_avatar_variant_size( + variant: str, + size: int, + auth_session: Dict[str, Any] = Depends(verify_auth_token), + name: Optional[str] = Query(default="Clara Barton", description="Name to generate avatar for"), + colors: Optional[str] = Query(default=None, description="Comma-separated hex colors"), + square: bool = Query(default=False, description="Square avatar"), + title: bool = Query(default=False, description="Include title element"), + avatar_generator: AvatarGenerator = Depends(get_avatar_generator) +): + """Generate avatar with variant and size""" + try: + if variant not in avatar_generator.VALID_VARIANTS: + raise HTTPException(status_code=400, detail=f"Invalid variant. Must be one of: {', '.join(avatar_generator.VALID_VARIANTS)}") + + # Validate and sanitize inputs + if not name or not isinstance(name, str): + name = auth_session['user_id'] + + if not isinstance(size, int) or size < 10 or size > 1000: + size = avatar_generator.DEFAULT_SIZE + + color_palette = avatar_generator.normalize_colors(colors) + generator = avatar_generator.AVATAR_GENERATORS[variant] + svg_content = generator(name, color_palette, size, square, title) + + return Response( + content=svg_content, + media_type="image/svg+xml", + headers={ + "Cache-Control": "s-max-age=1, stale-while-revalidate", + "Content-Type": "image/svg+xml" + } + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Avatar generation failed: {str(e)}") + +@app.get("/avatar/{variant}/{size}/{name}", + tags=["Avatar API Endpoints"], summary="生成头像", description="根据变体生成头像") +async def generate_avatar_full_path( + variant: str, + size: int, + name: str, + colors: Optional[str] = Query(default=None, description="Comma-separated hex colors"), + square: bool = Query(default=False, description="Square avatar"), + title: bool = Query(default=False, description="Include title element"), + avatar_generator: AvatarGenerator = Depends(get_avatar_generator), + + auth_session: Dict[str, Any] = Depends(verify_auth_token), +): + """Generate avatar with variant, size, and name in path""" + try: + if variant not in avatar_generator.VALID_VARIANTS: + raise HTTPException(status_code=400, detail=f"Invalid variant. Must be one of: {', '.join(avatar_generator.VALID_VARIANTS)}") + + # Validate and sanitize inputs + if not name or not isinstance(name, str): + name = auth_session['user_id'] + + if not isinstance(size, int) or size < 10 or size > 1000: + size = avatar_generator.DEFAULT_SIZE + + color_palette = avatar_generator.normalize_colors(colors) + generator = avatar_generator.AVATAR_GENERATORS[variant] + svg_content = generator(name, color_palette, size, square, title) + + return Response( + content=svg_content, + media_type="image/svg+xml", + headers={ + "Cache-Control": "s-max-age=1, stale-while-revalidate", + "Content-Type": "image/svg+xml" + } + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Avatar generation failed: {str(e)}") + +# ============================================================================ +# System Statistics API Endpoints +# ============================================================================ +@app.get("/api/system-stats", response_model=SystemStatsResponse, + tags=["System Statistics API Endpoints"], summary="获取系统统计信息", description="获取服务器的系统统计信息") +async def get_system_stats( + auth_session: Dict[str, Any] = Depends(verify_auth_token), + time_delta: Optional[int] = Query(default=5, description="时间范围(秒),默认为5秒"), + time_interval: Optional[int] = Query(default=1, description="时间间隔(秒),默认为1秒"), + system_stats_manager: SystemStatsManager = Depends(get_system_stats_manager) +): + """ + 获取系统统计信息端点 + + 需要在Authorization头中提供Bearer令牌,格式为: Bearer auth_key:auth_key_session_id + - **time_delta**: 时间范围(秒),默认为5秒 + 返回服务器的系统统计信息 + """ + try: + stats = system_stats_manager.get_recent_stats( + auth_session.get('user_group', ['user']), + time_delta=time_delta, + time_interval=time_interval + ) + if stats["success"]: + return stats + else: + if "未找到" in stats["message"]: + raise HTTPException(status_code=404, detail=stats["message"]) + elif "权限不足" in stats["message"]: + raise HTTPException(status_code=403, detail=stats["message"]) + else: + raise HTTPException(status_code=500, detail=stats["message"]) + + except HTTPException: + raise + except Exception as e: + system_stats_manager.logger.exception(f"Get system stats endpoint error: {e}") + raise HTTPException(status_code=500, detail="服务器内部错误") + +# ============================================================================ +# Additional Utility API Endpoints +# ============================================================================ + +@app.get("/api/health", response_model=Dict[str, Any], + tags=["Additional Utility API Endpoints"], summary="健康检查", description="检查API服务器状态") +async def health_check(db: Database = Depends(get_db)): + """健康检查端点""" + try: + stats = db.get_database_stats() + return { + "status": "healthy", + "timestamp": datetime.now().isoformat(), + "database_stats": stats + } + except Exception as e: + raise HTTPException(status_code=503, detail=f"服务不可用: {str(e)}") + +@app.post("/api/clean-cookies", response_model=SimpleResponse, + tags=["Additional Utility API Endpoints"], summary="清除认证Cookie", description="清除认证相关的Cookie") +async def clean_cookies(response: Response): + """清除认证Cookie端点""" + try: + clear_auth_cookies(response) + return SimpleResponse(success=True, message="认证Cookie已清除") + except Exception as e: + raise HTTPException(status_code=500, detail=f"清除Cookie失败: {str(e)}") + +# ============================================================================ +# Static Files Configuration +# ============================================================================ +# 在所有API路由定义之后挂载静态文件,确保API路由有更高优先级 + +# Frontend路径配置 +frontend_path = r"./livestream_site" # 前端静态文件目录 +app.mount("/", StaticFiles(directory=frontend_path, html=True), name="frontend") + +# ============================================================================ +# Main Entry Point +# ============================================================================ + +if __name__ == "__main__": + logger = get_logger() + logger.info("Starting FastAPI Authentication Server on port 8000...") + + # 检查是否在PyInstaller打包环境中运行 + import sys + if getattr(sys, 'frozen', False): + # 在打包环境中,直接传递app对象 + uvicorn.run( + app, + host="127.0.0.1", + port=8000, + reload=False, + log_level="info" + ) + else: + # 在开发环境中,使用字符串导入(支持热重载) + uvicorn.run( + "httpbackend_server:app", + host="127.0.0.1", + port=8000, + reload=True, + reload_delay=5.0, + log_level="info" + ) \ No newline at end of file diff --git a/trunk/python/monitor.py b/trunk/python/monitor.py deleted file mode 100644 index e718f53ff..000000000 --- a/trunk/python/monitor.py +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env python3 -""" -SRS Python Monitor Script -This is an example Python script that runs alongside SRS server. -It demonstrates how Python processes can be managed by SRS. -""" - -import time -import signal -import sys -import argparse -import logging -from datetime import datetime - -class SRSMonitor: - def __init__(self, config_file=None, verbose=False): - self.running = True - self.config_file = config_file - self.verbose = verbose - - # Set up logging - level = logging.DEBUG if verbose else logging.INFO - logging.basicConfig( - level=level, - format='[%(asctime)s] [Python Monitor] %(levelname)s: %(message)s', - datefmt='%Y-%m-%d %H:%M:%S' - ) - self.logger = logging.getLogger(__name__) - - # Set up signal handlers - signal.signal(signal.SIGTERM, self.signal_handler) - signal.signal(signal.SIGINT, self.signal_handler) - - def signal_handler(self, signum, frame): - """Handle shutdown signals from SRS""" - self.logger.info(f"Received signal {signum}, shutting down gracefully...") - self.running = False - - def run(self): - """Main monitoring loop""" - self.logger.info("SRS Python Monitor started") - if self.config_file: - self.logger.info(f"Using config file: {self.config_file}") - - try: - while self.running: - # Simulate monitoring work - self.logger.debug(f"Monitor heartbeat at {datetime.now()}") - - # You can add your monitoring logic here: - # - Check stream status - # - Monitor server health - # - Send alerts - # - Log analytics - - time.sleep(5) # Check every 5 seconds - - except Exception as e: - self.logger.error(f"Error in monitor loop: {e}") - finally: - self.cleanup() - - def cleanup(self): - """Cleanup before shutdown""" - self.logger.info("Cleaning up Python Monitor...") - # Add any cleanup logic here - self.logger.info("Python Monitor stopped") - -def main(): - parser = argparse.ArgumentParser(description='SRS Python Monitor') - parser.add_argument('--config', help='Configuration file path') - parser.add_argument('--verbose', action='store_true', help='Enable verbose logging') - - args = parser.parse_args() - - monitor = SRSMonitor(args.config, args.verbose) - monitor.run() - -if __name__ == '__main__': - main() diff --git a/trunk/python/requirements.txt b/trunk/python/requirements.txt index 2438804f1..4bf721606 100644 --- a/trunk/python/requirements.txt +++ b/trunk/python/requirements.txt @@ -1,7 +1,70 @@ -# Python packages required for SRS Python addons -requests>=2.25.0 -psutil>=5.8.0 -pyyaml>=5.4.0 -websockets>=10.0 -aiohttp>=3.8.0 -numpy>=1.20.0 +aiofiles==23.2.0 +aiohappyeyeballs==2.6.1 +aiohttp==3.12.13 +aiosignal==1.3.2 +altgraph==0.17.4 +annotated-types==0.7.0 +anyio==3.7.1 +attrs==25.3.0 +bcrypt==4.3.0 +blinker==1.9.0 +certifi==2025.4.26 +cffi==1.17.1 +charset-normalizer==3.4.2 +click==8.2.1 +colorama==0.4.6 +cryptography==45.0.3 +dnspython==2.7.0 +ecdsa==0.19.1 +email-validator==2.1.0 +fastapi==0.104.1 +Flask==3.1.1 +Flask-SQLAlchemy==3.1.1 +frozenlist==1.7.0 +greenlet==3.2.3 +h11==0.16.0 +httpcore==1.0.9 +httptools==0.6.4 +httpx==0.25.2 +idna==3.10 +iniconfig==2.1.0 +itsdangerous==2.2.0 +Jinja2==3.1.2 +jwt==1.3.1 +MarkupSafe==3.0.2 +multidict==6.4.4 +packaging==25.0 +passlib==1.7.4 +pefile==2023.2.7 +pluggy==1.6.0 +propcache==0.3.2 +pyasn1==0.6.1 +pycparser==2.22 +pydantic==2.5.0 +pydantic_core==2.14.1 +pyinstaller==6.14.1 +pyinstaller-hooks-contrib==2025.5 +PyJWT==2.8.0 +pytest==7.4.3 +pytest-asyncio==0.21.1 +python-dotenv==1.0.0 +python-jose==3.3.0 +python-multipart==0.0.6 +pywin32-ctypes==0.2.3 +PyYAML==6.0.2 +requests==2.32.4 +rsa==4.9.1 +setuptools==78.1.1 +six==1.17.0 +sniffio==1.3.1 +SQLAlchemy==2.0.41 +starlette==0.27.0 +typing-inspection==0.4.1 +typing_extensions==4.14.0 +urllib3==2.4.0 +uvicorn==0.24.0 +watchfiles==1.0.5 +websockets==15.0.1 +Werkzeug==3.1.3 +wheel==0.45.1 +yarl==1.20.1 diff --git a/trunk/python/security_module.py b/trunk/python/security_module.py new file mode 100644 index 000000000..7f5005d6a --- /dev/null +++ b/trunk/python/security_module.py @@ -0,0 +1,317 @@ +import hashlib +import secrets +import hmac +from datetime import datetime, timedelta +from typing import Optional, Dict, Any + +from database import Database +from srs_logger import get_logger + +# ============================================================================ +# Security and Utility Functions +# ============================================================================ + +class AuthManager: + """认证管理器""" + + def __init__(self, db: Database): + self.db = db + self.logger = get_logger() + + # Token 配置 + self.AUTH_TOKEN_EXPIRE_HOURS = 1 # 认证令牌1小时过期 + self.REFRESH_TOKEN_EXPIRE_DAYS = 7 # 刷新令牌7天过期 + self.TOKEN_LENGTH = 128 # 令牌长度 + + def generate_secure_token(self) -> str: + """生成安全的随机令牌""" + return secrets.token_urlsafe(self.TOKEN_LENGTH) + + def generate_salt(self) -> str: + """生成盐值""" + return secrets.token_hex(16) + + def hash_password(self, password: str, salt: str) -> str: + """对密码进行加盐哈希""" + return hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000).hex() + + def verify_password(self, password: str, salt: str, hashed_password: str) -> bool: + """验证密码""" + return hmac.compare_digest( + self.hash_password(password, salt), + hashed_password + ) + + def hash_token(self, token: str, salt: str) -> str: + """对令牌进行加盐哈希""" + return hashlib.pbkdf2_hmac('sha256', token.encode(), salt.encode(), 100000).hex() + + def verify_token(self, token: str, salt: str, hashed_token: str) -> bool: + """验证令牌""" + return hmac.compare_digest( + self.hash_token(token, salt), + hashed_token + ) + + def register_user(self, username: str, name: str, email: Optional[str], password: str) -> bool: + """注册新用户""" + try: + # 生成密码盐值和哈希 + salt = self.generate_salt() + hashed_password = self.hash_password(password, salt) + + # 创建用户 + user_data = self.db.create_user( + username=username, + name=name, + email=email, + hashed_password=hashed_password, + salt=salt, + user_group=["user"], + is_activated=True + ) + + if user_data: + self.logger.info(f"User registered successfully: {username}") + return True + else: + self.logger.warn(f"Failed to register user: {username}") + return False + + except Exception as e: + self.logger.exception(f"Error during user registration: {e}") + return False + + def authenticate_user(self, username_or_email: str, password: str) -> Optional[Dict[str, Any]]: + """认证用户并返回用户数据""" + try: + # 获取用户信息 + user_data = self.db.get_user(username_or_email) + if not user_data: + self.logger.warn(f"User not found: {username_or_email}") + return None + + # 检查用户是否激活 + if not user_data.get('is_activated', False): + self.logger.warn(f"User account deactivated: {username_or_email}") + return None + + # 验证密码 + if self.verify_password(password, user_data['salt'], user_data['hashed_password']): + self.logger.info(f"User authenticated successfully: {username_or_email}") + return user_data + else: + self.logger.warn(f"Invalid password for user: {username_or_email}") + return None + + except Exception as e: + self.logger.exception(f"Error during user authentication: {e}") + return None + + def create_auth_tokens(self, user_id: str) -> Optional[Dict[str, Any]]: + """为用户创建认证和刷新令牌""" + try: + # 清理过期会话 + self.db.cleanup_expired_sessions() + + # 生成认证令牌 + auth_key = self.generate_secure_token() + auth_salt = self.generate_salt() + hashed_auth_key = self.hash_token(auth_key, auth_salt) + auth_expire_time = datetime.now() + timedelta(hours=self.AUTH_TOKEN_EXPIRE_HOURS) + + # 生成刷新令牌 + refresh_key = self.generate_secure_token() + refresh_salt = self.generate_salt() + hashed_refresh_key = self.hash_token(refresh_key, refresh_salt) + refresh_expire_time = datetime.now() + timedelta(days=self.REFRESH_TOKEN_EXPIRE_DAYS) + + # 创建认证会话 + auth_session = self.db.create_auth_session( + user_id=user_id, + hashed_authkey=hashed_auth_key, + salt=auth_salt, + expire_time=auth_expire_time + ) + + if not auth_session: + self.logger.error(f"Failed to create auth session for user: {user_id}") + return None + + # 创建刷新会话 + refresh_session = self.db.create_refresh_session( + user_id=user_id, + hashed_refreshkey=hashed_refresh_key, + salt=refresh_salt, + expire_time=refresh_expire_time + ) + + if not refresh_session: + self.logger.error(f"Failed to create refresh session for user: {user_id}") + # 清理已创建的认证会话 + self.db.delete_auth_session(auth_session['session_id']) + return None + + # 更新用户最后活跃时间 + self.db.update_user_last_active(user_id) + + return { + 'auth_key': auth_key, + 'auth_key_session_id': auth_session['session_id'], + 'refresh_key': refresh_key, + 'refresh_key_session_id': refresh_session['session_id'] + } + + except Exception as e: + self.logger.exception(f"Error creating auth tokens for user: {user_id}") + return None + + def refresh_tokens(self, refresh_key_session_id: str, refresh_key: str, + auth_key_session_id: Optional[str] = None) -> Optional[Dict[str, Any]]: + """刷新认证令牌""" + try: + # 清理过期会话 + self.db.cleanup_expired_sessions() + + # 验证刷新会话 + refresh_session = self.db.get_refresh_session(refresh_key_session_id) + if not refresh_session: + self.logger.warn(f"Invalid or expired refresh session: {refresh_key_session_id}") + return None + + # 验证刷新令牌 + if not self.verify_token(refresh_key, refresh_session['salt'], refresh_session['hashed_refreshkey']): + self.logger.warn(f"Invalid refresh token for session: {refresh_key_session_id}") + return None + + user_id = refresh_session['user_id'] + + # 删除旧的认证会话(如果提供了session_id) + if auth_key_session_id: + self.db.delete_auth_session(auth_key_session_id) + + # 删除旧的刷新会话 + self.db.delete_refresh_session(refresh_key_session_id) + + # 创建新的令牌 + new_tokens = self.create_auth_tokens(user_id) + if new_tokens: + self.logger.info(f"Tokens refreshed successfully for user: {user_id}") + return new_tokens + else: + self.logger.error(f"Failed to create new tokens during refresh for user: {user_id}") + return None + + except Exception as e: + self.logger.exception(f"Error during token refresh: {e}") + return None + + def logout_session(self, refresh_key_session_id: str, auth_key_session_id: str = None) -> bool: + """登出单个会话,通过删除指定的refresh session和对应的auth session""" + try: + # 删除指定的刷新会话 + refresh_success = self.db.delete_refresh_session(refresh_key_session_id) + if refresh_success: + self.logger.info(f"Refresh session logged out successfully: {refresh_key_session_id}") + + auth_success = self.db.delete_auth_session(auth_key_session_id) + if auth_success: + self.logger.info(f"Auth session logged out successfully: {auth_key_session_id}") + else: + self.logger.warn(f"Failed to delete auth session: {auth_key_session_id}") + + return refresh_success and auth_success + + except Exception as e: + self.logger.exception(f"Error during session logout: {e}") + return False + + def logout_all_sessions(self, user_id: str) -> bool: + """登出用户的所有会话,通过refresh session确定用户""" + try: + # 删除用户的所有会话 + success = self.db.delete_user_sessions(user_id) + if success: + self.logger.info(f"All sessions logged out successfully for user: {user_id}") + + return success + + except Exception as e: + self.logger.exception(f"Error during logout all sessions: {e}") + return False + + def update_user_password_with_verification(self, user_id: str, original_password: str, new_password: str) -> Dict[str, Any]: + """验证原密码后更新用户密码,并登出所有会话""" + try: + # 获取用户数据 + user_data = self.db.get_user_by_id(user_id) + if not user_data: + return {"success": False, "message": "用户未找到"} + + # 验证原密码 + if not self.verify_password(original_password, user_data['salt'], user_data['hashed_password']): + return {"success": False, "message": "原密码错误"} + + # 生成新的盐值和密码哈希 + new_salt = self.generate_salt() + new_hashed_password = self.hash_password(new_password, new_salt) + + # 更新密码 + updated_user = self.db.update_user_password(user_id, new_hashed_password, new_salt) + if not updated_user: + return {"success": False, "message": "密码更新失败"} + + # 密码更新成功后,登出用户的所有会话(强制重新登录) + logout_success = self.db.delete_user_sessions(user_id) + if logout_success: + self.logger.info(f"All sessions logged out after password update for user: {user_data['username']}") + else: + self.logger.warn(f"Failed to logout all sessions after password update for user: {user_data['username']}") + + self.logger.info(f"Password updated successfully for user: {user_data['username']}") + return {"success": True, "message": "密码更新成功,请重新登录", "logout_all": True} + + except Exception as e: + self.logger.exception(f"Error updating user password: {e}") + return {"success": False, "message": "服务器内部错误"} + + def delete_own_account(self, user_id: str) -> Dict[str, Any]: + """删除用户自己的账户""" + try: + success = self.db.delete_user(user_id) + if not success: + return {"success": False, "message": "账户删除失败"} + + self.logger.info(f"User-ID: '{user_id}' deleted his/her own account") + return {"success": True, "message": "您的账户已成功删除"} + + except Exception as e: + self.logger.exception(f"Error deleting own account: {e}") + return {"success": False, "message": "服务器内部错误"} + + def admin_delete_user(self, admin_user_id: str, admin_user_groups: list, target_user_id: str, ) -> Dict[str, Any]: + """管理员删除指定用户账户""" + try: + # 检查管理员权限 + if not any(group in admin_user_groups for group in ['manager', 'admin']): + return { + "success": False, + "message": "权限不足,只有管理员可以删除其他用户账户" + } + + # 获取目标用户数据 + target_user = self.db.get_user_by_id(target_user_id) + if not target_user: + return {"success": False, "message": "目标删除的用户未找到"} + + # 执行删除操作 + success = self.db.delete_user(target_user_id) + if not success: + return {"success": False, "message": "用户删除失败"} + + self.logger.info(f"User-ID '{target_user_id}' deleted by admin-ID: '{admin_user_id}'") + return {"success": True, "message": f"用户 '{target_user_id}' 已成功删除"} + + except Exception as e: + self.logger.exception(f"Error in admin delete user: {e}") + return {"success": False, "message": "服务器内部错误"} \ No newline at end of file diff --git a/trunk/python/srs_logger.py b/trunk/python/srs_logger.py new file mode 100644 index 000000000..0a780cbcb --- /dev/null +++ b/trunk/python/srs_logger.py @@ -0,0 +1,405 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +SRS Python Logger Module +Provides synchronized logging with SRS main process. +""" + +import logging +import os +import sys +import threading +import time +import re +from datetime import datetime +from typing import Optional, Dict, Any +import argparse + +class SRSLogFormatter(logging.Formatter): + """ + Custom formatter that matches SRS log format with color support: + [2025-05-30 21:54:18.835][WARN][3448][python_analytics] message + """ + + # ANSI color codes + COLORS = { + 'RESET': '\033[0m', + 'RED': '\033[31m', + 'YELLOW': '\033[33m', + 'WHITE': '\033[37m', + 'CYAN': '\033[36m', + 'GREEN': '\033[32m' + } + + def __init__(self, use_colors=True): + super().__init__() + self.pid = os.getpid() + self.session_id = self._generate_session_id() + self.use_colors = use_colors and self._supports_color() + + def _supports_color(self) -> bool: + """Check if the terminal supports color output""" + import sys + + # Check if we're in a terminal + if not hasattr(sys.stdout, 'isatty') or not sys.stdout.isatty(): + return False + + # Check for Windows terminal support + if os.name == 'nt': + try: + import colorama + colorama.init() + return True + except ImportError: + # Try to enable ANSI escape sequence support on Windows + try: + import ctypes + kernel32 = ctypes.windll.kernel32 + kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7) + return True + except: + return False + + # Unix-like systems usually support colors + return True + + def _generate_session_id(self) -> str: + """Generate a meaningful session ID based on the calling script""" + import inspect + + # Try to get the main script name + main_module = sys.modules.get('__main__') + if main_module and hasattr(main_module, '__file__'): + script_path = main_module.__file__ + if script_path: + script_name = os.path.splitext(os.path.basename(script_path))[0] + return f"python_{script_name}" + + # Fallback to inspect the call stack to find the calling script + try: + for frame_info in inspect.stack(): + filename = frame_info.filename + if filename and not filename.endswith('srs_logger.py') and not filename.endswith('logging/__init__.py'): + script_name = os.path.splitext(os.path.basename(filename))[0] + if script_name != '