diff --git a/dashboard/server.py b/dashboard/server.py index 7a4f2d9..11aea21 100644 --- a/dashboard/server.py +++ b/dashboard/server.py @@ -6,6 +6,11 @@ import requests import os import logging from typing import Optional, Dict, Any +from src.utils.validation import ( + validate_isin, validate_exchange, validate_date, + validate_int_range, sanitize_sql_string, + validate_isin_list, validate_exchange_list, +) logger = logging.getLogger(__name__) @@ -59,10 +64,19 @@ async def get_trades(isin: str = None, days: int = 7): Gibt aggregierte Analyse aller Trades zurück (nicht einzelne Trades). Nutzt vorberechnete Daten aus analytics_exchange_daily. """ + try: + days = validate_int_range(days, 1, 365) + except ValueError: + raise HTTPException(status_code=400, detail="Ungueltiger days-Parameter (1-365)") + if isin: + try: + isin = validate_isin(isin) + except ValueError: + raise HTTPException(status_code=400, detail="Ungueltiger ISIN-Wert") # Für spezifische ISIN: hole aus trades Tabelle query = f""" - select + select date_trunc('day', timestamp) as date, count(*) as trade_count, sum(price * quantity) as volume, @@ -109,6 +123,11 @@ async def get_summary(days: int = None): Gibt Zusammenfassung zurück. Nutzt analytics_daily_summary für total_trades. Optional: days Parameter für Zeitraum-basierte Zusammenfassung. """ + if days: + try: + days = validate_int_range(days, 1, 3650) + except ValueError: + raise HTTPException(status_code=400, detail="Ungueltiger days-Parameter (1-3650)") if days: # Zeitraum-basierte Zusammenfassung query = f""" @@ -169,6 +188,11 @@ async def get_summary(days: int = None): @app.get("/api/statistics/total-trades") async def get_total_trades(days: int = None): """Gibt Gesamtzahl aller Trades zurück (aus analytics_daily_summary). Optional: days Parameter für Zeitraum.""" + if days: + try: + days = validate_int_range(days, 1, 3650) + except ValueError: + raise HTTPException(status_code=400, detail="Ungueltiger days-Parameter (1-3650)") if days: query = f"select sum(total_trades) as total from analytics_daily_summary where timestamp >= dateadd('d', -{days}, now())" else: @@ -201,17 +225,30 @@ async def get_custom_analytics( - exchanges: Komma-separierte Liste von Exchanges (optional) """ # Validiere Parameter + try: + date_from = validate_date(date_from) + date_to = validate_date(date_to) + except ValueError: + raise HTTPException(status_code=400, detail="Ungueltiges Datumsformat (erwartet: YYYY-MM-DD)") + valid_x_axis = ["date", "exchange", "isin"] valid_y_axis = ["volume", "trade_count", "avg_price"] valid_group_by = ["exchange", "sector", "date"] - + if x_axis not in valid_x_axis: raise HTTPException(status_code=400, detail=f"Invalid x_axis. Must be one of: {valid_x_axis}") if y_axis not in valid_y_axis: raise HTTPException(status_code=400, detail=f"Invalid y_axis. Must be one of: {valid_y_axis}") if group_by not in valid_group_by: raise HTTPException(status_code=400, detail=f"Invalid group_by. Must be one of: {valid_group_by}") - + + validated_exchanges = None + if exchanges: + try: + validated_exchanges = validate_exchange_list(exchanges) + except ValueError: + raise HTTPException(status_code=400, detail="Ungueltiger Exchange-Name in der Liste") + # Für Sektor-Gruppierung: direkter JOIN mit metadata (nicht vorberechnet) if group_by == "sector": y_axis_map = { @@ -233,8 +270,8 @@ async def get_custom_analytics( and t.timestamp <= '{date_to}' """ - if exchanges: - exchange_list = ",".join([f"'{e.strip()}'" for e in exchanges.split(",")]) + if validated_exchanges: + exchange_list = ",".join([f"'{e}'" for e in validated_exchanges]) query += f" and t.exchange in ({exchange_list})" query += f" group by date_trunc('day', t.timestamp), coalesce(m.sector, 'Unbekannt') order by x_value asc, group_value asc" @@ -252,12 +289,9 @@ async def get_custom_analytics( # Nutze vorberechnete Daten aus analytics_custom exchange_filter = "all" - if exchanges: - # Wenn mehrere Exchanges angegeben, müssen wir kombinieren - # Für jetzt: nutze nur wenn ein Exchange angegeben ist - exchange_list = [e.strip() for e in exchanges.split(",")] - if len(exchange_list) == 1: - exchange_filter = exchange_list[0] + if validated_exchanges: + if len(validated_exchanges) == 1: + exchange_filter = validated_exchanges[0] else: # Bei mehreren Exchanges: gib Fehler zurück, da dies nicht vorberechnet wird raise HTTPException( @@ -317,8 +351,12 @@ async def get_moving_average(days: int = 7, exchange: str = None): """ if exchange: + try: + exchange = validate_exchange(exchange) + except ValueError: + raise HTTPException(status_code=400, detail="Ungueltiger Exchange-Name") query += f" and exchange = '{exchange}'" - + query += " order by date asc, exchange asc" data = query_questdb(query, timeout=5) @@ -393,6 +431,10 @@ async def get_stock_trends(days: int = 7, limit: int = 20): """ if days not in [7, 30, 42, 69, 180, 365]: raise HTTPException(status_code=400, detail="Invalid days parameter. Must be one of: 7, 30, 42, 69, 180, 365") + try: + limit = validate_int_range(limit, 1, 1000) + except ValueError: + raise HTTPException(status_code=400, detail="Ungueltiger limit-Parameter (1-1000)") query = f""" select @@ -423,22 +465,55 @@ async def get_analytics( continents: str = None ): """Analytics Endpunkt für Report Builder""" + # Validiere optionale Parameter + if date_from: + try: + date_from = validate_date(date_from) + except ValueError: + raise HTTPException(status_code=400, detail="Ungueltiges date_from Format (erwartet: YYYY-MM-DD)") + if date_to: + try: + date_to = validate_date(date_to) + except ValueError: + raise HTTPException(status_code=400, detail="Ungueltiges date_to Format (erwartet: YYYY-MM-DD)") + + validated_isins = None + if isins: + try: + validated_isins = validate_isin_list(isins) + except ValueError: + raise HTTPException(status_code=400, detail="Ungueltiger ISIN-Wert in der Liste") + + sanitized_continents = None + if continents: + sanitized_continents = [sanitize_sql_string(c.strip(), max_length=50) for c in continents.split(",") if c.strip()] + composite_keys = ["exchange_continent", "exchange_sector"] + valid_metrics = ["volume", "count", "avg_price", "all"] + valid_groups = ["day", "month", "exchange", "isin", "name", "continent", "sector", "exchange_continent", "exchange_sector"] + + if metric not in valid_metrics: + raise HTTPException(status_code=400, detail=f"Ungueltiger metric-Parameter. Erlaubt: {valid_metrics}") + if group_by not in valid_groups: + raise HTTPException(status_code=400, detail=f"Ungueltiger group_by-Parameter. Erlaubt: {valid_groups}") + if sub_group_by and sub_group_by not in valid_groups: + raise HTTPException(status_code=400, detail=f"Ungueltiger sub_group_by-Parameter. Erlaubt: {valid_groups}") + needs_metadata = any([ group_by in ["name", "continent", "sector"] + composite_keys, sub_group_by in ["name", "continent", "sector"] + composite_keys, continents is not None ]) - + t_prefix = "t." if needs_metadata else "" m_prefix = "m." if needs_metadata else "" - + metrics_map = { "volume": f"sum({t_prefix}price * {t_prefix}quantity)", "count": f"count(*)", "avg_price": f"avg({t_prefix}price)" } - + groups_map = { "day": f"date_trunc('day', {t_prefix}timestamp)", "month": f"date_trunc('month', {t_prefix}timestamp)", @@ -450,35 +525,35 @@ async def get_analytics( "exchange_continent": f"concat({t_prefix}exchange, ' - ', coalesce({m_prefix}continent, 'Unknown'))" if needs_metadata else "'Unknown'", "exchange_sector": f"concat({t_prefix}exchange, ' - ', coalesce({m_prefix}sector, 'Unknown'))" if needs_metadata else "'Unknown'" } - + selected_metric = metrics_map.get(metric, metrics_map["volume"]) selected_group = groups_map.get(group_by, groups_map["day"]) - + query = f"select {selected_group} as label" - + if sub_group_by and sub_group_by in groups_map: query += f", {groups_map[sub_group_by]} as sub_label" - + if metric == 'all': query += f", count(*) as value_count, sum({t_prefix}price * {t_prefix}quantity) as value_volume from trades" else: query += f", {selected_metric} as value from trades" if needs_metadata: query += " t left join metadata m on t.isin = m.isin" - + query += " where 1=1" - + if date_from: query += f" and {t_prefix}timestamp >= '{date_from}'" if date_to: query += f" and {t_prefix}timestamp <= '{date_to}'" - - if isins: - isins_list = ",".join([f"'{i.strip()}'" for i in isins.split(",")]) + + if validated_isins: + isins_list = ",".join([f"'{i}'" for i in validated_isins]) query += f" and {t_prefix}isin in ({isins_list})" - if continents and needs_metadata: - cont_list = ",".join([f"'{c.strip()}'" for c in continents.split(",")]) + if sanitized_continents and needs_metadata: + cont_list = ",".join([f"'{c}'" for c in sanitized_continents]) query += f" and {m_prefix}continent in ({cont_list})" query += f" group by {selected_group}" @@ -493,7 +568,8 @@ async def get_analytics( @app.get("/api/metadata/search") async def search_metadata(q: str): """Case-insensitive search for ISIN or Name""" - query = f"select isin, name from metadata where isin ilike '%{q}%' or name ilike '%{q}%' limit 10" + q_safe = sanitize_sql_string(q, max_length=100) + query = f"select isin, name from metadata where isin ilike '%{q_safe}%' or name ilike '%{q_safe}%' limit 10" data = query_questdb(query) return format_questdb_response(data)