11"""Flask web UI for DOCSight – DOCSIS channel monitoring."""
22
3+ import functools
34import logging
5+ import os
46import time
57from datetime import datetime , timedelta
68
7- from flask import Flask , render_template , request , jsonify , redirect
9+ from flask import Flask , render_template , request , jsonify , redirect , session , url_for
810
911from .config import POLL_MIN , POLL_MAX , PASSWORD_MASK , SECRET_KEYS
1012from .i18n import get_translations , LANGUAGES
1113
1214log = logging .getLogger ("docsis.web" )
1315
1416app = Flask (__name__ , template_folder = "templates" )
17+ app .secret_key = os .environ .get ("FLASK_SECRET_KEY" , os .urandom (32 ))
1518
1619
1720@app .template_filter ("fmt_k" )
@@ -67,6 +70,56 @@ def init_config(config_manager, on_config_changed=None):
6770 _on_config_changed = on_config_changed
6871
6972
73+ def _auth_required ():
74+ """Check if auth is enabled and user is not logged in."""
75+ if not _config_manager :
76+ return False
77+ admin_pw = _config_manager .get ("admin_password" , "" )
78+ if not admin_pw :
79+ return False
80+ return not session .get ("authenticated" )
81+
82+
83+ def require_auth (f ):
84+ """Decorator: redirect to /login if auth is enabled and not logged in."""
85+ @functools .wraps (f )
86+ def decorated (* args , ** kwargs ):
87+ if _auth_required ():
88+ return redirect ("/login" )
89+ return f (* args , ** kwargs )
90+ return decorated
91+
92+
93+ @app .route ("/login" , methods = ["GET" , "POST" ])
94+ def login ():
95+ if not _config_manager or not _config_manager .get ("admin_password" , "" ):
96+ return redirect ("/" )
97+ lang = _get_lang ()
98+ t = get_translations (lang )
99+ theme = _config_manager .get_theme () if _config_manager else "dark"
100+ error = None
101+ if request .method == "POST" :
102+ pw = request .form .get ("password" , "" )
103+ if pw == _config_manager .get ("admin_password" , "" ):
104+ session ["authenticated" ] = True
105+ return redirect ("/" )
106+ error = t .get ("login_failed" , "Invalid password" )
107+ return render_template ("login.html" , t = t , lang = lang , theme = theme , error = error )
108+
109+
110+ @app .route ("/logout" )
111+ def logout ():
112+ session .pop ("authenticated" , None )
113+ return redirect ("/login" )
114+
115+
116+ @app .context_processor
117+ def inject_auth ():
118+ """Make auth_enabled available in all templates."""
119+ auth_enabled = bool (_config_manager and _config_manager .get ("admin_password" , "" ))
120+ return {"auth_enabled" : auth_enabled }
121+
122+
70123def update_state (analysis = None , error = None , poll_interval = None , connection_info = None ):
71124 """Update the shared web state from the main loop."""
72125 if analysis is not None :
@@ -82,6 +135,7 @@ def update_state(analysis=None, error=None, poll_interval=None, connection_info=
82135
83136
84137@app .route ("/" )
138+ @require_auth
85139def index ():
86140 if _config_manager and not _config_manager .is_configured ():
87141 return redirect ("/setup" )
@@ -134,6 +188,7 @@ def setup():
134188
135189
136190@app .route ("/settings" )
191+ @require_auth
137192def settings ():
138193 config = _config_manager .get_all (mask_secrets = True ) if _config_manager else {}
139194 theme = _config_manager .get_theme () if _config_manager else "dark"
@@ -143,6 +198,7 @@ def settings():
143198
144199
145200@app .route ("/api/config" , methods = ["POST" ])
201+ @require_auth
146202def api_config ():
147203 """Save configuration."""
148204 if not _config_manager :
@@ -168,6 +224,7 @@ def api_config():
168224
169225
170226@app .route ("/api/test-fritz" , methods = ["POST" ])
227+ @require_auth
171228def api_test_fritz ():
172229 """Test FritzBox connection."""
173230 try :
@@ -189,6 +246,7 @@ def api_test_fritz():
189246
190247
191248@app .route ("/api/test-mqtt" , methods = ["POST" ])
249+ @require_auth
192250def api_test_mqtt ():
193251 """Test MQTT broker connection."""
194252 try :
0 commit comments