From d9f3bd8289ca910c63368765b4c863d0a6731138 Mon Sep 17 00:00:00 2001 From: Matthew Grotke Date: Wed, 27 May 2026 22:04:04 -0400 Subject: [PATCH] Development --- .../action.py => action_accountlogout.py} | 6 +- docker/routlin-dash/app/auth.py | 4 +- docker/routlin-dash/app/main.py | 10 +- docker/routlin-dash/app/navbar_content.json | 34 +++--- .../app/pages/accountadd/__init__.py | 0 .../app/pages/accountcreate/action.py | 25 ++-- .../app/pages/accountcreate/content.json | 9 +- .../app/pages/accountdelete/__init__.py | 0 .../app/pages/accountdelete/action.py | 50 -------- .../app/pages/accountlogin/action.py | 21 ++-- .../app/pages/accountlogin/content.json | 9 +- .../app/pages/accountlogout/__init__.py | 0 .../{accountadd => accountmanage}/action.py | 49 ++++++-- .../app/pages/accountmanage/content.json | 7 +- .../app/pages/accountverifyemail/action.py | 35 +++--- .../app/pages/accountverifyemail/content.json | 11 +- .../routlin-dash/app/pages/actions/action.py | 28 ++--- .../app/pages/actions/content.json | 9 +- .../app/pages/bannedips/action.py | 62 +++++----- .../app/pages/bannedips/content.json | 9 +- docker/routlin-dash/app/pages/ddns/action.py | 86 +++++++------- .../routlin-dash/app/pages/ddns/content.json | 23 ++-- docker/routlin-dash/app/pages/dhcp/action.py | 74 ++++++------ .../routlin-dash/app/pages/dhcp/content.json | 9 +- .../app/pages/dnsblocking/action.py | 77 ++++++------ .../app/pages/dnsblocking/content.json | 19 ++- .../app/pages/dnsserver/action.py | 36 +++--- .../app/pages/dnsserver/content.json | 9 +- .../app/pages/hostoverrides/action.py | 66 +++++------ .../app/pages/hostoverrides/content.json | 9 +- .../app/pages/intervlan/action.py | 61 +++++----- .../app/pages/intervlan/content.json | 9 +- docker/routlin-dash/app/pages/mdns/action.py | 15 ++- .../app/pages/networklayout/action.py | 91 ++++++++------- .../app/pages/networklayout/content.json | 9 +- .../app/pages/overview/content.json | 3 +- .../app/pages/physicalinterfaces/action.py | 41 +++---- .../app/pages/physicalinterfaces/content.json | 9 +- .../app/pages/portforwarding/action.py | 61 +++++----- .../app/pages/portforwarding/content.json | 9 +- .../app/pages/preferences/action.py | 31 ++--- .../app/pages/preferences/content.json | 9 +- docker/routlin-dash/app/pages/vpn/action.py | 110 +++++++++--------- .../routlin-dash/app/pages/vpn/content.json | 15 ++- docker/routlin-dash/app/view_page.py | 42 ++----- 45 files changed, 635 insertions(+), 666 deletions(-) rename docker/routlin-dash/app/{pages/accountlogout/action.py => action_accountlogout.py} (61%) delete mode 100644 docker/routlin-dash/app/pages/accountadd/__init__.py delete mode 100644 docker/routlin-dash/app/pages/accountdelete/__init__.py delete mode 100644 docker/routlin-dash/app/pages/accountdelete/action.py delete mode 100644 docker/routlin-dash/app/pages/accountlogout/__init__.py rename docker/routlin-dash/app/pages/{accountadd => accountmanage}/action.py (56%) diff --git a/docker/routlin-dash/app/pages/accountlogout/action.py b/docker/routlin-dash/app/action_accountlogout.py similarity index 61% rename from docker/routlin-dash/app/pages/accountlogout/action.py rename to docker/routlin-dash/app/action_accountlogout.py index d98f82f..3a10b35 100644 --- a/docker/routlin-dash/app/pages/accountlogout/action.py +++ b/docker/routlin-dash/app/action_accountlogout.py @@ -4,8 +4,8 @@ from auth import require_level bp = Blueprint('accountlogout', __name__) -@bp.route('/action/log_out', methods=['POST']) +@bp.route('/action/accountlogout/logout', methods=['POST']) @require_level('viewer') -def log_out(): +def logout(): session.clear() - return redirect('/view/view_overview') + return redirect('/overview') diff --git a/docker/routlin-dash/app/auth.py b/docker/routlin-dash/app/auth.py index ae20c47..241913b 100644 --- a/docker/routlin-dash/app/auth.py +++ b/docker/routlin-dash/app/auth.py @@ -13,9 +13,9 @@ def require_level(minimum): if LEVEL_RANK.get(current, 0) < LEVEL_RANK.get(minimum, 0): if current == 'nothing': flash('Please log in to continue.', 'error') - return redirect('/view/view_login') + return redirect('/accountlogin') flash('You do not have permission to perform this action.', 'error') - return redirect('/view/view_overview') + return redirect('/overview') return f(*args, **kwargs) return wrapper return decorator diff --git a/docker/routlin-dash/app/main.py b/docker/routlin-dash/app/main.py index 9a56f97..ef77514 100644 --- a/docker/routlin-dash/app/main.py +++ b/docker/routlin-dash/app/main.py @@ -18,11 +18,10 @@ from pages.preferences.action import bp as preferences_bp from pages.accountverifyemail.action import bp as accountverifyemail_bp from pages.vpn.action import bp as vpn_bp from pages.accountcreate.action import bp as accountcreate_bp -from pages.accountadd.action import bp as accountadd_bp -from pages.accountdelete.action import bp as accountdelete_bp -from pages.accountlogout.action import bp as accountlogout_bp +from pages.accountmanage.action import bp as accountmanage_bp from pages.mdns.action import bp as mdns_bp -from api_apply_health import bp as api_apply_health_bp +from action_accountlogout import bp as accountlogout_bp +from api_apply_health import bp as api_apply_health_bp app = Flask(__name__) app.secret_key = os.environ.get('SECRET_KEY', os.urandom(24)) @@ -43,8 +42,7 @@ app.register_blueprint(preferences_bp) app.register_blueprint(accountverifyemail_bp) app.register_blueprint(vpn_bp) app.register_blueprint(accountcreate_bp) -app.register_blueprint(accountadd_bp) -app.register_blueprint(accountdelete_bp) +app.register_blueprint(accountmanage_bp) app.register_blueprint(accountlogout_bp) app.register_blueprint(mdns_bp) app.register_blueprint(api_apply_health_bp) diff --git a/docker/routlin-dash/app/navbar_content.json b/docker/routlin-dash/app/navbar_content.json index 992fe4e..abe16a5 100644 --- a/docker/routlin-dash/app/navbar_content.json +++ b/docker/routlin-dash/app/navbar_content.json @@ -3,7 +3,7 @@ { "type": "nav_item", "label": "Overview", - "map_to": "view_overview", + "map_to": "overview", "client_requirement": "client_is_nothing+" }, { @@ -11,23 +11,23 @@ "label": "%MENU_LABEL%", "client_requirement": "client_is_viewer+", "items": [ - { "type": "nav_item", "label": "Physical Interfaces", "map_to": "view_physicalinterfaces", "client_requirement": "client_is_administrator+" }, - { "type": "nav_item", "label": "DNS Server", "map_to": "view_dnsserver", "client_requirement": "client_is_administrator+" }, - { "type": "nav_item", "label": "DNS Blocking", "map_to": "view_dnsblocking", "client_requirement": "client_is_administrator+" }, - { "type": "nav_item", "label": "Network Layout", "map_to": "view_networklayout", "client_requirement": "client_is_administrator+" }, - { "type": "nav_item", "label": "Inter-VLAN Exceptions", "map_to": "view_intervlan", "client_requirement": "client_is_administrator+" }, - { "type": "nav_item", "label": "Port Forwarding", "map_to": "view_portforwarding", "client_requirement": "client_is_administrator+" }, - { "type": "nav_item", "label": "DHCP", "map_to": "view_dhcp" }, - { "type": "nav_item", "label": "DDNS", "map_to": "view_ddns" }, - { "type": "nav_item", "label": "Host Overrides", "map_to": "view_hostoverrides", "client_requirement": "client_is_administrator+" }, - { "type": "nav_item", "label": "VPN", "map_to": "view_vpn" }, - { "type": "nav_item", "label": "Banned IPs", "map_to": "view_bannedips", "client_requirement": "client_is_administrator+" } + { "type": "nav_item", "label": "Physical Interfaces", "map_to": "physicalinterfaces", "client_requirement": "client_is_administrator+" }, + { "type": "nav_item", "label": "DNS Server", "map_to": "dnsserver", "client_requirement": "client_is_administrator+" }, + { "type": "nav_item", "label": "DNS Blocking", "map_to": "dnsblocking", "client_requirement": "client_is_administrator+" }, + { "type": "nav_item", "label": "Network Layout", "map_to": "networklayout", "client_requirement": "client_is_administrator+" }, + { "type": "nav_item", "label": "Inter-VLAN Exceptions", "map_to": "intervlan", "client_requirement": "client_is_administrator+" }, + { "type": "nav_item", "label": "Port Forwarding", "map_to": "portforwarding", "client_requirement": "client_is_administrator+" }, + { "type": "nav_item", "label": "DHCP", "map_to": "dhcp" }, + { "type": "nav_item", "label": "DDNS", "map_to": "ddns" }, + { "type": "nav_item", "label": "Host Overrides", "map_to": "hostoverrides", "client_requirement": "client_is_administrator+" }, + { "type": "nav_item", "label": "VPN", "map_to": "vpn" }, + { "type": "nav_item", "label": "Banned IPs", "map_to": "bannedips", "client_requirement": "client_is_administrator+" } ] }, { "type": "nav_item", "label": "Actions", - "map_to": "view_actions", + "map_to": "actions", "client_requirement": "client_is_administrator+" }, { @@ -36,22 +36,22 @@ "align": "right", "client_requirement": "client_is_viewer+", "items": [ - { "type": "nav_item", "label": "Preferences", "map_to": "view_preferences" }, - { "type": "nav_item", "label": "Manage Accounts", "map_to": "view_manageaccounts", "client_requirement": "client_is_manager+" }, + { "type": "nav_item", "label": "Preferences", "map_to": "preferences" }, + { "type": "nav_item", "label": "Manage Accounts", "map_to": "accountmanage", "client_requirement": "client_is_manager+" }, { "type": "nav_action", "label": "Log Out", "action": "log_out" } ] }, { "type": "nav_item", "label": "Log In", - "map_to": "view_login", + "map_to": "accountlogin", "align": "right", "client_requirement": "client_is_nothing=" }, { "type": "nav_item", "label": "Create Account", - "map_to": "view_createaccount", + "map_to": "accountcreate", "align": "right", "client_requirement": "client_is_nothing=" } diff --git a/docker/routlin-dash/app/pages/accountadd/__init__.py b/docker/routlin-dash/app/pages/accountadd/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/docker/routlin-dash/app/pages/accountcreate/action.py b/docker/routlin-dash/app/pages/accountcreate/action.py index 32e816d..26f49c6 100644 --- a/docker/routlin-dash/app/pages/accountcreate/action.py +++ b/docker/routlin-dash/app/pages/accountcreate/action.py @@ -1,3 +1,4 @@ +from pathlib import Path from flask import Blueprint, request, session, redirect, flash import json, os, bcrypt, secrets, smtplib from datetime import datetime, timezone, timedelta @@ -6,7 +7,9 @@ from auth import require_level from config_utils import WEB_APP_DISPLAY_NAME, ACCOUNTS_FILE import sanitize -bp = Blueprint('accountcreate', __name__) +_PAGE = Path(__file__).parent.name + +bp = Blueprint(_PAGE, __name__) CODE_TTL_MIN = 15 @@ -48,12 +51,12 @@ def _send_verification_email(to_address, code): smtp.send_message(msg) -@bp.route('/action/create_account', methods=['POST']) +@bp.route('/action/accountcreate/form_create', methods=['POST']) @require_level('nothing') -def create_account(): +def form_create(): # Abort if already logged in if session.get('access_level', 'nothing') != 'nothing': - return redirect('/view/view_overview') + return redirect('/overview') email = sanitize.email(request.form.get('email', '')) password = request.form.get('password', '') @@ -62,26 +65,26 @@ def create_account(): if not email or not password or not password_confirm or not tz: flash('All fields are required.', 'error') - return redirect('/view/view_createaccount') + return redirect(f'/{_PAGE}') if password != password_confirm: flash('Passwords do not match.', 'error') - return redirect('/view/view_createaccount') + return redirect(f'/{_PAGE}') if len(password) < 8: flash('Password must be at least 8 characters.', 'error') - return redirect('/view/view_createaccount') + return redirect(f'/{_PAGE}') accounts = _load_accounts().get('accounts', []) account = next((a for a in accounts if a.get('email_address', '').lower() == email), None) if account is None: flash('Email address not recognised. Contact your manager.', 'error') - return redirect('/view/view_createaccount') + return redirect(f'/{_PAGE}') if account.get('hashed_password'): flash('This account is already set up. Please log in instead.', 'error') - return redirect('/view/view_createaccount') + return redirect(f'/{_PAGE}') salt = bcrypt.gensalt() hashed = bcrypt.hashpw(password.encode('utf-8'), salt) @@ -92,7 +95,7 @@ def create_account(): _send_verification_email(account['email_address'], code) except Exception as exc: flash(f'Could not send verification email: {exc}', 'error') - return redirect('/view/view_createaccount') + return redirect(f'/{_PAGE}') session['pending_create_account'] = { 'email': account['email_address'], @@ -102,4 +105,4 @@ def create_account(): 'expires': expires, } - return redirect('/view/view_verifyemail') + return redirect('/accountverifyemail') diff --git a/docker/routlin-dash/app/pages/accountcreate/content.json b/docker/routlin-dash/app/pages/accountcreate/content.json index 0805471..5880e8d 100644 --- a/docker/routlin-dash/app/pages/accountcreate/content.json +++ b/docker/routlin-dash/app/pages/accountcreate/content.json @@ -1,5 +1,4 @@ { - "id": "view_createaccount", "client_requirement": "client_is_nothing=", "items": [ { @@ -22,7 +21,7 @@ }, { "type": "form", - "action": "/action/create_account", + "action": "/action/accountcreate/form_create", "method": "post", "items": [ { @@ -58,7 +57,7 @@ }, { "type": "button_primary", - "action": "/action/create_account", + "action": "/action/accountcreate/form_create", "method": "post", "text": "Create Account", "class": "btn-full" @@ -69,7 +68,7 @@ "type": "p", "text": "Already have an account?", "link": { - "action": "/view/view_login", + "action": "/accountlogin", "text": "Log In" } } @@ -94,7 +93,7 @@ }, { "type": "button_primary", - "action": "/view/overview", + "action": "/overview", "text": "Go to Overview" } ] diff --git a/docker/routlin-dash/app/pages/accountdelete/__init__.py b/docker/routlin-dash/app/pages/accountdelete/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/docker/routlin-dash/app/pages/accountdelete/action.py b/docker/routlin-dash/app/pages/accountdelete/action.py deleted file mode 100644 index e63c42f..0000000 --- a/docker/routlin-dash/app/pages/accountdelete/action.py +++ /dev/null @@ -1,50 +0,0 @@ -from flask import Blueprint, request, session, redirect, flash -import json -from auth import require_level -from config_utils import ACCOUNTS_FILE - -bp = Blueprint('accountdelete', __name__) - - - -def _load_accounts(): - try: - with open(ACCOUNTS_FILE) as f: - return json.load(f) - except Exception: - return {'accounts': []} - -def _save_accounts(data): - with open(ACCOUNTS_FILE, 'w') as f: - json.dump(data, f, indent=2) - - -@bp.route('/action/delete_account', methods=['POST']) -@require_level('manager') -def delete_account(): - try: - row_index = int(request.form.get('row_index', '')) - except (ValueError, TypeError): - flash('Invalid request.', 'error') - return redirect('/view/view_manageaccounts') - - data = _load_accounts() - accounts = data.get('accounts', []) - - if row_index < 0 or row_index >= len(accounts): - flash('Account not found.', 'error') - return redirect('/view/view_manageaccounts') - - target = accounts[row_index] - - if target.get('email_address', '').lower() == session.get('email_address', '').lower(): - flash('You cannot remove your own account.', 'error') - return redirect('/view/view_manageaccounts') - - removed_email = target.get('email_address', '') - accounts.pop(row_index) - data['accounts'] = accounts - _save_accounts(data) - - flash(f'Account for {removed_email} has been removed.', 'success') - return redirect('/view/view_manageaccounts') diff --git a/docker/routlin-dash/app/pages/accountlogin/action.py b/docker/routlin-dash/app/pages/accountlogin/action.py index e57584c..1b10ef2 100644 --- a/docker/routlin-dash/app/pages/accountlogin/action.py +++ b/docker/routlin-dash/app/pages/accountlogin/action.py @@ -1,10 +1,13 @@ +from pathlib import Path from flask import Blueprint, request, session, redirect, flash import json, bcrypt from auth import require_level from config_utils import ACCOUNTS_FILE import sanitize -bp = Blueprint('accountlogin', __name__) +_PAGE = Path(__file__).parent.name + +bp = Blueprint(_PAGE, __name__) @@ -16,35 +19,35 @@ def _load_accounts(): return {'accounts': []} -@bp.route('/action/log_in', methods=['POST']) +@bp.route('/action/accountlogin/form_login', methods=['POST']) @require_level('nothing') -def log_in(): +def form_login(): # Abort if already logged in if session.get('access_level', 'nothing') != 'nothing': - return redirect('/view/view_overview') + return redirect('/overview') email = sanitize.email(request.form.get('email', '')) password = request.form.get('password', '') if not email or not password: flash('Email address and password are required.', 'error') - return redirect('/view/view_login') + return redirect(f'/{_PAGE}') accounts = _load_accounts().get('accounts', []) account = next((a for a in accounts if a.get('email_address', '').lower() == email), None) if account is None: flash('Email address not recognised.', 'error') - return redirect('/view/view_login') + return redirect(f'/{_PAGE}') if not account.get('hashed_password'): flash('Account setup is not complete. Please use Create Account to set your password first.', 'error') - return redirect('/view/view_login') + return redirect(f'/{_PAGE}') stored_hash = account['hashed_password'].encode('utf-8') if not bcrypt.checkpw(password.encode('utf-8'), stored_hash): flash('Invalid email address or password.', 'error') - return redirect('/view/view_login') + return redirect(f'/{_PAGE}') session.clear() session['email_address'] = account['email_address'] @@ -53,4 +56,4 @@ def log_in(): session['apply_changes_immediately'] = False session.permanent = True - return redirect('/view/view_overview') + return redirect('/overview') diff --git a/docker/routlin-dash/app/pages/accountlogin/content.json b/docker/routlin-dash/app/pages/accountlogin/content.json index fea107c..c9b2922 100644 --- a/docker/routlin-dash/app/pages/accountlogin/content.json +++ b/docker/routlin-dash/app/pages/accountlogin/content.json @@ -1,5 +1,4 @@ { - "id": "view_login", "client_requirement": "client_is_nothing=", "items": [ { @@ -22,7 +21,7 @@ }, { "type": "form", - "action": "/action/log_in", + "action": "/action/accountlogin/form_login", "method": "post", "items": [ { @@ -41,7 +40,7 @@ }, { "type": "button_primary", - "action": "/action/log_in", + "action": "/action/accountlogin/form_login", "method": "post", "text": "Log In", "class": "btn-full" @@ -52,7 +51,7 @@ "type": "p", "text": "Need to complete your account?", "link": { - "action": "/view/view_createaccount", + "action": "/accountcreate", "text": "Create Account" } } @@ -77,7 +76,7 @@ }, { "type": "button_primary", - "action": "/view/overview", + "action": "/overview", "text": "Go to Overview" } ] diff --git a/docker/routlin-dash/app/pages/accountlogout/__init__.py b/docker/routlin-dash/app/pages/accountlogout/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/docker/routlin-dash/app/pages/accountadd/action.py b/docker/routlin-dash/app/pages/accountmanage/action.py similarity index 56% rename from docker/routlin-dash/app/pages/accountadd/action.py rename to docker/routlin-dash/app/pages/accountmanage/action.py index 5da658d..37f15b3 100644 --- a/docker/routlin-dash/app/pages/accountadd/action.py +++ b/docker/routlin-dash/app/pages/accountmanage/action.py @@ -1,3 +1,4 @@ +from pathlib import Path from flask import Blueprint, request, session, redirect, flash import json, re from datetime import datetime, timezone @@ -5,8 +6,9 @@ from auth import require_level from config_utils import ACCOUNTS_FILE import sanitize -bp = Blueprint('accountadd', __name__) +_PAGE = Path(__file__).parent.name +bp = Blueprint(_PAGE, __name__) VALID_LEVELS = {'viewer', 'administrator', 'manager'} @@ -23,30 +25,30 @@ def _save_accounts(data): json.dump(data, f, indent=2) -@bp.route('/action/add_account', methods=['POST']) +@bp.route('/action/accountmanage/accounts_add', methods=['POST']) @require_level('manager') -def add_account(): +def accounts_add(): email = sanitize.email(request.form.get('email_address', '')) access_level = request.form.get('access_level', '').strip() if not email: flash('Email address is required.', 'error') - return redirect('/view/view_manageaccounts') + return redirect(f'/{_PAGE}') if not re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', email): flash('Email address does not appear to be valid.', 'error') - return redirect('/view/view_manageaccounts') + return redirect(f'/{_PAGE}') if access_level not in VALID_LEVELS: flash('Invalid access level.', 'error') - return redirect('/view/view_manageaccounts') + return redirect(f'/{_PAGE}') data = _load_accounts() accounts = data.get('accounts', []) if any(a.get('email_address', '').lower() == email for a in accounts): flash('An account with that email address already exists.', 'error') - return redirect('/view/view_manageaccounts') + return redirect(f'/{_PAGE}') now = datetime.now(tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') accounts.append({ @@ -61,4 +63,35 @@ def add_account(): _save_accounts(data) flash(f'Authorization added for {email}. User must complete account setup via the Create Account page.', 'success') - return redirect('/view/view_manageaccounts') + return redirect(f'/{_PAGE}') + + +@bp.route('/action/accountmanage/accounts_delete', methods=['POST']) +@require_level('manager') +def accounts_delete(): + try: + row_index = int(request.form.get('row_index', '')) + except (ValueError, TypeError): + flash('Invalid request.', 'error') + return redirect(f'/{_PAGE}') + + data = _load_accounts() + accounts = data.get('accounts', []) + + if row_index < 0 or row_index >= len(accounts): + flash('Account not found.', 'error') + return redirect(f'/{_PAGE}') + + target = accounts[row_index] + + if target.get('email_address', '').lower() == session.get('email_address', '').lower(): + flash('You cannot remove your own account.', 'error') + return redirect(f'/{_PAGE}') + + removed_email = target.get('email_address', '') + accounts.pop(row_index) + data['accounts'] = accounts + _save_accounts(data) + + flash(f'Account for {removed_email} has been removed.', 'success') + return redirect(f'/{_PAGE}') diff --git a/docker/routlin-dash/app/pages/accountmanage/content.json b/docker/routlin-dash/app/pages/accountmanage/content.json index 49c7b91..cf19928 100644 --- a/docker/routlin-dash/app/pages/accountmanage/content.json +++ b/docker/routlin-dash/app/pages/accountmanage/content.json @@ -1,5 +1,4 @@ { - "id": "view_manageaccounts", "client_requirement": "client_is_manager+", "items": [ { @@ -40,7 +39,7 @@ ], "row_actions": [ { - "action": "/action/delete_account", + "action": "/action/accountmanage/accounts_delete", "method": "post", "text": "Remove", "class": "btn-danger btn-sm" @@ -53,7 +52,7 @@ "items": [ { "type": "form", - "action": "/action/add_account", + "action": "/action/accountmanage/accounts_add", "method": "post", "items": [ { @@ -76,7 +75,7 @@ "items": [ { "type": "button_primary", - "action": "/action/add_account", + "action": "/action/accountmanage/accounts_add", "method": "post", "text": "Authorize" } diff --git a/docker/routlin-dash/app/pages/accountverifyemail/action.py b/docker/routlin-dash/app/pages/accountverifyemail/action.py index 75e9b14..5f418a9 100644 --- a/docker/routlin-dash/app/pages/accountverifyemail/action.py +++ b/docker/routlin-dash/app/pages/accountverifyemail/action.py @@ -1,10 +1,13 @@ +from pathlib import Path from flask import Blueprint, request, session, redirect, flash import json, os, secrets from datetime import datetime, timezone, timedelta from auth import require_level from config_utils import ACCOUNTS_FILE -bp = Blueprint('accountverifyemail', __name__) +_PAGE = Path(__file__).parent.name + +bp = Blueprint(_PAGE, __name__) @@ -20,29 +23,29 @@ def _save_accounts(data): json.dump(data, f, indent=2) -@bp.route('/action/verify_email', methods=['POST']) +@bp.route('/action/accountverifyemail/email_verify', methods=['POST']) @require_level('nothing') -def verify_email(): +def email_verify(): # Abort if already logged in if session.get('access_level', 'nothing') != 'nothing': - return redirect('/view/view_overview') + return redirect('/overview') pending = session.get('pending_create_account') if not pending: flash('No pending account creation found. Please start over.', 'error') - return redirect('/view/view_createaccount') + return redirect('/accountcreate') expires = datetime.fromisoformat(pending['expires']) if datetime.now(tz=timezone.utc) > expires: session.pop('pending_create_account', None) flash('Verification code has expired. Please start over.', 'error') - return redirect('/view/view_createaccount') + return redirect('/accountcreate') submitted = request.form.get('code', '').strip() if submitted != pending['code']: flash('Incorrect verification code.', 'error') - return redirect('/view/view_verifyemail') + return redirect(f'/{_PAGE}') data = _load_accounts() accounts = data.get('accounts', []) @@ -54,12 +57,12 @@ def verify_email(): if account is None: session.pop('pending_create_account', None) flash('Account no longer exists. Contact your manager.', 'error') - return redirect('/view/view_createaccount') + return redirect('/accountcreate') if account.get('hashed_password'): session.pop('pending_create_account', None) flash('This account is already set up. Please log in.', 'error') - return redirect('/view/view_login') + return redirect('/accountlogin') now = datetime.now(tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') account['hashed_password'] = pending['hashed_password'] @@ -77,15 +80,15 @@ def verify_email(): session['timezone'] = pending['timezone'] session.permanent = True - return redirect('/view/view_overview') + return redirect('/overview') -@bp.route('/action/resend_verification') +@bp.route('/action/accountverifyemail/email_resend') @require_level('nothing') -def resend_verification(): +def email_resend(): # Abort if already logged in if session.get('access_level', 'nothing') != 'nothing': - return redirect('/view/view_overview') + return redirect('/overview') from pages.accountcreate.action import _send_verification_email, CODE_TTL_MIN @@ -93,7 +96,7 @@ def resend_verification(): if not pending: flash('No pending account creation found. Please start over.', 'error') - return redirect('/view/view_createaccount') + return redirect('/accountcreate') code = f'{secrets.randbelow(1000000):06d}' expires = (datetime.now(tz=timezone.utc) + timedelta(minutes=CODE_TTL_MIN)).isoformat() @@ -102,11 +105,11 @@ def resend_verification(): _send_verification_email(pending['email'], code) except Exception as exc: flash(f'Could not resend verification email: {exc}', 'error') - return redirect('/view/view_verifyemail') + return redirect(f'/{_PAGE}') pending['code'] = code pending['expires'] = expires session['pending_create_account'] = pending flash('A new verification code has been sent.', 'success') - return redirect('/view/view_verifyemail') + return redirect(f'/{_PAGE}') diff --git a/docker/routlin-dash/app/pages/accountverifyemail/content.json b/docker/routlin-dash/app/pages/accountverifyemail/content.json index fa5a318..8c33829 100644 --- a/docker/routlin-dash/app/pages/accountverifyemail/content.json +++ b/docker/routlin-dash/app/pages/accountverifyemail/content.json @@ -1,5 +1,4 @@ { - "id": "view_verifyemail", "client_requirement": "client_is_nothing=", "items": [ { @@ -19,7 +18,7 @@ }, { "type": "form", - "action": "/action/verify_email", + "action": "/action/accountverifyemail/email_verify", "method": "post", "items": [ { @@ -32,7 +31,7 @@ }, { "type": "button_primary", - "action": "/action/verify_email", + "action": "/action/accountverifyemail/email_verify", "method": "post", "text": "Verify", "class": "btn-full" @@ -43,7 +42,7 @@ "type": "p", "text": "Didn't receive it?", "link": { - "action": "/action/resend_verification", + "action": "/action/accountverifyemail/email_resend", "text": "Resend code" } }, @@ -51,7 +50,7 @@ "type": "p", "text": "Wrong email?", "link": { - "action": "/view/view_createaccount", + "action": "/accountcreate", "text": "Start over" } } @@ -76,7 +75,7 @@ }, { "type": "button_primary", - "action": "/view/view_overview", + "action": "/overview", "text": "Go to Overview" } ] diff --git a/docker/routlin-dash/app/pages/actions/action.py b/docker/routlin-dash/app/pages/actions/action.py index 972434f..5a22ad0 100644 --- a/docker/routlin-dash/app/pages/actions/action.py +++ b/docker/routlin-dash/app/pages/actions/action.py @@ -1,41 +1,41 @@ +from pathlib import Path from flask import Blueprint, request, redirect, flash, session from auth import require_level from config_utils import (flush_pending_to_queue, get_dashboard_pending, revert_snapshot_to_config, queued_msg) -bp = Blueprint('actions', __name__) +_PAGE = Path(__file__).parent.name -_VIEW = '/view/view_actions' +bp = Blueprint(_PAGE, __name__) - -@bp.route('/action/actions_cardpending_save', methods=['POST']) +@bp.route('/action/actions/pending_save', methods=['POST']) @require_level('administrator') -def actions_cardpending_save(): +def pending_save(): session['apply_changes_immediately'] = 'apply_changes_immediately' in request.form flash('Preference saved.', 'success') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') -@bp.route('/action/actions_cardpending_applynow', methods=['POST']) +@bp.route('/action/actions/pending_apply', methods=['POST']) @require_level('administrator') -def actions_cardpending_applynow(): +def pending_apply(): pending = get_dashboard_pending() if not pending: flash('No pending changes to apply.', 'info') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') flush_pending_to_queue() if any(cmd != 'fix problems' for _, _, cmd, _ in pending): flash('Changes queued.', 'success') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') -@bp.route('/action/actions_cardhistory_revertselected', methods=['POST']) +@bp.route('/action/actions/history_revert', methods=['POST']) @require_level('administrator') -def actions_cardhistory_revertselected(): +def history_revert(): selected_uuids = request.form.getlist('selected_uuids') if not selected_uuids: flash('No items selected.', 'info') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') succeeded, failed = 0, 0 for uuid in selected_uuids: msg, ok = revert_snapshot_to_config(uuid) @@ -47,4 +47,4 @@ def actions_cardhistory_revertselected(): if succeeded: plural = 's' if succeeded != 1 else '' flash(f'{succeeded} change{plural} reverted.', 'success') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') diff --git a/docker/routlin-dash/app/pages/actions/content.json b/docker/routlin-dash/app/pages/actions/content.json index 4ed8c4e..cb6253a 100644 --- a/docker/routlin-dash/app/pages/actions/content.json +++ b/docker/routlin-dash/app/pages/actions/content.json @@ -1,5 +1,4 @@ { - "id": "view_actions", "client_requirement": "client_is_viewer+", "items": [ { @@ -22,7 +21,7 @@ "items": [ { "type": "form", - "action": "/action/actions_cardpending_applynow", + "action": "/action/actions/pending_apply", "method": "post", "items": [ { @@ -50,7 +49,7 @@ }, { "type": "form", - "action": "/action/actions_cardpending_save", + "action": "/action/actions/pending_save", "method": "post", "items": [ { @@ -66,7 +65,7 @@ "items": [ { "type": "button_primary", - "action": "/action/actions_cardpending_save", + "action": "/action/actions/pending_save", "method": "post", "text": "Save" }, @@ -87,7 +86,7 @@ "items": [ { "type": "form", - "action": "/action/actions_cardhistory_revertselected", + "action": "/action/actions/history_revert", "method": "post", "items": [ { diff --git a/docker/routlin-dash/app/pages/bannedips/action.py b/docker/routlin-dash/app/pages/bannedips/action.py index 6c0af66..01c3ffc 100644 --- a/docker/routlin-dash/app/pages/bannedips/action.py +++ b/docker/routlin-dash/app/pages/bannedips/action.py @@ -1,3 +1,4 @@ +from pathlib import Path import copy from flask import Blueprint, request, redirect, flash @@ -6,10 +7,9 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h import sanitize import validation as validate -bp = Blueprint('bannedips', __name__) - -VIEW = '/view/view_bannedips' +_PAGE = Path(__file__).parent.name +bp = Blueprint(_PAGE, __name__) def _row_index(): try: @@ -37,15 +37,15 @@ def _parse_ip(): return ip -@bp.route('/action/add_banned_ip', methods=['POST']) +@bp.route('/action/bannedips/addip_add', methods=['POST']) @require_level('administrator') -def add_banned_ip(): +def addip_add(): description = sanitize.text(request.form.get('description', '')) ip = _parse_ip() if ip is None: - return redirect(VIEW) + return redirect(f'/{_PAGE}') if not _hash_ok(): - return redirect(VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() entry = {'description': description, 'ip': ip, 'enabled': True} @@ -54,7 +54,7 @@ def add_banned_ip(): if errors: for msg in errors: flash(msg, 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') flash(save_config_with_snapshot( cfg, @@ -62,24 +62,24 @@ def add_banned_ip(): before=None, after=entry, description=f'Added banned IP: {ip}', ), 'success') - return redirect(VIEW) + return redirect(f'/{_PAGE}') -@bp.route('/action/toggle_banned_ip', methods=['POST']) +@bp.route('/action/bannedips/table_toggle', methods=['POST']) @require_level('administrator') -def toggle_banned_ip(): +def table_toggle(): idx = _row_index() if idx is None: flash('Invalid request.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') if not _hash_ok(): - return redirect(VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() items = cfg.get('banned_ips', []) if idx < 0 or idx >= len(items): flash('Entry not found.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') old_enabled = items[idx].get('enabled', True) items[idx]['enabled'] = not old_enabled @@ -87,7 +87,7 @@ def toggle_banned_ip(): if errors: for msg in errors: flash(msg, 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') action = 'Enabled' if not old_enabled else 'Disabled' flash(save_config_with_snapshot( @@ -96,31 +96,31 @@ def toggle_banned_ip(): before={'enabled': old_enabled}, after={'enabled': not old_enabled}, description=f'{action} banned IP: {items[idx]["ip"]}', ), 'success') - return redirect(VIEW) + return redirect(f'/{_PAGE}') -@bp.route('/action/edit_banned_ip', methods=['POST']) +@bp.route('/action/bannedips/table_edit', methods=['POST']) @require_level('administrator') -def edit_banned_ip(): +def table_edit(): idx = _row_index() if idx is None: flash('Invalid request.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') description = sanitize.text(request.form.get('description', '')) ip = _parse_ip() if ip is None: - return redirect(VIEW) + return redirect(f'/{_PAGE}') enabled = request.form.get('enabled') == 'on' if not _hash_ok(): - return redirect(VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() items = cfg.get('banned_ips', []) if idx < 0 or idx >= len(items): flash('Entry not found.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') before = copy.deepcopy(items[idx]) items[idx].update({'description': description, 'ip': ip, 'enabled': enabled}) @@ -128,7 +128,7 @@ def edit_banned_ip(): if errors: for msg in errors: flash(msg, 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') flash(save_config_with_snapshot( cfg, @@ -136,31 +136,31 @@ def edit_banned_ip(): before=before, after=copy.deepcopy(items[idx]), description=f'Edited banned IP: {ip}', ), 'success') - return redirect(VIEW) + return redirect(f'/{_PAGE}') -@bp.route('/action/delete_banned_ip', methods=['POST']) +@bp.route('/action/bannedips/table_delete', methods=['POST']) @require_level('administrator') -def delete_banned_ip(): +def table_delete(): idx = _row_index() if idx is None: flash('Invalid request.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') if not _hash_ok(): - return redirect(VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() items = cfg.get('banned_ips', []) if idx < 0 or idx >= len(items): flash('Entry not found.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') removed = items.pop(idx) errors = validate.validate_config(cfg) if errors: for msg in errors: flash(msg, 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') flash(save_config_with_snapshot( cfg, @@ -168,4 +168,4 @@ def delete_banned_ip(): before=removed, after=None, description=f'Deleted banned IP: {removed["ip"]}', ), 'success') - return redirect(VIEW) + return redirect(f'/{_PAGE}') diff --git a/docker/routlin-dash/app/pages/bannedips/content.json b/docker/routlin-dash/app/pages/bannedips/content.json index 7010acd..de07162 100644 --- a/docker/routlin-dash/app/pages/bannedips/content.json +++ b/docker/routlin-dash/app/pages/bannedips/content.json @@ -1,5 +1,4 @@ { - "id": "view_bannedips", "client_requirement": "client_is_viewer+", "items": [ { @@ -43,7 +42,7 @@ "row_actions": [ { "client_requirement": "client_is_administrator+", - "action": "/action/edit_banned_ip", + "action": "/action/bannedips/table_edit", "method": "inline_edit", "text": "Edit", "class": "btn-ghost btn-sm", @@ -65,7 +64,7 @@ }, { "client_requirement": "client_is_administrator+", - "action": "/action/delete_banned_ip", + "action": "/action/bannedips/table_delete", "method": "post", "text": "Delete", "class": "btn-danger btn-sm" @@ -80,7 +79,7 @@ "items": [ { "type": "form", - "action": "/action/add_banned_ip", + "action": "/action/bannedips/addip_add", "method": "post", "items": [ { @@ -103,7 +102,7 @@ "items": [ { "type": "button_primary", - "action": "/action/add_banned_ip", + "action": "/action/bannedips/addip_add", "method": "post", "text": "Add Banned IP" }, diff --git a/docker/routlin-dash/app/pages/ddns/action.py b/docker/routlin-dash/app/pages/ddns/action.py index b6b837d..0222cac 100644 --- a/docker/routlin-dash/app/pages/ddns/action.py +++ b/docker/routlin-dash/app/pages/ddns/action.py @@ -1,3 +1,4 @@ +from pathlib import Path import copy import os from flask import Blueprint, request, redirect, flash, send_file, abort @@ -6,32 +7,33 @@ from config_utils import load_config, verify_config_hash, save_config_with_snaps import sanitize import validation as validate -bp = Blueprint('ddns', __name__) +_PAGE = Path(__file__).parent.name + +bp = Blueprint(_PAGE, __name__) -VIEW = '/view/view_ddns' LOG_FILE = f'{CONFIGS_DIR}/ddns.log' -@bp.route('/action/ddns_cardaddaccount_add', methods=['POST']) +@bp.route('/action/ddns/addaccount_add', methods=['POST']) @require_level('administrator') -def ddns_cardaddaccount_add(): +def addaccount_add(): provider_type = sanitize.filtervalue(request.form.get('provider', ''), validate.VALID_DDNS_PROVIDERS) description = sanitize.description(request.form.get('description', '')) hostnames = sanitize.domainlist(request.form.get('hostnames', '').splitlines()) if not description: flash('Description is required.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') if not hostnames: flash('At least one hostname is required.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') if not provider_type: flash('Unknown provider type.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') if not verify_config_hash(request.form.get('config_hash', '')): flash('Configuration was modified by another session. Please refresh and try again.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') entry = { 'description': description, @@ -54,17 +56,17 @@ def ddns_cardaddaccount_add(): cmd='ddns update', queue=False, ), 'success') - return redirect(VIEW) + return redirect(f'/{_PAGE}') -@bp.route('/action/ddns_tableaccounts_rowedit', methods=['POST']) +@bp.route('/action/ddns/accounts_edit', methods=['POST']) @require_level('administrator') -def ddns_tableaccounts_rowedit(): +def accounts_edit(): try: row_index = int(request.form.get('row_index', -1)) except (TypeError, ValueError): flash('Invalid row index.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') provider_type = sanitize.filtervalue(request.form.get('provider', ''), validate.VALID_DDNS_PROVIDERS) description = sanitize.description(request.form.get('description', '')) @@ -73,17 +75,17 @@ def ddns_tableaccounts_rowedit(): if not provider_type: flash('Unknown provider type.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') if not verify_config_hash(request.form.get('config_hash', '')): flash('Configuration was modified by another session. Please refresh and try again.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() providers = cfg.setdefault('ddns', {}).setdefault('providers', []) if row_index < 0 or row_index >= len(providers): flash('Invalid provider index.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') before = copy.deepcopy(providers[row_index]) entry = { @@ -106,27 +108,27 @@ def ddns_tableaccounts_rowedit(): cmd='ddns update', queue=False, ), 'success') - return redirect(VIEW) + return redirect(f'/{_PAGE}') -@bp.route('/action/ddns_tableaccounts_rowdelete', methods=['POST']) +@bp.route('/action/ddns/accounts_delete', methods=['POST']) @require_level('administrator') -def ddns_tableaccounts_rowdelete(): +def accounts_delete(): try: row_index = int(request.form.get('row_index', -1)) except (TypeError, ValueError): flash('Invalid row index.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') if not verify_config_hash(request.form.get('config_hash', '')): flash('Configuration was modified by another session. Please refresh and try again.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() providers = cfg.setdefault('ddns', {}).setdefault('providers', []) if row_index < 0 or row_index >= len(providers): flash('Invalid provider index.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') before = copy.deepcopy(providers[row_index]) description = before.get('description', str(row_index)) @@ -138,12 +140,12 @@ def ddns_tableaccounts_rowdelete(): cmd='ddns update', queue=False, ), 'success') - return redirect(VIEW) + return redirect(f'/{_PAGE}') -@bp.route('/action/ddns_cardipcheckinterval_save', methods=['POST']) +@bp.route('/action/ddns/ipcheckinterval_save', methods=['POST']) @require_level('administrator') -def ddns_cardipcheckinterval_save(): +def ipcheckinterval_save(): raw = request.form.get('timer_interval', '').strip() try: mins = int(raw) @@ -151,12 +153,12 @@ def ddns_cardipcheckinterval_save(): raise ValueError except ValueError: flash('Interval must be a whole number of minutes >= 1.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') timer_interval = f'{mins}m' if not verify_config_hash(request.form.get('config_hash', '')): flash('Configuration was modified by another session. Please refresh and try again.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() before = copy.deepcopy(cfg.get('ddns', {}).get('general', {})) @@ -167,22 +169,22 @@ def ddns_cardipcheckinterval_save(): description='Updated DDNS check interval', cmd='core apply', ), 'success') - return redirect(VIEW) + return redirect(f'/{_PAGE}') -@bp.route('/action/ddns_cardipcheckservices_save', methods=['POST']) +@bp.route('/action/ddns/ipcheckservices_save', methods=['POST']) @require_level('administrator') -def ddns_cardipcheckservices_save(): +def ipcheckservices_save(): if not verify_config_hash(request.form.get('config_hash', '')): flash('Configuration was modified by another session. Please refresh and try again.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') http_services = [u.strip() for u in request.form.getlist('http_services') if u.strip()] dig_services = [u.strip() for u in request.form.getlist('dig_services') if u.strip()] if not http_services and not dig_services: flash('At least one IP check service is required.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() before = copy.deepcopy(cfg.get('ddns', {}).get('ip_check_services', [])) @@ -196,21 +198,21 @@ def ddns_cardipcheckservices_save(): cmd='ddns update', queue=False, ), 'success') - return redirect(VIEW) + return redirect(f'/{_PAGE}') -@bp.route('/action/ddns_cardlogging_save', methods=['POST']) +@bp.route('/action/ddns/logging_save', methods=['POST']) @require_level('administrator') -def ddns_cardlogging_save(): +def logging_save(): log_max_kb = validate.int_range(request.form.get('log_max_kb', '').strip(), 64, None) if log_max_kb is None: flash('Max Log Size must be a number >= 64.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') log_errors_only = 'log_errors_only' in request.form if not verify_config_hash(request.form.get('config_hash', '')): flash('Configuration was modified by another session. Please refresh and try again.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() before = copy.deepcopy(cfg.get('ddns', {}).get('general', {})) @@ -225,23 +227,23 @@ def ddns_cardlogging_save(): cmd='ddns update', queue=False, ), 'success') - return redirect(VIEW) + return redirect(f'/{_PAGE}') -@bp.route('/action/ddns_cardlogging_clear', methods=['POST']) +@bp.route('/action/ddns/logging_clear', methods=['POST']) @require_level('administrator') -def ddns_cardlogging_clear(): +def logging_clear(): try: open(LOG_FILE, 'w').close() flash('DDNS log cleared.', 'success') except Exception as ex: flash(f'Could not clear log: {ex}', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') -@bp.route('/action/ddns_cardlogging_download', methods=['GET']) +@bp.route('/action/ddns/logging_download', methods=['GET']) @require_level('administrator') -def ddns_cardlogging_download(): +def logging_download(): if not os.path.isfile(LOG_FILE): abort(404) return send_file(LOG_FILE, as_attachment=True, download_name='ddns.log', mimetype='text/plain') diff --git a/docker/routlin-dash/app/pages/ddns/content.json b/docker/routlin-dash/app/pages/ddns/content.json index b968463..38ff3d1 100644 --- a/docker/routlin-dash/app/pages/ddns/content.json +++ b/docker/routlin-dash/app/pages/ddns/content.json @@ -1,5 +1,4 @@ { - "id": "view_ddns", "client_requirement": "client_is_viewer+", "items": [ { @@ -29,7 +28,7 @@ "label": "IP Check Interval", "value": "%DDNS_TIMER_INTERVAL%", "sub": "%STAT_PUBLIC_IP_LAST_CHECKED%", - "edit_action": "/action/ddns_cardipcheckinterval_save", + "edit_action": "/action/ddns/ipcheckinterval_save", "edit_field": "timer_interval", "edit_input_type": "number", "edit_min": "1", @@ -54,7 +53,7 @@ "items": [ { "type": "form", - "action": "/action/ddns_cardipcheckservices_save", + "action": "/action/ddns/ipcheckservices_save", "method": "post", "items": [ { @@ -78,7 +77,7 @@ "items": [ { "type": "button_primary", - "action": "/action/ddns_cardipcheckservices_save", + "action": "/action/ddns/ipcheckservices_save", "method": "post", "text": "Save" }, @@ -125,7 +124,7 @@ "row_actions": [ { "client_requirement": "client_is_administrator+", - "action": "/action/ddns_tableaccounts_rowedit", + "action": "/action/ddns/accounts_edit", "method": "inline_edit", "text": "Edit", "class": "btn-ghost btn-sm", @@ -155,7 +154,7 @@ }, { "client_requirement": "client_is_administrator+", - "action": "/action/ddns_tableaccounts_rowdelete", + "action": "/action/ddns/accounts_delete", "method": "post", "text": "Delete", "class": "btn-danger btn-sm" @@ -169,7 +168,7 @@ "items": [ { "type": "form", - "action": "/action/ddns_cardaddaccount_add", + "action": "/action/ddns/addaccount_add", "method": "post", "items": [ { @@ -202,7 +201,7 @@ "items": [ { "type": "button_primary", - "action": "/action/ddns_cardaddaccount_add", + "action": "/action/ddns/addaccount_add", "method": "post", "text": "Add Provider" }, @@ -236,12 +235,12 @@ "items": [ { "type": "button_ghost", - "action": "/action/ddns_cardlogging_download", + "action": "/action/ddns/logging_download", "text": "Download Log" }, { "type": "button_danger", - "action": "/action/ddns_cardlogging_clear", + "action": "/action/ddns/logging_clear", "method": "post", "text": "Clear Log" } @@ -252,7 +251,7 @@ }, { "type": "form", - "action": "/action/ddns_cardlogging_save", + "action": "/action/ddns/logging_save", "method": "post", "items": [ { @@ -277,7 +276,7 @@ "items": [ { "type": "button_primary", - "action": "/action/ddns_cardlogging_save", + "action": "/action/ddns/logging_save", "method": "post", "text": "Save" }, diff --git a/docker/routlin-dash/app/pages/dhcp/action.py b/docker/routlin-dash/app/pages/dhcp/action.py index d3c9e26..2c1335b 100644 --- a/docker/routlin-dash/app/pages/dhcp/action.py +++ b/docker/routlin-dash/app/pages/dhcp/action.py @@ -1,3 +1,4 @@ +from pathlib import Path import copy import ipaddress @@ -7,10 +8,9 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h import sanitize import validation as validate -bp = Blueprint('dhcp', __name__) - -VIEW = '/view/view_dhcp' +_PAGE = Path(__file__).parent.name +bp = Blueprint(_PAGE, __name__) def _row_index(): try: @@ -66,9 +66,9 @@ def _check_ip_conflicts(ip, vlan): return None -@bp.route('/action/add_dhcp_reservation', methods=['POST']) +@bp.route('/action/dhcp/addreservation_add', methods=['POST']) @require_level('administrator') -def add_dhcp_reservation(): +def addreservation_add(): vlan_name = sanitize.name(request.form.get('vlan_name', '')) description = sanitize.text(request.form.get('description', '')) hostname = validate.domainname(request.form.get('hostname', '')) @@ -77,27 +77,27 @@ def add_dhcp_reservation(): radius_client = 'radius_client' in request.form if ip is None: - return redirect(VIEW) + return redirect(f'/{_PAGE}') if not vlan_name: flash('The configuration has not been saved because a VLAN is required.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') if not mac: flash('The configuration has not been saved because a MAC address is required.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') if not _hash_ok(): - return redirect(VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() vlans = cfg.get('vlans', []) vlan = next((v for v in vlans if v.get('name') == vlan_name), None) if vlan is None: flash(f'The configuration has not been saved because VLAN "{vlan_name}" was not found.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') conflict = _check_ip_conflicts(ip, vlan) if conflict: flash(f'The configuration has not been saved because {conflict}', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') entry = { 'description': description, @@ -112,7 +112,7 @@ def add_dhcp_reservation(): if errors: for msg in errors: flash(msg, 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') flash(save_config_with_snapshot( cfg, @@ -120,25 +120,25 @@ def add_dhcp_reservation(): before=None, after=entry, description=f'Added DHCP reservation: {hostname or mac} ({ip or "dynamic"})', ), 'success') - return redirect(VIEW) + return redirect(f'/{_PAGE}') -@bp.route('/action/toggle_dhcp_reservation', methods=['POST']) +@bp.route('/action/dhcp/reservations_toggle', methods=['POST']) @require_level('administrator') -def toggle_dhcp_reservation(): +def reservations_toggle(): idx = _row_index() if idx is None: flash('Invalid request.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') if not _hash_ok(): - return redirect(VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() vlans = cfg.get('vlans', []) vi, ri = _flat_index_to_vlan_res(vlans, idx) if vi is None: flash('Entry not found.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') res = vlans[vi]['reservations'][ri] old_enabled = res.get('enabled', True) @@ -147,7 +147,7 @@ def toggle_dhcp_reservation(): if errors: for msg in errors: flash(msg, 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') vlan_name = vlans[vi]['name'] action = 'Enabled' if not old_enabled else 'Disabled' @@ -157,16 +157,16 @@ def toggle_dhcp_reservation(): before={'enabled': old_enabled}, after={'enabled': not old_enabled}, description=f'{action} DHCP reservation: {res.get("hostname") or res["mac"]}', ), 'success') - return redirect(VIEW) + return redirect(f'/{_PAGE}') -@bp.route('/action/edit_dhcp_reservation', methods=['POST']) +@bp.route('/action/dhcp/reservations_edit', methods=['POST']) @require_level('administrator') -def edit_dhcp_reservation(): +def reservations_edit(): idx = _row_index() if idx is None: flash('Invalid request.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') description = sanitize.text(request.form.get('description', '')) hostname = validate.domainname(request.form.get('hostname', '')) @@ -175,24 +175,24 @@ def edit_dhcp_reservation(): radius_client = 'radius_client' in request.form if ip is None: - return redirect(VIEW) + return redirect(f'/{_PAGE}') if not mac: flash('The configuration has not been saved because a MAC address is required.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') if not _hash_ok(): - return redirect(VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() vlans = cfg.get('vlans', []) vi, ri = _flat_index_to_vlan_res(vlans, idx) if vi is None: flash('Entry not found.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') conflict = _check_ip_conflicts(ip, vlans[vi]) if conflict: flash(f'The configuration has not been saved because {conflict}', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') res = vlans[vi]['reservations'][ri] before = copy.deepcopy(res) @@ -208,7 +208,7 @@ def edit_dhcp_reservation(): if errors: for msg in errors: flash(msg, 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') vlan_name = vlans[vi]['name'] flash(save_config_with_snapshot( @@ -217,25 +217,25 @@ def edit_dhcp_reservation(): before=before, after=copy.deepcopy(res), description=f'Edited DHCP reservation: {hostname or mac} ({ip or "dynamic"})', ), 'success') - return redirect(VIEW) + return redirect(f'/{_PAGE}') -@bp.route('/action/delete_dhcp_reservation', methods=['POST']) +@bp.route('/action/dhcp/reservations_delete', methods=['POST']) @require_level('administrator') -def delete_dhcp_reservation(): +def reservations_delete(): idx = _row_index() if idx is None: flash('Invalid request.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') if not _hash_ok(): - return redirect(VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() vlans = cfg.get('vlans', []) vi, ri = _flat_index_to_vlan_res(vlans, idx) if vi is None: flash('Entry not found.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') vlan_name = vlans[vi]['name'] removed = vlans[vi]['reservations'].pop(ri) @@ -243,7 +243,7 @@ def delete_dhcp_reservation(): if errors: for msg in errors: flash(msg, 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') flash(save_config_with_snapshot( cfg, @@ -251,4 +251,4 @@ def delete_dhcp_reservation(): before=removed, after=None, description=f'Deleted DHCP reservation: {removed.get("hostname") or removed["mac"]}', ), 'success') - return redirect(VIEW) + return redirect(f'/{_PAGE}') diff --git a/docker/routlin-dash/app/pages/dhcp/content.json b/docker/routlin-dash/app/pages/dhcp/content.json index 257a37e..3d6625c 100644 --- a/docker/routlin-dash/app/pages/dhcp/content.json +++ b/docker/routlin-dash/app/pages/dhcp/content.json @@ -1,5 +1,4 @@ { - "id": "view_dhcp", "client_requirement": "client_is_viewer+", "items": [ { @@ -97,7 +96,7 @@ "row_actions": [ { "client_requirement": "client_is_administrator+", - "action": "/action/edit_dhcp_reservation", + "action": "/action/dhcp/reservations_edit", "method": "inline_edit", "text": "Edit", "class": "btn-ghost btn-sm", @@ -134,7 +133,7 @@ }, { "client_requirement": "client_is_administrator+", - "action": "/action/delete_dhcp_reservation", + "action": "/action/dhcp/reservations_delete", "method": "post", "text": "Delete", "class": "btn-danger btn-sm" @@ -149,7 +148,7 @@ "items": [ { "type": "form", - "action": "/action/add_dhcp_reservation", + "action": "/action/dhcp/addreservation_add", "method": "post", "items": [ { @@ -203,7 +202,7 @@ "items": [ { "type": "button_primary", - "action": "/action/add_dhcp_reservation", + "action": "/action/dhcp/addreservation_add", "method": "post", "text": "Add" }, diff --git a/docker/routlin-dash/app/pages/dnsblocking/action.py b/docker/routlin-dash/app/pages/dnsblocking/action.py index a087c62..c661c93 100644 --- a/docker/routlin-dash/app/pages/dnsblocking/action.py +++ b/docker/routlin-dash/app/pages/dnsblocking/action.py @@ -1,3 +1,4 @@ +from pathlib import Path import copy import re from flask import Blueprint, request, redirect, flash @@ -6,9 +7,9 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h import sanitize import validation as validate -bp = Blueprint('dnsblocking', __name__) +_PAGE = Path(__file__).parent.name -VIEW = '/view/view_dnsblocking' +bp = Blueprint(_PAGE, __name__) _VALID_FORMATS_STR = ', '.join(sorted(validate.VALID_BLOCKLIST_FORMATS)) @@ -52,22 +53,22 @@ def _parse_fields(): return {'name': name, 'description': description, 'format': fmt, 'url': url}, None -@bp.route('/action/dnsblocking_tableblocklists_rowdelete', methods=['POST']) +@bp.route('/action/dnsblocking/blocklists_delete', methods=['POST']) @require_level('administrator') -def dnsblocking_tableblocklists_rowdelete(): +def blocklists_delete(): idx = _row_index() if idx is None: flash('Invalid request.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') if not _hash_ok(): - return redirect(VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() items = cfg.get('dns_blocking', {}).get('blocklists', []) if idx < 0 or idx >= len(items): flash('Entry not found.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') before = copy.deepcopy(items[idx]) name = before.get('name', str(idx)) @@ -76,36 +77,36 @@ def dnsblocking_tableblocklists_rowdelete(): if errors: for msg in errors: flash(msg, 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') flash(save_config_with_snapshot( cfg, path='dns_blocking', key=name, operation='delete', before=before, after=None, description=f'Deleted blocklist: {name}', queue=False, ), 'success') - return redirect(VIEW) + return redirect(f'/{_PAGE}') -@bp.route('/action/dnsblocking_tableblocklists_rowedit', methods=['POST']) +@bp.route('/action/dnsblocking/blocklists_edit', methods=['POST']) @require_level('administrator') -def dnsblocking_tableblocklists_rowedit(): +def blocklists_edit(): idx = _row_index() if idx is None: flash('Invalid request.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') fields, err = _parse_fields() if err: - return redirect(VIEW) + return redirect(f'/{_PAGE}') if not _hash_ok(): - return redirect(VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() items = cfg.get('dns_blocking', {}).get('blocklists', []) if idx < 0 or idx >= len(items): flash('Entry not found.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') before = copy.deepcopy(items[idx]) items[idx].update({ @@ -118,32 +119,32 @@ def dnsblocking_tableblocklists_rowedit(): if errors: for msg in errors: flash(msg, 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') flash(save_config_with_snapshot( cfg, path='dns_blocking', key=fields['name'], operation='edit', before=before, after=copy.deepcopy(items[idx]), description=f'Edited blocklist: {fields["name"]}', queue=False, ), 'success') - return redirect(VIEW) + return redirect(f'/{_PAGE}') -@bp.route('/action/dnsblocking_cardaddblocklist_add', methods=['POST']) +@bp.route('/action/dnsblocking/addblocklist_add', methods=['POST']) @require_level('administrator') -def dnsblocking_cardaddblocklist_add(): +def addblocklist_add(): fields, err = _parse_fields() if err: - return redirect(VIEW) + return redirect(f'/{_PAGE}') if not _hash_ok(): - return redirect(VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() blocklists = cfg.setdefault('dns_blocking', {}).setdefault('blocklists', []) if any(b.get('name', '').lower() == fields['name'].lower() for b in blocklists): flash('The configuration has not been saved because a blocklist with that name already exists.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') entry = { 'name': fields['name'], @@ -157,28 +158,28 @@ def dnsblocking_cardaddblocklist_add(): if errors: for msg in errors: flash(msg, 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') flash(save_config_with_snapshot( cfg, path='dns_blocking', key=fields['name'], operation='add', before=None, after=copy.deepcopy(entry), description=f'Added blocklist: {fields["name"]}', queue=False, ), 'success') - return redirect(VIEW) + return redirect(f'/{_PAGE}') -@bp.route('/action/dnsblocking_cardblocklistrefresh_save', methods=['POST']) +@bp.route('/action/dnsblocking/blocklistrefresh_save', methods=['POST']) @require_level('administrator') -def dnsblocking_cardblocklistrefresh_save(): +def blocklistrefresh_save(): daily_execute_time = validate.time_24h(sanitize.time_24h(request.form.get('daily_execute_time_24hr_local', ''))) if not daily_execute_time: flash('Daily Refresh Time must be a valid 24-hour time (e.g. 02:30).', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') if not verify_config_hash(request.form.get('config_hash', '')): flash('Configuration was modified by another session. Please refresh and try again.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() before = copy.deepcopy(cfg.get('dns_blocking', {}).get('general', {})) @@ -189,30 +190,30 @@ def dnsblocking_cardblocklistrefresh_save(): description='Updated daily blocklist refresh time', cmd='core apply', ), 'success') - return redirect(VIEW) + return redirect(f'/{_PAGE}') -@bp.route('/action/dnsblocking_cardblocklistrefresh_refreshnow', methods=['POST']) +@bp.route('/action/dnsblocking/blocklistrefresh_refresh', methods=['POST']) @require_level('administrator') -def dnsblocking_cardblocklistrefresh_refreshnow(): +def blocklistrefresh_refresh(): flash(queued_msg('core update-blocklists', action_label='Blocklist refresh queued'), 'success') - return redirect(VIEW) + return redirect(f'/{_PAGE}') -@bp.route('/action/dnsblocking_cardlogging_save', methods=['POST']) +@bp.route('/action/dnsblocking/logging_save', methods=['POST']) @require_level('administrator') -def dnsblocking_cardlogging_save(): +def logging_save(): log_max_kb_raw = request.form.get('log_max_kb', '').strip() log_errors_only = 'log_errors_only' in request.form log_max_kb = validate.int_range(log_max_kb_raw, 64, None) if log_max_kb is None: flash('Max Log Size must be a number >= 64.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') if not verify_config_hash(request.form.get('config_hash', '')): flash('Configuration was modified by another session. Please refresh and try again.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() before = copy.deepcopy(cfg.get('dns_blocking', {}).get('general', {})) @@ -224,11 +225,11 @@ def dnsblocking_cardlogging_save(): if errors: for msg in errors: flash(msg, 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') flash(save_config_with_snapshot( cfg, path='dns_blocking', key='general', operation='edit', before=before, after=copy.deepcopy(cfg['dns_blocking']['general']), description='Updated DNS blocking log settings', queue=False, ), 'success') - return redirect(VIEW) + return redirect(f'/{_PAGE}') diff --git a/docker/routlin-dash/app/pages/dnsblocking/content.json b/docker/routlin-dash/app/pages/dnsblocking/content.json index dc78231..e736d4a 100644 --- a/docker/routlin-dash/app/pages/dnsblocking/content.json +++ b/docker/routlin-dash/app/pages/dnsblocking/content.json @@ -1,5 +1,4 @@ { - "id": "view_dnsblocking", "client_requirement": "client_is_viewer+", "items": [ { @@ -42,7 +41,7 @@ "row_actions": [ { "client_requirement": "client_is_administrator+", - "action": "/action/dnsblocking_tableblocklists_rowedit", + "action": "/action/dnsblocking/blocklists_edit", "method": "inline_edit", "text": "Edit", "class": "btn-ghost btn-sm", @@ -70,7 +69,7 @@ }, { "client_requirement": "client_is_administrator+", - "action": "/action/dnsblocking_tableblocklists_rowdelete", + "action": "/action/dnsblocking/blocklists_delete", "method": "post", "text": "Delete", "class": "btn-danger btn-sm" @@ -85,7 +84,7 @@ "items": [ { "type": "form", - "action": "/action/dnsblocking_cardaddblocklist_add", + "action": "/action/dnsblocking/addblocklist_add", "method": "post", "items": [ { @@ -123,7 +122,7 @@ "items": [ { "type": "button_primary", - "action": "/action/dnsblocking_cardaddblocklist_add", + "action": "/action/dnsblocking/addblocklist_add", "method": "post", "text": "Add Blocklist" }, @@ -154,7 +153,7 @@ "items": [ { "type": "button_secondary", - "action": "/action/dnsblocking_cardblocklistrefresh_refreshnow", + "action": "/action/dnsblocking/blocklistrefresh_refresh", "method": "post", "text": "Refresh All Now" } @@ -165,7 +164,7 @@ }, { "type": "form", - "action": "/action/dnsblocking_cardblocklistrefresh_save", + "action": "/action/dnsblocking/blocklistrefresh_save", "method": "post", "items": [ { @@ -183,7 +182,7 @@ "items": [ { "type": "button_primary", - "action": "/action/dnsblocking_cardblocklistrefresh_save", + "action": "/action/dnsblocking/blocklistrefresh_save", "method": "post", "text": "Save" }, @@ -204,7 +203,7 @@ "items": [ { "type": "form", - "action": "/action/dnsblocking_cardlogging_save", + "action": "/action/dnsblocking/logging_save", "method": "post", "items": [ { @@ -229,7 +228,7 @@ "items": [ { "type": "button_primary", - "action": "/action/dnsblocking_cardlogging_save", + "action": "/action/dnsblocking/logging_save", "method": "post", "text": "Save" }, diff --git a/docker/routlin-dash/app/pages/dnsserver/action.py b/docker/routlin-dash/app/pages/dnsserver/action.py index f0bcd4d..5d965f1 100644 --- a/docker/routlin-dash/app/pages/dnsserver/action.py +++ b/docker/routlin-dash/app/pages/dnsserver/action.py @@ -1,3 +1,4 @@ +from pathlib import Path import copy from flask import Blueprint, request, redirect, flash from auth import require_level @@ -5,33 +6,32 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h import sanitize import validation as validate -bp = Blueprint('dnsserver', __name__) +_PAGE = Path(__file__).parent.name -_VIEW = '/view/view_dnsserver' +bp = Blueprint(_PAGE, __name__) - -@bp.route('/action/dnsserver_cardupstreamdns_save', methods=['POST']) +@bp.route('/action/dnsserver/upstreamdns_save', methods=['POST']) @require_level('administrator') -def dnsserver_cardupstreamdns_save(): +def upstreamdns_save(): strict_order = 'strict_order' in request.form submitted = request.form.getlist('upstream_servers') for s in submitted: if not s.strip(): flash('Remove blank server entries before saving.', 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') upstream_servers = [] for s in submitted: clean = sanitize.ip(s.strip()) if not clean: flash(f"'{s.strip()}' is not a valid IP address.", 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') upstream_servers.append(clean) if not verify_config_hash(request.form.get('config_hash', '')): flash('Configuration was modified by another session. Please refresh and try again.', 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() before = copy.deepcopy(cfg.get('upstream_dns', {})) @@ -39,7 +39,7 @@ def dnsserver_cardupstreamdns_save(): if (strict_order == bool(current.get('strict_order', False)) and upstream_servers == current.get('upstream_servers', [])): flash('No changes detected.', 'info') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') cfg.setdefault('upstream_dns', {}).update({ 'strict_order': strict_order, @@ -49,45 +49,45 @@ def dnsserver_cardupstreamdns_save(): if errors: for msg in errors: flash(msg, 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') flash(save_config_with_snapshot( cfg, path='upstream_dns', key='global', operation='edit', before=before, after=copy.deepcopy(cfg['upstream_dns']), description='Updated upstream DNS servers', cmd='core apply', ), 'success') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') -@bp.route('/action/dnsserver_carddnsforwarding_save', methods=['POST']) +@bp.route('/action/dnsserver/dnsforwarding_save', methods=['POST']) @require_level('administrator') -def dnsserver_carddnsforwarding_save(): +def dnsforwarding_save(): cache_size = validate.int_range(request.form.get('cache_size', '').strip(), 0, None) if cache_size is None: flash('Cache Size must be a non-negative integer.', 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') if not verify_config_hash(request.form.get('config_hash', '')): flash('Configuration was modified by another session. Please refresh and try again.', 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() before = copy.deepcopy(cfg.get('upstream_dns', {})) current = cfg.get('upstream_dns', {}) if cache_size == int(current.get('cache_size', 0)): flash('No changes detected.', 'info') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') cfg.setdefault('upstream_dns', {})['cache_size'] = cache_size errors = validate.validate_config(cfg) if errors: for msg in errors: flash(msg, 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') flash(save_config_with_snapshot( cfg, path='upstream_dns', key='global', operation='edit', before=before, after=copy.deepcopy(cfg['upstream_dns']), description='Updated DNS cache size', cmd='core apply', ), 'success') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') diff --git a/docker/routlin-dash/app/pages/dnsserver/content.json b/docker/routlin-dash/app/pages/dnsserver/content.json index b4e903a..474cb20 100644 --- a/docker/routlin-dash/app/pages/dnsserver/content.json +++ b/docker/routlin-dash/app/pages/dnsserver/content.json @@ -1,5 +1,4 @@ { - "id": "view_dnsserver", "client_requirement": "client_is_administrator+", "items": [ { @@ -22,7 +21,7 @@ "items": [ { "type": "form", - "action": "/action/dnsserver_cardupstreamdns_save", + "action": "/action/dnsserver/upstreamdns_save", "method": "post", "items": [ { @@ -48,7 +47,7 @@ "items": [ { "type": "button_primary", - "action": "/action/dnsserver_cardupstreamdns_save", + "action": "/action/dnsserver/upstreamdns_save", "method": "post", "text": "Save" }, @@ -69,7 +68,7 @@ "items": [ { "type": "form", - "action": "/action/dnsserver_carddnsforwarding_save", + "action": "/action/dnsserver/dnsforwarding_save", "method": "post", "items": [ { @@ -86,7 +85,7 @@ "items": [ { "type": "button_primary", - "action": "/action/dnsserver_carddnsforwarding_save", + "action": "/action/dnsserver/dnsforwarding_save", "method": "post", "text": "Save" }, diff --git a/docker/routlin-dash/app/pages/hostoverrides/action.py b/docker/routlin-dash/app/pages/hostoverrides/action.py index 5f18b35..e67c4df 100644 --- a/docker/routlin-dash/app/pages/hostoverrides/action.py +++ b/docker/routlin-dash/app/pages/hostoverrides/action.py @@ -1,3 +1,4 @@ +from pathlib import Path import copy import ipaddress @@ -7,10 +8,9 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h import sanitize import validation as validate -bp = Blueprint('hostoverrides', __name__) - -VIEW = '/view/view_hostoverrides' +_PAGE = Path(__file__).parent.name +bp = Blueprint(_PAGE, __name__) def _vlan_networks(cfg): nets = [] @@ -48,23 +48,23 @@ def _hash_ok(): return True -@bp.route('/action/add_host_override', methods=['POST']) +@bp.route('/action/hostoverrides/addoverride_add', methods=['POST']) @require_level('administrator') -def add_host_override(): +def addoverride_add(): description = sanitize.text(request.form.get('description', '')) host = validate.domainname(request.form.get('host', '')) ip = sanitize.ip(request.form.get('ip', '')) if not host or not ip: flash('Hostname and IP address are required.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') if not _hash_ok(): - return redirect(VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() if not _ip_in_vlan(ip, cfg): flash('IP address does not fall within any configured VLAN subnet.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') entry = {'description': description, 'host': host, 'ip': ip, 'enabled': True} cfg.setdefault('host_overrides', []).append(entry) @@ -72,7 +72,7 @@ def add_host_override(): if errors: for msg in errors: flash(msg, 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') flash(save_config_with_snapshot( cfg, @@ -80,24 +80,24 @@ def add_host_override(): before=None, after=entry, description=f'Added host override: {host} → {ip}', ), 'success') - return redirect(VIEW) + return redirect(f'/{_PAGE}') -@bp.route('/action/toggle_host_override', methods=['POST']) +@bp.route('/action/hostoverrides/table_toggle', methods=['POST']) @require_level('administrator') -def toggle_host_override(): +def table_toggle(): idx = _row_index() if idx is None: flash('Invalid request.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') if not _hash_ok(): - return redirect(VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() items = cfg.get('host_overrides', []) if idx < 0 or idx >= len(items): flash('Entry not found.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') old_enabled = items[idx].get('enabled', True) items[idx]['enabled'] = not old_enabled @@ -105,7 +105,7 @@ def toggle_host_override(): if errors: for msg in errors: flash(msg, 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') action = 'Enabled' if not old_enabled else 'Disabled' flash(save_config_with_snapshot( @@ -114,16 +114,16 @@ def toggle_host_override(): before={'enabled': old_enabled}, after={'enabled': not old_enabled}, description=f'{action} host override: {items[idx]["host"]}', ), 'success') - return redirect(VIEW) + return redirect(f'/{_PAGE}') -@bp.route('/action/edit_host_override', methods=['POST']) +@bp.route('/action/hostoverrides/table_edit', methods=['POST']) @require_level('administrator') -def edit_host_override(): +def table_edit(): idx = _row_index() if idx is None: flash('Invalid request.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') description = sanitize.text(request.form.get('description', '')) host = validate.domainname(request.form.get('host', '')) @@ -132,19 +132,19 @@ def edit_host_override(): if not host or not ip: flash('Hostname and IP address are required.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') if not _hash_ok(): - return redirect(VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() if not _ip_in_vlan(ip, cfg): flash('IP address does not fall within any configured VLAN subnet.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') items = cfg.get('host_overrides', []) if idx < 0 or idx >= len(items): flash('Entry not found.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') before = copy.deepcopy(items[idx]) items[idx].update({'description': description, 'host': host, 'ip': ip, 'enabled': enabled}) @@ -152,7 +152,7 @@ def edit_host_override(): if errors: for msg in errors: flash(msg, 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') flash(save_config_with_snapshot( cfg, @@ -160,31 +160,31 @@ def edit_host_override(): before=before, after=copy.deepcopy(items[idx]), description=f'Edited host override: {host} → {ip}', ), 'success') - return redirect(VIEW) + return redirect(f'/{_PAGE}') -@bp.route('/action/delete_host_override', methods=['POST']) +@bp.route('/action/hostoverrides/table_delete', methods=['POST']) @require_level('administrator') -def delete_host_override(): +def table_delete(): idx = _row_index() if idx is None: flash('Invalid request.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') if not _hash_ok(): - return redirect(VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() items = cfg.get('host_overrides', []) if idx < 0 or idx >= len(items): flash('Entry not found.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') removed = items.pop(idx) errors = validate.validate_config(cfg) if errors: for msg in errors: flash(msg, 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') flash(save_config_with_snapshot( cfg, @@ -192,4 +192,4 @@ def delete_host_override(): before=removed, after=None, description=f'Deleted host override: {removed["host"]}', ), 'success') - return redirect(VIEW) + return redirect(f'/{_PAGE}') diff --git a/docker/routlin-dash/app/pages/hostoverrides/content.json b/docker/routlin-dash/app/pages/hostoverrides/content.json index cbe2818..e789387 100644 --- a/docker/routlin-dash/app/pages/hostoverrides/content.json +++ b/docker/routlin-dash/app/pages/hostoverrides/content.json @@ -1,5 +1,4 @@ { - "id": "view_hostoverrides", "client_requirement": "client_is_viewer+", "items": [ { @@ -43,7 +42,7 @@ "row_actions": [ { "client_requirement": "client_is_administrator+", - "action": "/action/edit_host_override", + "action": "/action/hostoverrides/table_edit", "method": "inline_edit", "text": "Edit", "class": "btn-ghost btn-sm", @@ -71,7 +70,7 @@ }, { "client_requirement": "client_is_administrator+", - "action": "/action/delete_host_override", + "action": "/action/hostoverrides/table_delete", "method": "post", "text": "Delete", "class": "btn-danger btn-sm" @@ -86,7 +85,7 @@ "items": [ { "type": "form", - "action": "/action/add_host_override", + "action": "/action/hostoverrides/addoverride_add", "method": "post", "items": [ { @@ -117,7 +116,7 @@ "items": [ { "type": "button_primary", - "action": "/action/add_host_override", + "action": "/action/hostoverrides/addoverride_add", "method": "post", "text": "Add Host Override" }, diff --git a/docker/routlin-dash/app/pages/intervlan/action.py b/docker/routlin-dash/app/pages/intervlan/action.py index 8f0abca..8e1d057 100644 --- a/docker/routlin-dash/app/pages/intervlan/action.py +++ b/docker/routlin-dash/app/pages/intervlan/action.py @@ -1,3 +1,4 @@ +from pathlib import Path import copy from flask import Blueprint, request, redirect, flash @@ -6,9 +7,9 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h import sanitize import validation as validate -bp = Blueprint('intervlan', __name__) +_PAGE = Path(__file__).parent.name -VIEW = '/view/view_intervlan' +bp = Blueprint(_PAGE, __name__) _VALID_PROTOS_STR = ', '.join(sorted(validate.VALID_PROTOCOLS)) @@ -77,14 +78,14 @@ def _entry_key(entry): return f'{entry["protocol"]}:{entry["src_ip_or_subnet"]}→{entry["dst_ip_or_subnet"]}{port}' -@bp.route('/action/add_inter_vlan', methods=['POST']) +@bp.route('/action/intervlan/addexception_add', methods=['POST']) @require_level('administrator') -def add_inter_vlan(): +def addexception_add(): entry, err = _parse_entry() if err: - return redirect(VIEW) + return redirect(f'/{_PAGE}') if not _hash_ok(): - return redirect(VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() cfg.setdefault('inter_vlan_exceptions', []).append(entry) @@ -92,7 +93,7 @@ def add_inter_vlan(): if errors: for msg in errors: flash(msg, 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') key = _entry_key(entry) flash(save_config_with_snapshot( @@ -101,24 +102,24 @@ def add_inter_vlan(): before=None, after=entry, description=f'Added inter-VLAN rule: {key}', ), 'success') - return redirect(VIEW) + return redirect(f'/{_PAGE}') -@bp.route('/action/toggle_inter_vlan', methods=['POST']) +@bp.route('/action/intervlan/table_toggle', methods=['POST']) @require_level('administrator') -def toggle_inter_vlan(): +def table_toggle(): idx = _row_index() if idx is None: flash('Invalid request.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') if not _hash_ok(): - return redirect(VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() items = cfg.get('inter_vlan_exceptions', []) if idx < 0 or idx >= len(items): flash('Entry not found.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') old_enabled = items[idx].get('enabled', True) items[idx]['enabled'] = not old_enabled @@ -126,7 +127,7 @@ def toggle_inter_vlan(): if errors: for msg in errors: flash(msg, 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') key = _entry_key(items[idx]) action = 'Enabled' if not old_enabled else 'Disabled' @@ -136,28 +137,28 @@ def toggle_inter_vlan(): before={'enabled': old_enabled}, after={'enabled': not old_enabled}, description=f'{action} inter-VLAN rule: {key}', ), 'success') - return redirect(VIEW) + return redirect(f'/{_PAGE}') -@bp.route('/action/edit_inter_vlan', methods=['POST']) +@bp.route('/action/intervlan/table_edit', methods=['POST']) @require_level('administrator') -def edit_inter_vlan(): +def table_edit(): idx = _row_index() if idx is None: flash('Invalid request.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') entry, err = _parse_entry() if err: - return redirect(VIEW) + return redirect(f'/{_PAGE}') if not _hash_ok(): - return redirect(VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() items = cfg.get('inter_vlan_exceptions', []) if idx < 0 or idx >= len(items): flash('Entry not found.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') before = copy.deepcopy(items[idx]) items[idx] = entry @@ -166,7 +167,7 @@ def edit_inter_vlan(): if errors: for msg in errors: flash(msg, 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') key = _entry_key(entry) flash(save_config_with_snapshot( @@ -175,31 +176,31 @@ def edit_inter_vlan(): before=before, after=copy.deepcopy(items[idx]), description=f'Edited inter-VLAN rule: {key}', ), 'success') - return redirect(VIEW) + return redirect(f'/{_PAGE}') -@bp.route('/action/delete_inter_vlan', methods=['POST']) +@bp.route('/action/intervlan/table_delete', methods=['POST']) @require_level('administrator') -def delete_inter_vlan(): +def table_delete(): idx = _row_index() if idx is None: flash('Invalid request.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') if not _hash_ok(): - return redirect(VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() items = cfg.get('inter_vlan_exceptions', []) if idx < 0 or idx >= len(items): flash('Entry not found.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') removed = items.pop(idx) errors = validate.validate_config(cfg) if errors: for msg in errors: flash(msg, 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') key = _entry_key(removed) flash(save_config_with_snapshot( @@ -208,4 +209,4 @@ def delete_inter_vlan(): before=removed, after=None, description=f'Deleted inter-VLAN rule: {key}', ), 'success') - return redirect(VIEW) + return redirect(f'/{_PAGE}') diff --git a/docker/routlin-dash/app/pages/intervlan/content.json b/docker/routlin-dash/app/pages/intervlan/content.json index f2642b0..e47e1aa 100644 --- a/docker/routlin-dash/app/pages/intervlan/content.json +++ b/docker/routlin-dash/app/pages/intervlan/content.json @@ -1,5 +1,4 @@ { - "id": "view_intervlan", "client_requirement": "client_is_viewer+", "items": [ { @@ -53,7 +52,7 @@ "row_actions": [ { "client_requirement": "client_is_administrator+", - "action": "/action/edit_inter_vlan", + "action": "/action/intervlan/table_edit", "method": "inline_edit", "text": "Edit", "class": "btn-ghost btn-sm", @@ -88,7 +87,7 @@ }, { "client_requirement": "client_is_administrator+", - "action": "/action/delete_inter_vlan", + "action": "/action/intervlan/table_delete", "method": "post", "text": "Delete", "class": "btn-danger btn-sm" @@ -103,7 +102,7 @@ "items": [ { "type": "form", - "action": "/action/add_inter_vlan", + "action": "/action/intervlan/addexception_add", "method": "post", "items": [ { @@ -149,7 +148,7 @@ "items": [ { "type": "button_primary", - "action": "/action/add_inter_vlan", + "action": "/action/intervlan/addexception_add", "method": "post", "text": "Add Exception" }, diff --git a/docker/routlin-dash/app/pages/mdns/action.py b/docker/routlin-dash/app/pages/mdns/action.py index 6b51f02..38887f9 100644 --- a/docker/routlin-dash/app/pages/mdns/action.py +++ b/docker/routlin-dash/app/pages/mdns/action.py @@ -1,3 +1,4 @@ +from pathlib import Path import copy from flask import Blueprint, request, redirect, flash @@ -6,17 +7,19 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h import sanitize import validation as validate -bp = Blueprint('mdns', __name__) +_PAGE = Path(__file__).parent.name + +bp = Blueprint(_PAGE, __name__) -@bp.route('/action/apply_mdns', methods=['POST']) +@bp.route('/action/mdns/settings_apply', methods=['POST']) @require_level('administrator') -def apply_mdns(): +def settings_apply(): mdns_enabled = 'mdns_enabled' in request.form if not verify_config_hash(request.form.get('config_hash', '')): flash('Configuration was modified by another session. Please refresh and try again.', 'error') - return redirect('/view/view_mdns') + return redirect(f'/{_PAGE}') cfg = load_config() mdns_reflect_vlans = sanitize.filterlist( @@ -33,7 +36,7 @@ def apply_mdns(): if errors: for msg in errors: flash(msg, 'error') - return redirect('/view/view_mdns') + return redirect(f'/{_PAGE}') flash(save_config_with_snapshot( cfg, @@ -41,4 +44,4 @@ def apply_mdns(): before=before or None, after=copy.deepcopy(cfg['mdns_reflection']), description='Updated mDNS reflection settings', ), 'success') - return redirect('/view/view_mdns') + return redirect(f'/{_PAGE}') diff --git a/docker/routlin-dash/app/pages/networklayout/action.py b/docker/routlin-dash/app/pages/networklayout/action.py index 260224a..1d4e698 100644 --- a/docker/routlin-dash/app/pages/networklayout/action.py +++ b/docker/routlin-dash/app/pages/networklayout/action.py @@ -1,3 +1,4 @@ +from pathlib import Path import copy import ipaddress @@ -7,9 +8,9 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h import sanitize import validation as validate -bp = Blueprint('networklayout', __name__) +_PAGE = Path(__file__).parent.name -VIEW = '/view/view_networklayout' +bp = Blueprint(_PAGE, __name__) _VLAN_FIELDS = ['name', 'vlan_id', 'is_vpn', 'subnet', 'subnet_mask', 'dnsmasq_log_queries', 'radius_default', 'mdns_reflection', 'use_blocklists'] @@ -29,9 +30,9 @@ def _hash_ok(): return True -@bp.route('/action/networklayout_cardaddvlan_addvlan', methods=['POST']) +@bp.route('/action/networklayout/addvlan_add', methods=['POST']) @require_level('administrator') -def networklayout_cardaddvlan_addvlan(): +def addvlan_add(): name = sanitize.name(request.form.get('name', '')) vlan_id = sanitize.vlan_id(request.form.get('vlan_id', '')) is_vpn = 'is_vpn' in request.form @@ -47,29 +48,29 @@ def networklayout_cardaddvlan_addvlan(): if not name: flash('Name is required.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') if vlan_id is None: flash('VLAN ID must be an integer between 1 and 4094.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') if not subnet: flash('Subnet IP is required.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') if subnet_mask is None: flash('Invalid subnet prefix (must be 1-30).', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') if not _hash_ok(): - return redirect(VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() vlans = cfg.setdefault('vlans', []) if any(v.get('vlan_id') == vlan_id for v in vlans): flash(f'VLAN ID {vlan_id} is already in use.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') if radius_default and any(v.get('radius_default') for v in vlans): flash('Only one VLAN can be the RADIUS default.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') entry = { 'name': name, @@ -91,7 +92,7 @@ def networklayout_cardaddvlan_addvlan(): if errors: for msg in errors: flash(msg, 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') flash(save_config_with_snapshot( cfg, @@ -99,16 +100,16 @@ def networklayout_cardaddvlan_addvlan(): before=None, after={k: entry[k] for k in _VLAN_FIELDS if k in entry}, description=f'Added VLAN: {name} ({subnet}/{subnet_mask})', ), 'success') - return redirect(VIEW) + return redirect(f'/{_PAGE}') -@bp.route('/action/networklayout_tablevlans_edit', methods=['POST']) +@bp.route('/action/networklayout/vlans_edit', methods=['POST']) @require_level('administrator') -def networklayout_tablevlans_edit(): +def vlans_edit(): idx = _row_index() if idx is None: flash('Invalid request.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') name = sanitize.name(request.form.get('name', '')) vlan_id = sanitize.vlan_id(request.form.get('vlan_id', '')) @@ -126,7 +127,7 @@ def networklayout_tablevlans_edit(): clean = sanitize.ip(raw_ip) if not clean: flash(f"'{raw_ip}' is not a valid IP address.", 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') identity_ips.append(clean) identity_descs = request.form.get('server_identity_descriptions', '').splitlines() identity_hostnames = request.form.get('server_identity_hostnames', '').splitlines() @@ -136,27 +137,27 @@ def networklayout_tablevlans_edit(): subnet_mask = sanitize.subnet_mask(subnet_mask_raw) if subnet_mask is None: flash('Invalid subnet prefix (must be 1-30).', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') else: subnet_mask = None if not name: flash('Name is required.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') if vlan_id is None: flash('VLAN ID must be an integer between 1 and 4094.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') if not subnet: flash('Subnet IP is required.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') if not _hash_ok(): - return redirect(VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() vlans = cfg.get('vlans', []) if idx < 0 or idx >= len(vlans): flash('VLAN not found.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') existing = vlans[idx] is_vpn = existing.get('is_vpn', False) @@ -167,20 +168,20 @@ def networklayout_tablevlans_edit(): for _ip in identity_ips: if ipaddress.IPv4Address(_ip) not in _vlan_net: flash(f"Server identity IP '{_ip}' is not in the VLAN subnet ({subnet}/{final_mask}).", 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') current_id = existing.get('vlan_id') if current_id == 1 and vlan_id != 1: flash('VLAN 1 is the physical interface and cannot change its ID.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') if vlan_id != current_id and any(v.get('vlan_id') == vlan_id for i, v in enumerate(vlans) if i != idx): flash(f'VLAN ID {vlan_id} is already in use.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') if radius_default and any(i != idx and v.get('radius_default') for i, v in enumerate(vlans)): flash('Only one VLAN can be the RADIUS default.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') old_identities = existing.get('server_identities', []) new_identities = [] @@ -197,7 +198,7 @@ def networklayout_tablevlans_edit(): clean_hostname = sanitize.hostname(hostname_raw) if clean_hostname is None: flash(f"'{hostname_raw}' is not a valid hostname.", 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') entry['hostname'] = clean_hostname else: entry.pop('hostname', None) @@ -206,7 +207,7 @@ def networklayout_tablevlans_edit(): gateway_raw = sanitize.ip(request.form.get('gateway', '')) if gateway_raw and gateway_raw not in identity_ips: flash(f"Gateway '{gateway_raw}' must match one of the server identity IPs.", 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') inferred_gw = (min(identity_ips, key=lambda ip: int(ip.split('.')[-1])) if identity_ips else '') new_stored_gw = gateway_raw if (gateway_raw and gateway_raw != inferred_gw) else '' @@ -221,17 +222,17 @@ def networklayout_tablevlans_edit(): _clean = sanitize.ip(_line) if not _clean: flash(f"'{_line}' is not a valid DNS server IP.", 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') dns_ips.append(_clean) if dns_override and not dns_ips: flash('At least one DNS server IP is required when override is enabled.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') if dns_override and dns_ips: _vlan_net = ipaddress.IPv4Network(f'{subnet}/{final_mask}', strict=False) for _ip in dns_ips: if ipaddress.IPv4Address(_ip) not in _vlan_net: flash(f"DNS server '{_ip}' is not in the VLAN subnet ({subnet}/{final_mask}).", 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') new_stored_dns = dns_ips if dns_override else [] _existing_dns = existing.get('dhcp_information', {}).get('explicit_overrides', {}).get('dns_server', []) existing_dns = _existing_dns if isinstance(_existing_dns, list) else ([_existing_dns] if _existing_dns else []) @@ -245,17 +246,17 @@ def networklayout_tablevlans_edit(): _clean = sanitize.ip(_line) if not _clean: flash(f"'{_line}' is not a valid NTP server IP.", 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') ntp_ips.append(_clean) if ntp_override and not ntp_ips: flash('At least one NTP server IP is required when override is enabled.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') if ntp_override and ntp_ips: _vlan_net = ipaddress.IPv4Network(f'{subnet}/{final_mask}', strict=False) for _ip in ntp_ips: if ipaddress.IPv4Address(_ip) not in _vlan_net: flash(f"NTP server '{_ip}' is not in the VLAN subnet ({subnet}/{final_mask}).", 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') new_stored_ntp = ntp_ips if ntp_override else [] _existing_ntp = existing.get('dhcp_information', {}).get('explicit_overrides', {}).get('ntp_server', []) existing_ntp = _existing_ntp if isinstance(_existing_ntp, list) else ([_existing_ntp] if _existing_ntp else []) @@ -282,7 +283,7 @@ def networklayout_tablevlans_edit(): and new_stored_dns == existing_dns and new_stored_ntp == existing_ntp): flash('No changes were made.', 'info') - return redirect(VIEW) + return redirect(f'/{_PAGE}') before = {k: existing.get(k) for k in _VLAN_FIELDS} existing.update({ @@ -316,7 +317,7 @@ def networklayout_tablevlans_edit(): if errors: for msg in errors: flash(msg, 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') flash(save_config_with_snapshot( cfg, @@ -324,31 +325,31 @@ def networklayout_tablevlans_edit(): before=before, after={k: existing.get(k) for k in _VLAN_FIELDS}, description=f'Edited VLAN: {name}', ), 'success') - return redirect(VIEW) + return redirect(f'/{_PAGE}') -@bp.route('/action/networklayout_tablevlans_delete', methods=['POST']) +@bp.route('/action/networklayout/vlans_delete', methods=['POST']) @require_level('administrator') -def networklayout_tablevlans_delete(): +def vlans_delete(): idx = _row_index() if idx is None: flash('Invalid request.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') if not _hash_ok(): - return redirect(VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() vlans = cfg.get('vlans', []) if idx < 0 or idx >= len(vlans): flash('VLAN not found.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') removed = vlans.pop(idx) errors = validate.validate_config(cfg) if errors: for msg in errors: flash(msg, 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') flash(save_config_with_snapshot( cfg, @@ -357,4 +358,4 @@ def networklayout_tablevlans_delete(): after=None, description=f'Deleted VLAN: {removed["name"]}', ), 'success') - return redirect(VIEW) + return redirect(f'/{_PAGE}') diff --git a/docker/routlin-dash/app/pages/networklayout/content.json b/docker/routlin-dash/app/pages/networklayout/content.json index 8eaf746..b2f73aa 100644 --- a/docker/routlin-dash/app/pages/networklayout/content.json +++ b/docker/routlin-dash/app/pages/networklayout/content.json @@ -1,5 +1,4 @@ { - "id": "view_networklayout", "client_requirement": "client_is_viewer+", "items": [ { @@ -95,7 +94,7 @@ "row_actions": [ { "client_requirement": "client_is_administrator+", - "action": "/action/networklayout_tablevlans_edit", + "action": "/action/networklayout/vlans_edit", "method": "inline_edit", "text": "Edit", "class": "btn-ghost btn-sm", @@ -155,7 +154,7 @@ }, { "client_requirement": "client_is_administrator+", - "action": "/action/networklayout_tablevlans_delete", + "action": "/action/networklayout/vlans_delete", "method": "post", "text": "Delete", "class": "btn-danger btn-sm", @@ -174,7 +173,7 @@ "items": [ { "type": "form", - "action": "/action/networklayout_cardaddvlan_addvlan", + "action": "/action/networklayout/addvlan_add", "method": "post", "items": [ { @@ -263,7 +262,7 @@ "items": [ { "type": "button_primary", - "action": "/action/networklayout_cardaddvlan_addvlan", + "action": "/action/networklayout/addvlan_add", "method": "post", "text": "Add VLAN", "class": "add-vlan-btn", diff --git a/docker/routlin-dash/app/pages/overview/content.json b/docker/routlin-dash/app/pages/overview/content.json index 490587a..afd0140 100644 --- a/docker/routlin-dash/app/pages/overview/content.json +++ b/docker/routlin-dash/app/pages/overview/content.json @@ -1,5 +1,4 @@ { - "id": "view_overview", "client_requirement": "client_is_nothing+", "items": [ { @@ -22,7 +21,7 @@ }, { "type": "button_primary", - "action": "/view/view_login", + "action": "/accountlogin", "text": "Log In" } ] diff --git a/docker/routlin-dash/app/pages/physicalinterfaces/action.py b/docker/routlin-dash/app/pages/physicalinterfaces/action.py index 7b3b4d1..4277768 100644 --- a/docker/routlin-dash/app/pages/physicalinterfaces/action.py +++ b/docker/routlin-dash/app/pages/physicalinterfaces/action.py @@ -1,3 +1,4 @@ +from pathlib import Path import copy import os @@ -7,9 +8,9 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h import sanitize import validation as validate -bp = Blueprint('physicalinterfaces', __name__) +_PAGE = Path(__file__).parent.name -_VIEW = '/view/view_physicalinterfaces' +bp = Blueprint(_PAGE, __name__) _EXCLUDE_PREFIXES = ('lo', 'wg', 'docker', 'br-', 'veth', 'tun', 'tap', 'ppp', 'virbr', @@ -31,29 +32,29 @@ def _valid_interface(name): return name in _get_system_interfaces() -@bp.route('/action/physicalinterfaces_cardphysicalinterface_save', methods=['POST']) +@bp.route('/action/physicalinterfaces/physicalinterface_save', methods=['POST']) @require_level('administrator') -def physicalinterfaces_cardphysicalinterface_save(): +def physicalinterface_save(): wan = sanitize.interface_name(request.form.get('wan_interface', '')) lan = sanitize.interface_name(request.form.get('lan_interface', '')) if not wan or not lan: flash('Both WAN and LAN interfaces are required.', 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') if wan == lan: flash('WAN and LAN interfaces must be different.', 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') if not verify_config_hash(request.form.get('config_hash', '')): flash('Configuration was modified by another session. Please refresh and try again.', 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') available = _get_system_interfaces() for iface in (wan, lan): if available and iface not in available: flash(f"Interface '{iface}' does not exist on this system.", 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() before = copy.deepcopy(cfg.get('network_interfaces', {})) @@ -64,22 +65,22 @@ def physicalinterfaces_cardphysicalinterface_save(): if errors: for msg in errors: flash(msg, 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') flash(save_config_with_snapshot( cfg, path='network_interfaces', key='global', operation='edit', before=before, after=copy.deepcopy(cfg['network_interfaces']), description='Updated network interfaces', cmd='core apply', ), 'success') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') -@bp.route('/action/physicalinterfaces_cardinterfaceconfiguration_apply', methods=['POST']) +@bp.route('/action/physicalinterfaces/ifaceconfig_apply', methods=['POST']) @require_level('administrator') -def physicalinterfaces_cardinterfaceconfiguration_apply(): +def ifaceconfig_apply(): if not verify_config_hash(request.form.get('config_hash', '')): flash('Configuration was modified by another session. Please refresh and try again.', 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') iface = sanitize.interface_name(request.form.get('iface', '')) mtu = request.form.get('mtu', '').strip() @@ -89,27 +90,27 @@ def physicalinterfaces_cardinterfaceconfiguration_apply(): if not iface: flash('No interface specified.', 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') if not _valid_interface(iface): flash(f"Interface '{iface}' does not exist on this system.", 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') mtu_int = None if mtu: mtu_int = validate.int_range(mtu, 68, 9000) if mtu_int is None: flash('MTU must be an integer between 68 and 9000.', 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') mac_raw = request.form.get('mac', '').strip() if mac_raw and not mac: flash('MAC address must be in the format aa:bb:cc:dd:ee:ff.', 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') if not mtu_int and not mac: flash('No changes specified.', 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') queued = False if mtu_int and str(mtu_int) != original_mtu: @@ -121,7 +122,7 @@ def physicalinterfaces_cardinterfaceconfiguration_apply(): if not queued: flash('No changes detected.', 'info') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') flash(queued_msg(action_label='Changes queued'), 'success') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') diff --git a/docker/routlin-dash/app/pages/physicalinterfaces/content.json b/docker/routlin-dash/app/pages/physicalinterfaces/content.json index 8baa310..c3328cd 100644 --- a/docker/routlin-dash/app/pages/physicalinterfaces/content.json +++ b/docker/routlin-dash/app/pages/physicalinterfaces/content.json @@ -1,5 +1,4 @@ { - "id": "view_physicalinterfaces", "client_requirement": "client_is_administrator+", "items": [ { @@ -22,7 +21,7 @@ "items": [ { "type": "form", - "action": "/action/physicalinterfaces_cardphysicalinterface_save", + "action": "/action/physicalinterfaces/physicalinterface_save", "method": "post", "items": [ { @@ -46,7 +45,7 @@ "items": [ { "type": "button_primary", - "action": "/action/physicalinterfaces_cardphysicalinterface_save", + "action": "/action/physicalinterfaces/physicalinterface_save", "method": "post", "text": "Save" }, @@ -69,7 +68,7 @@ "items": [ { "type": "form", - "action": "/action/physicalinterfaces_cardinterfaceconfiguration_apply", + "action": "/action/physicalinterfaces/ifaceconfig_apply", "method": "post", "items": [ { @@ -142,7 +141,7 @@ "items": [ { "type": "button_primary", - "action": "/action/physicalinterfaces_cardinterfaceconfiguration_apply", + "action": "/action/physicalinterfaces/ifaceconfig_apply", "method": "post", "text": "Apply" }, diff --git a/docker/routlin-dash/app/pages/portforwarding/action.py b/docker/routlin-dash/app/pages/portforwarding/action.py index ef3aa0c..9b7a6a5 100644 --- a/docker/routlin-dash/app/pages/portforwarding/action.py +++ b/docker/routlin-dash/app/pages/portforwarding/action.py @@ -1,3 +1,4 @@ +from pathlib import Path import copy from flask import Blueprint, request, redirect, flash @@ -6,9 +7,9 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h import sanitize import validation as validate -bp = Blueprint('portforwarding', __name__) +_PAGE = Path(__file__).parent.name -VIEW = '/view/view_portforwarding' +bp = Blueprint(_PAGE, __name__) _VALID_PROTOS_STR = ', '.join(sorted(validate.VALID_PROTOCOLS)) @@ -73,14 +74,14 @@ def _parse_entry(): }, None -@bp.route('/action/add_port_forward', methods=['POST']) +@bp.route('/action/portforwarding/addrule_add', methods=['POST']) @require_level('administrator') -def add_port_forward(): +def addrule_add(): entry, err = _parse_entry() if err: - return redirect(VIEW) + return redirect(f'/{_PAGE}') if not _hash_ok(): - return redirect(VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() cfg.setdefault('port_forwarding', []).append(entry) @@ -88,7 +89,7 @@ def add_port_forward(): if errors: for msg in errors: flash(msg, 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') key = f'{entry["protocol"]}:{entry["dest_port"]}' flash(save_config_with_snapshot( @@ -97,24 +98,24 @@ def add_port_forward(): before=None, after=entry, description=f'Added port forward: {key} → {entry["nat_ip"]}:{entry["nat_port"]}', ), 'success') - return redirect(VIEW) + return redirect(f'/{_PAGE}') -@bp.route('/action/toggle_port_forward', methods=['POST']) +@bp.route('/action/portforwarding/rules_toggle', methods=['POST']) @require_level('administrator') -def toggle_port_forward(): +def rules_toggle(): idx = _row_index() if idx is None: flash('Invalid request.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') if not _hash_ok(): - return redirect(VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() items = cfg.get('port_forwarding', []) if idx < 0 or idx >= len(items): flash('Entry not found.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') old_enabled = items[idx].get('enabled', True) items[idx]['enabled'] = not old_enabled @@ -122,7 +123,7 @@ def toggle_port_forward(): if errors: for msg in errors: flash(msg, 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') key = f'{items[idx]["protocol"]}:{items[idx]["dest_port"]}' action = 'Enabled' if not old_enabled else 'Disabled' @@ -132,28 +133,28 @@ def toggle_port_forward(): before={'enabled': old_enabled}, after={'enabled': not old_enabled}, description=f'{action} port forward: {key}', ), 'success') - return redirect(VIEW) + return redirect(f'/{_PAGE}') -@bp.route('/action/edit_port_forward', methods=['POST']) +@bp.route('/action/portforwarding/rules_edit', methods=['POST']) @require_level('administrator') -def edit_port_forward(): +def rules_edit(): idx = _row_index() if idx is None: flash('Invalid request.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') entry, err = _parse_entry() if err: - return redirect(VIEW) + return redirect(f'/{_PAGE}') if not _hash_ok(): - return redirect(VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() items = cfg.get('port_forwarding', []) if idx < 0 or idx >= len(items): flash('Entry not found.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') before = copy.deepcopy(items[idx]) items[idx] = entry @@ -162,7 +163,7 @@ def edit_port_forward(): if errors: for msg in errors: flash(msg, 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') key = f'{entry["protocol"]}:{entry["dest_port"]}' flash(save_config_with_snapshot( @@ -171,31 +172,31 @@ def edit_port_forward(): before=before, after=copy.deepcopy(items[idx]), description=f'Edited port forward: {key} → {entry["nat_ip"]}:{entry["nat_port"]}', ), 'success') - return redirect(VIEW) + return redirect(f'/{_PAGE}') -@bp.route('/action/delete_port_forward', methods=['POST']) +@bp.route('/action/portforwarding/rules_delete', methods=['POST']) @require_level('administrator') -def delete_port_forward(): +def rules_delete(): idx = _row_index() if idx is None: flash('Invalid request.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') if not _hash_ok(): - return redirect(VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() items = cfg.get('port_forwarding', []) if idx < 0 or idx >= len(items): flash('Entry not found.', 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') removed = items.pop(idx) errors = validate.validate_config(cfg) if errors: for msg in errors: flash(msg, 'error') - return redirect(VIEW) + return redirect(f'/{_PAGE}') key = f'{removed["protocol"]}:{removed["dest_port"]}' flash(save_config_with_snapshot( @@ -204,4 +205,4 @@ def delete_port_forward(): before=removed, after=None, description=f'Deleted port forward: {key}', ), 'success') - return redirect(VIEW) + return redirect(f'/{_PAGE}') diff --git a/docker/routlin-dash/app/pages/portforwarding/content.json b/docker/routlin-dash/app/pages/portforwarding/content.json index 9c6f13c..99ad8c1 100644 --- a/docker/routlin-dash/app/pages/portforwarding/content.json +++ b/docker/routlin-dash/app/pages/portforwarding/content.json @@ -1,5 +1,4 @@ { - "id": "view_portforwarding", "client_requirement": "client_is_viewer+", "items": [ { @@ -53,7 +52,7 @@ "row_actions": [ { "client_requirement": "client_is_administrator+", - "action": "/action/edit_port_forward", + "action": "/action/portforwarding/rules_edit", "method": "inline_edit", "text": "Edit", "class": "btn-ghost btn-sm", @@ -88,7 +87,7 @@ }, { "client_requirement": "client_is_administrator+", - "action": "/action/delete_port_forward", + "action": "/action/portforwarding/rules_delete", "method": "post", "text": "Delete", "class": "btn-danger btn-sm" @@ -103,7 +102,7 @@ "items": [ { "type": "form", - "action": "/action/add_port_forward", + "action": "/action/portforwarding/addrule_add", "method": "post", "items": [ { @@ -149,7 +148,7 @@ "items": [ { "type": "button_primary", - "action": "/action/add_port_forward", + "action": "/action/portforwarding/addrule_add", "method": "post", "text": "Add Rule" }, diff --git a/docker/routlin-dash/app/pages/preferences/action.py b/docker/routlin-dash/app/pages/preferences/action.py index 340c2ab..d3f9265 100644 --- a/docker/routlin-dash/app/pages/preferences/action.py +++ b/docker/routlin-dash/app/pages/preferences/action.py @@ -1,10 +1,13 @@ +from pathlib import Path from flask import Blueprint, request, session, redirect, flash import json, bcrypt from auth import require_level from config_utils import ACCOUNTS_FILE import sanitize -bp = Blueprint('preferences', __name__) +_PAGE = Path(__file__).parent.name + +bp = Blueprint(_PAGE, __name__) @@ -20,14 +23,14 @@ def _save_accounts(data): json.dump(data, f, indent=2) -@bp.route('/action/save_preferences', methods=['POST']) +@bp.route('/action/preferences/accountdetails_save', methods=['POST']) @require_level('viewer') -def save_preferences(): +def accountdetails_save(): tz = sanitize.timezone(request.form.get('timezone', '').strip()) if not tz: flash('Timezone is required.', 'error') - return redirect('/view/view_preferences') + return redirect(f'/{_PAGE}') email = session.get('email_address', '').lower() data = _load_accounts() @@ -36,7 +39,7 @@ def save_preferences(): if account is None: flash('Account not found. Please log in again.', 'error') - return redirect('/view/view_login') + return redirect('/accountlogin') account['timezone'] = tz _save_accounts(data) @@ -44,27 +47,27 @@ def save_preferences(): session['timezone'] = tz flash('Preferences saved.', 'success') - return redirect('/view/view_preferences') + return redirect(f'/{_PAGE}') -@bp.route('/action/change_password', methods=['POST']) +@bp.route('/action/preferences/changepassword_save', methods=['POST']) @require_level('viewer') -def change_password(): +def changepassword_save(): current_password = request.form.get('current_password', '') new_password = request.form.get('new_password', '') confirm_password = request.form.get('confirm_password', '') if not current_password or not new_password or not confirm_password: flash('All fields are required.', 'error') - return redirect('/view/view_preferences') + return redirect(f'/{_PAGE}') if new_password != confirm_password: flash('New passwords do not match.', 'error') - return redirect('/view/view_preferences') + return redirect(f'/{_PAGE}') if len(new_password) < 8: flash('New password must be at least 8 characters.', 'error') - return redirect('/view/view_preferences') + return redirect(f'/{_PAGE}') email = session.get('email_address', '').lower() data = _load_accounts() @@ -73,12 +76,12 @@ def change_password(): if account is None: flash('Account not found. Please log in again.', 'error') - return redirect('/view/view_login') + return redirect('/accountlogin') stored_hash = account.get('hashed_password', '').encode('utf-8') if not bcrypt.checkpw(current_password.encode('utf-8'), stored_hash): flash('Current password is incorrect.', 'error') - return redirect('/view/view_preferences') + return redirect(f'/{_PAGE}') salt = bcrypt.gensalt() hashed = bcrypt.hashpw(new_password.encode('utf-8'), salt) @@ -88,4 +91,4 @@ def change_password(): _save_accounts(data) flash('Password changed successfully.', 'success') - return redirect('/view/view_preferences') + return redirect(f'/{_PAGE}') diff --git a/docker/routlin-dash/app/pages/preferences/content.json b/docker/routlin-dash/app/pages/preferences/content.json index 5b096a1..8f082b5 100644 --- a/docker/routlin-dash/app/pages/preferences/content.json +++ b/docker/routlin-dash/app/pages/preferences/content.json @@ -1,5 +1,4 @@ { - "id": "view_preferences", "client_requirement": "client_is_viewer+", "items": [ { @@ -21,7 +20,7 @@ "items": [ { "type": "form", - "action": "/action/save_preferences", + "action": "/action/preferences/accountdetails_save", "method": "post", "items": [ { @@ -46,7 +45,7 @@ "items": [ { "type": "button_primary", - "action": "/action/save_preferences", + "action": "/action/preferences/accountdetails_save", "method": "post", "text": "Save Preferences" } @@ -62,7 +61,7 @@ "items": [ { "type": "form", - "action": "/action/change_password", + "action": "/action/preferences/changepassword_save", "method": "post", "items": [ { @@ -91,7 +90,7 @@ "items": [ { "type": "button_primary", - "action": "/action/change_password", + "action": "/action/preferences/changepassword_save", "method": "post", "text": "Change Password" } diff --git a/docker/routlin-dash/app/pages/vpn/action.py b/docker/routlin-dash/app/pages/vpn/action.py index aa9f1f7..d232671 100644 --- a/docker/routlin-dash/app/pages/vpn/action.py +++ b/docker/routlin-dash/app/pages/vpn/action.py @@ -1,3 +1,4 @@ +from pathlib import Path import base64 import copy import ipaddress @@ -9,9 +10,10 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h import sanitize import validation as validate -bp = Blueprint('vpn', __name__) +_PAGE = Path(__file__).parent.name + +bp = Blueprint(_PAGE, __name__) -_VIEW = '/view/view_vpn' _MTU_MIN = 576 _MTU_MAX = 9000 @@ -121,7 +123,7 @@ def _conf_response(vlan, peer_name, peer_ip, private_key): if not server_pub: flash('Peer saved. Run sudo python3 ~/routlin/core.py --apply to generate the server ' 'public key, then regenerate this peer to download the client config.', 'warning') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') conf = _build_client_conf(vlan, peer_name, peer_ip, private_key, server_pub) safe = re.sub(r'[^A-Za-z0-9_\-]', '_', peer_name) resp = make_response(conf) @@ -130,9 +132,9 @@ def _conf_response(vlan, peer_name, peer_ip, private_key): return resp -@bp.route('/action/apply_vpn', methods=['POST']) +@bp.route('/action/vpn/wireguard_apply', methods=['POST']) @require_level('administrator') -def apply_vpn(): +def wireguard_apply(): listen_port_raw = request.form.get('vpn_listen_port', '').strip() server_endpoint = validate.domainname(request.form.get('vpn_server_endpoint', '')) domain = validate.domainname(request.form.get('vpn_domain', '')) @@ -141,39 +143,39 @@ def apply_vpn(): if not listen_port_raw: flash('Listen port is required.', 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') listen_port = validate.int_range(listen_port_raw, 1, 65535) if listen_port is None: flash(f'"{listen_port_raw}" is not a valid port number (1-65535).', 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') dns_server = '' if dns_raw: dns_server = validate.ip(dns_raw) if not dns_server: flash(f'"{dns_raw}" is not a valid IP address for DNS server.', 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') mtu = None if mtu_raw: mtu = validate.int_range(mtu_raw, _MTU_MIN, _MTU_MAX) if mtu is None: flash(f'"{mtu_raw}" is not a valid MTU (must be {_MTU_MIN}-{_MTU_MAX}).', 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') if not _hash_ok(): - return redirect(_VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() vpn_vlan = _wg_vlan(cfg) if vpn_vlan is None: flash('No WireGuard VLAN found in configuration.', 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') for v in cfg.get('vlans', []): if v.get('is_vpn') and v is not vpn_vlan and v.get('vpn_information', {}).get('listen_port') == listen_port: flash(f'Listen port {listen_port} is already used by another VPN VLAN.', 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') before_info = copy.deepcopy(vpn_vlan.get('vpn_information', {})) info = vpn_vlan.setdefault('vpn_information', {}) @@ -195,7 +197,7 @@ def apply_vpn(): if errors: for msg in errors: flash(msg, 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') vlan_name = vpn_vlan['name'] flash(save_config_with_snapshot( @@ -204,12 +206,12 @@ def apply_vpn(): before=before_info or None, after=copy.deepcopy(info), description=f'Updated VPN configuration for {vlan_name}', ), 'success') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') -@bp.route('/action/add_vpn_peer', methods=['POST']) +@bp.route('/action/vpn/addpeer_add', methods=['POST']) @require_level('administrator') -def add_vpn_peer(): +def addpeer_add(): peer_name = sanitize.name(request.form.get('peer_name', '')) peer_vlan_nm = request.form.get('peer_vlan', '').strip() peer_ip_raw = request.form.get('peer_ip', '').strip() @@ -218,41 +220,41 @@ def add_vpn_peer(): if not peer_name: flash('Peer name is required.', 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') if not peer_vlan_nm: flash('Assigned VLAN is required.', 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') peer_ip = validate.ip(peer_ip_raw) if not peer_ip: flash(f'"{peer_ip_raw}" is not a valid IP address.', 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') if not _hash_ok(): - return redirect(_VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() vpn_vlan = _wg_vlan_by_name(cfg, peer_vlan_nm) if vpn_vlan is None: flash(f'VPN VLAN "{peer_vlan_nm}" not found.', 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') try: network = ipaddress.IPv4Network(f"{vpn_vlan['subnet']}/{vpn_vlan['subnet_mask']}", strict=False) if ipaddress.IPv4Address(peer_ip) not in network: flash(f'{peer_ip} is not within the subnet {vpn_vlan["subnet"]}/{vpn_vlan["subnet_mask"]} of {peer_vlan_nm}.', 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') except Exception: pass peers = vpn_vlan.setdefault('peers', []) if any(p.get('name') == peer_name for p in peers): flash(f'A peer named "{peer_name}" already exists.', 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') for v in cfg.get('vlans', []): if not v.get('is_vpn'): continue if any(p.get('ip') == peer_ip for p in v.get('peers', [])): flash(f'IP address {peer_ip} is already assigned to another peer.', 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') private_key, public_key = _generate_wg_keypair() entry = { @@ -267,7 +269,7 @@ def add_vpn_peer(): if errors: for msg in errors: flash(msg, 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') save_config_with_snapshot( cfg, @@ -279,13 +281,13 @@ def add_vpn_peer(): return _conf_response(vpn_vlan, peer_name, peer_ip, private_key) -@bp.route('/action/edit_vpn_peer', methods=['POST']) +@bp.route('/action/vpn/peers_edit', methods=['POST']) @require_level('administrator') -def edit_vpn_peer(): +def peers_edit(): flat_idx = _row_index() if flat_idx is None: flash('Invalid request.', 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') peer_name = sanitize.name(request.form.get('name', '')) split_tunnel = request.form.get('split_tunnel') in ('true', '1', 'on', 'yes') @@ -293,20 +295,20 @@ def edit_vpn_peer(): if not peer_name: flash('Peer name is required.', 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') if not _hash_ok(): - return redirect(_VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() vlan, peer_idx = _find_peer_by_flat_idx(cfg, flat_idx) if vlan is None: flash('Peer not found.', 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') peers = vlan.get('peers', []) if any(j != peer_idx and p.get('name') == peer_name for j, p in enumerate(peers)): flash(f'A peer named "{peer_name}" already exists.', 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') before = copy.deepcopy({k: peers[peer_idx].get(k) for k in ('name', 'split_tunnel', 'enabled')}) peers[peer_idx].update({'name': peer_name, 'split_tunnel': split_tunnel, 'enabled': enabled}) @@ -314,7 +316,7 @@ def edit_vpn_peer(): if errors: for msg in errors: flash(msg, 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') vlan_name = vlan['name'] flash(save_config_with_snapshot( @@ -323,24 +325,24 @@ def edit_vpn_peer(): before=before, after={'name': peer_name, 'split_tunnel': split_tunnel, 'enabled': enabled}, description=f'Edited VPN peer: {peer_name}', ), 'success') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') -@bp.route('/action/toggle_vpn_peer', methods=['POST']) +@bp.route('/action/vpn/peers_toggle', methods=['POST']) @require_level('administrator') -def toggle_vpn_peer(): +def peers_toggle(): flat_idx = _row_index() if flat_idx is None: flash('Invalid request.', 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') if not _hash_ok(): - return redirect(_VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() vlan, peer_idx = _find_peer_by_flat_idx(cfg, flat_idx) if vlan is None: flash('Peer not found.', 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') peers = vlan.get('peers', []) old_enabled = peers[peer_idx].get('enabled', True) @@ -349,7 +351,7 @@ def toggle_vpn_peer(): if errors: for msg in errors: flash(msg, 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') peer_name = peers[peer_idx]['name'] vlan_name = vlan['name'] @@ -360,24 +362,24 @@ def toggle_vpn_peer(): before={'enabled': old_enabled}, after={'enabled': not old_enabled}, description=f'{action} VPN peer: {peer_name}', ), 'success') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') -@bp.route('/action/delete_vpn_peer', methods=['POST']) +@bp.route('/action/vpn/peers_delete', methods=['POST']) @require_level('administrator') -def delete_vpn_peer(): +def peers_delete(): flat_idx = _row_index() if flat_idx is None: flash('Invalid request.', 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') if not _hash_ok(): - return redirect(_VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() vlan, peer_idx = _find_peer_by_flat_idx(cfg, flat_idx) if vlan is None: flash('Peer not found.', 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') peers = vlan.get('peers', []) removed = peers.pop(peer_idx) @@ -385,7 +387,7 @@ def delete_vpn_peer(): if errors: for msg in errors: flash(msg, 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') vlan_name = vlan['name'] flash(save_config_with_snapshot( @@ -395,24 +397,24 @@ def delete_vpn_peer(): after=None, description=f'Deleted VPN peer: {removed["name"]}', ), 'success') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') -@bp.route('/action/regenerate_vpn_peer', methods=['POST']) +@bp.route('/action/vpn/peers_regenerate', methods=['POST']) @require_level('administrator') -def regenerate_vpn_peer(): +def peers_regenerate(): flat_idx = _row_index() if flat_idx is None: flash('Invalid request.', 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') if not _hash_ok(): - return redirect(_VIEW) + return redirect(f'/{_PAGE}') cfg = load_config() vlan, peer_idx = _find_peer_by_flat_idx(cfg, flat_idx) if vlan is None: flash('Peer not found.', 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') private_key, public_key = _generate_wg_keypair() peer = vlan['peers'][peer_idx] @@ -422,7 +424,7 @@ def regenerate_vpn_peer(): if errors: for msg in errors: flash(msg, 'error') - return redirect(_VIEW) + return redirect(f'/{_PAGE}') vlan_name = vlan['name'] save_config_with_snapshot( diff --git a/docker/routlin-dash/app/pages/vpn/content.json b/docker/routlin-dash/app/pages/vpn/content.json index 6d75b15..617eeea 100644 --- a/docker/routlin-dash/app/pages/vpn/content.json +++ b/docker/routlin-dash/app/pages/vpn/content.json @@ -1,5 +1,4 @@ { - "id": "view_vpn", "client_requirement": "client_is_viewer+", "items": [ { @@ -89,7 +88,7 @@ "row_actions": [ { "client_requirement": "client_is_administrator+", - "action": "/action/edit_vpn_peer", + "action": "/action/vpn/peers_edit", "method": "inline_edit", "text": "Edit", "class": "btn-ghost btn-sm", @@ -113,14 +112,14 @@ }, { "client_requirement": "client_is_administrator+", - "action": "/action/regenerate_vpn_peer", + "action": "/action/vpn/peers_regenerate", "method": "post", "text": "Regen Conf", "class": "btn-ghost btn-sm" }, { "client_requirement": "client_is_administrator+", - "action": "/action/delete_vpn_peer", + "action": "/action/vpn/peers_delete", "method": "post", "text": "Delete", "class": "btn-danger btn-sm" @@ -134,7 +133,7 @@ "items": [ { "type": "form", - "action": "/action/add_vpn_peer", + "action": "/action/vpn/addpeer_add", "method": "post", "items": [ { @@ -181,7 +180,7 @@ "items": [ { "type": "button_primary", - "action": "/action/add_vpn_peer", + "action": "/action/vpn/addpeer_add", "method": "post", "text": "Add Peer & Download Conf" }, @@ -202,7 +201,7 @@ "items": [ { "type": "form", - "action": "/action/apply_vpn", + "action": "/action/vpn/wireguard_apply", "method": "post", "items": [ { @@ -259,7 +258,7 @@ "items": [ { "type": "button_primary", - "action": "/action/apply_vpn", + "action": "/action/vpn/wireguard_apply", "method": "post", "text": "Save" }, diff --git a/docker/routlin-dash/app/view_page.py b/docker/routlin-dash/app/view_page.py index 325daa0..0fb5234 100644 --- a/docker/routlin-dash/app/view_page.py +++ b/docker/routlin-dash/app/view_page.py @@ -66,23 +66,6 @@ def _load_icon(name): return '' -def _build_view_map(): - m = {} - if not _os.path.isdir(_PAGES_DIR): - return m - for name in _os.listdir(_PAGES_DIR): - cpath = _os.path.join(_PAGES_DIR, name, 'content.json') - if _os.path.isfile(cpath): - try: - with open(cpath) as f: - d = json.load(f) - vid = d.get('id') - if vid: - m[vid] = name - except Exception: - pass - return m -_VIEW_MAP = _build_view_map() # Shell helper ====================================================== @@ -2040,7 +2023,7 @@ def render_layout(view_id, content_html, tokens): else 'Fix pending. The processing service is not running.') else: fix_suffix = ('Fix pending. Click Apply Now below to fix.' - if view_id == 'view_actions' else + if view_id == 'actions' else 'Fix pending. Visit the Actions page ASAP to apply fix.') for sev, items in grouped.items(): if not items: @@ -2062,7 +2045,7 @@ def render_layout(view_id, content_html, tokens): pass pending_bar = '' - if has_pending_alert and not problem_bars and view_id != 'view_actions': + if has_pending_alert and not problem_bars and view_id != 'actions': pending_bar = ( '
' 'You have actions pending. Please visit the Actions page.' @@ -2114,7 +2097,7 @@ def _render_nav_item(item, active_view, level, in_dropdown=False, inherited_req= map_to = item.get('map_to', '') action = item.get('action', '') is_active = ' active' if map_to and map_to == active_view else '' - pending = ' nav-item-pending' if pending_alert and map_to == 'view_actions' else '' + pending = ' nav-item-pending' if pending_alert and map_to == 'actions' else '' cls = f'dropdown-item{is_active}' if in_dropdown else f'nav-item{is_active}{pending}' if action: return ( @@ -2122,7 +2105,7 @@ def _render_nav_item(item, active_view, level, in_dropdown=False, inherited_req= f'' ) if map_to: - return f'{label}' + return f'{label}' return f'{label}' if t == 'nav_menu': @@ -2161,15 +2144,14 @@ def _inline_js(): @bp.route('/') def index(): - return _serve_view('view_overview') + return _serve_view('overview') -@bp.route('/view/') -def view(view_id): - return _serve_view(view_id) +@bp.route('/') +def view(page_name): + return _serve_view(page_name) -def _serve_view(view_id): - page_name = _VIEW_MAP.get(view_id) - view_def = _load_json(_os.path.join(_PAGES_DIR, page_name, 'content.json')) if page_name else None +def _serve_view(page_name): + view_def = _load_json(_os.path.join(_PAGES_DIR, page_name, 'content.json')) if view_def is None: from flask import abort @@ -2178,7 +2160,7 @@ def _serve_view(view_id): view_req = view_def.get('client_requirement') level = _client_level() if not _passes(view_req, level): - return redirect('/view/view_overview' if level > 0 else '/view/view_login') + return redirect('/overview' if level > 0 else '/accountlogin') tokens = collect_tokens() @@ -2189,4 +2171,4 @@ def _serve_view(view_id): flash_html += f'
{msg_html}
' content_html = flash_html + render_items(view_def.get('items', []), tokens, view_req) - return render_layout(view_id, content_html, tokens) + return render_layout(page_name, content_html, tokens)