From 24c27767ec163ecfa18264b0b343ea7ac3489c5b Mon Sep 17 00:00:00 2001 From: zyphlar Date: Wed, 22 Apr 2026 17:03:15 -0700 Subject: [PATCH] Add auth system, fix map defaults, controls overflow, fix latest-date ref --- .gitignore | 3 +- docker-compose.yml | 2 + stack.env.example | 12 +++ web/server.py | 179 +++++++++++++++++++++++++++++---------- web/static/map.js | 7 +- web/templates/index.html | 21 +++++ web/templates/login.html | 113 ++++++++++++++++++++++++ web/templates/map.html | 6 +- 8 files changed, 288 insertions(+), 55 deletions(-) create mode 100644 stack.env.example create mode 100644 web/templates/login.html diff --git a/.gitignore b/.gitignore index bd1e1ee..9b65dcc 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ __pycache__ desktop.ini *.geojson osm_cache/ -.claude \ No newline at end of file +.claude +stack.env \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index cda1e95..684c25c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,8 @@ services: - "5000:5000" volumes: - ./data:/data + env_file: + - stack.env environment: - FLASK_ENV=development restart: unless-stopped diff --git a/stack.env.example b/stack.env.example new file mode 100644 index 0000000..1768f38 --- /dev/null +++ b/stack.env.example @@ -0,0 +1,12 @@ +# OSM Import Tools – environment configuration +# Edit these values before deploying. In Portainer GitOps you can also +# override them in the stack's Environment Variables UI. + +# Password for the built-in 'admin' account. +# The container seeds this on every startup, so changing it here +# and redeploying will update the password automatically. +ADMIN_PASSWORD=changeme + +# Secret key for Flask session signing. +# Generate a strong random value, e.g.: python -c "import secrets; print(secrets.token_hex(32))" +FLASK_SECRET_KEY=change-this-to-a-long-random-string diff --git a/web/server.py b/web/server.py index ce16644..7829977 100644 --- a/web/server.py +++ b/web/server.py @@ -2,10 +2,13 @@ """ Flask web server for The Villages Import Tools """ -from flask import Flask, render_template, jsonify, request, send_from_directory +from functools import wraps +from flask import Flask, render_template, jsonify, request, send_from_directory, session, redirect, url_for +from werkzeug.security import generate_password_hash, check_password_hash import subprocess import os import re +import secrets import shutil import threading import json @@ -19,7 +22,105 @@ except ImportError: app = Flask(__name__, static_folder='static', template_folder='templates') -# Store running processes +# ── Secret key ──────────────────────────────────────────────────────────────── + +def _get_or_create_secret_key(): + key = os.environ.get('FLASK_SECRET_KEY', '') + if key: + return key + key_file = '/data/.secret_key' + if os.path.exists(key_file): + with open(key_file) as f: + return f.read().strip() + key = secrets.token_hex(32) + try: + os.makedirs('/data', exist_ok=True) + with open(key_file, 'w') as f: + f.write(key) + except Exception: + pass + return key + +app.secret_key = _get_or_create_secret_key() + +# ── Users / auth ─────────────────────────────────────────────────────────────── + +USERS_FILE = '/data/users.yml' + + +def _load_users(): + try: + with open(USERS_FILE) as f: + data = yaml.safe_load(f) or {} + return {u['username']: u['password_hash'] for u in data.get('users', [])} + except Exception: + return {} + + +def _save_users(users_dict): + users_list = [{'username': u, 'password_hash': h} for u, h in sorted(users_dict.items())] + tmp = USERS_FILE + '.tmp' + with open(tmp, 'w') as f: + yaml.dump({'users': users_list}, f, default_flow_style=False, allow_unicode=True) + os.replace(tmp, USERS_FILE) + + +def _seed_admin(): + """Create or update the admin user from the ADMIN_PASSWORD env var.""" + password = os.environ.get('ADMIN_PASSWORD', '') + if not password: + return + users = _load_users() + users['admin'] = generate_password_hash(password) + _save_users(users) + + +_seed_admin() + + +def require_auth(f): + @wraps(f) + def decorated(*args, **kwargs): + if not session.get('username'): + if request.is_json or request.path.startswith('/api/'): + return jsonify({'error': 'Authentication required'}), 401 + return redirect(url_for('login_page', next=request.path)) + return f(*args, **kwargs) + return decorated + + +@app.route('/login', methods=['GET']) +def login_page(): + if session.get('username'): + return redirect(request.args.get('next', '/')) + return render_template('login.html', next=request.args.get('next', '/'), error=None) + + +@app.route('/login', methods=['POST']) +def login(): + username = request.form.get('username', '').strip() + password = request.form.get('password', '') + next_url = request.form.get('next', '/') + users = _load_users() + if username and username in users and check_password_hash(users[username], password): + session['username'] = username + return redirect(next_url) + return render_template('login.html', next=next_url, error='Invalid username or password') + + +@app.route('/logout', methods=['POST']) +def logout(): + session.clear() + return redirect('/') + + +@app.route('/api/auth-status') +def auth_status(): + username = session.get('username') + return jsonify({'authenticated': bool(username), 'username': username or ''}) + +# ── Store running processes ──────────────────────────────────────────────────── + running_processes = {} process_logs = {} @@ -31,15 +132,10 @@ def _cleanup_legacy_structure(): if not os.path.exists(data_dir): return - # Remove the legacy 'latest' symlink symlink = os.path.join(data_dir, 'latest') if os.path.islink(symlink): os.unlink(symlink) - elif os.path.isdir(symlink): - # Safety: if it somehow became a real dir, leave it alone - pass - # Migrate and remove 6-digit dated folders (YYMMDD) dated_pattern = re.compile(r'^\d{6}$') for entry in os.listdir(data_dir): entry_path = os.path.join(data_dir, entry) @@ -64,7 +160,6 @@ def index(): """Main index page with script execution buttons""" counties = get_counties() - # Get list of files with modification times data_files = {} for county in counties: county_dir = f'/data/{county["id"]}' @@ -82,7 +177,8 @@ def index(): return render_template('index.html', counties=counties, counties_json=_json.dumps(counties), - data_files=data_files) + data_files=data_files, + current_user=session.get('username')) @app.route('/map') def map_viewer(): @@ -149,6 +245,7 @@ def get_script_map(): return sm @app.route('/api/run-script', methods=['POST']) +@require_auth def run_script(): """Execute a script in the background""" data = request.json @@ -166,7 +263,6 @@ def run_script(): script_config = script_map[script_name] - # Handle both string commands and dict of county-specific commands if isinstance(script_config, str): cmd = ['bash', '-c', script_config] elif isinstance(script_config, dict): @@ -184,10 +280,8 @@ def run_script(): else: return jsonify({'error': 'Invalid script configuration'}), 400 - # Generate a unique job ID job_id = f"{script_name}_{county}_{datetime.now().strftime('%Y%m%d_%H%M%S')}" - # Start process in background def run_command(): try: process = subprocess.Popen( @@ -223,14 +317,10 @@ def job_status(job_id): """Get status and logs for a job""" is_running = job_id in running_processes logs = process_logs.get(job_id, []) - - return jsonify({ - 'job_id': job_id, - 'running': is_running, - 'logs': logs - }) + return jsonify({'job_id': job_id, 'running': is_running, 'logs': logs}) @app.route('/api/cancel-job', methods=['POST']) +@require_auth def cancel_job(): """Cancel a running job""" data = request.json @@ -238,7 +328,6 @@ def cancel_job(): if not job_id: return jsonify({'error': 'No job ID specified'}), 400 - if job_id not in running_processes: return jsonify({'error': 'Job not found or already completed'}), 404 @@ -251,7 +340,27 @@ def cancel_job(): except Exception as e: return jsonify({'error': str(e)}), 500 +@app.route('/api/upload-file', methods=['POST']) +@require_auth +def upload_file(): + """Upload a shapefile ZIP to a path under /data""" + dest = request.form.get('dest') + if not dest or not dest.startswith('/data/'): + return jsonify({'error': 'Invalid destination path'}), 400 + + if 'file' not in request.files: + return jsonify({'error': 'No file provided'}), 400 + + f = request.files['file'] + if not f.filename.lower().endswith('.zip'): + return jsonify({'error': 'File must be a ZIP archive (.zip)'}), 400 + + os.makedirs(os.path.dirname(dest), exist_ok=True) + f.save(dest) + return jsonify({'success': True, 'path': dest}) + @app.route('/api/clear-data', methods=['POST']) +@require_auth def clear_data(): """Delete all files from every county data directory under /data.""" counties = get_counties() @@ -273,24 +382,6 @@ def clear_data(): return jsonify({'error': '; '.join(errors)}), 500 return jsonify({'success': True, 'removed': removed}) -@app.route('/api/upload-file', methods=['POST']) -def upload_file(): - """Upload a shapefile ZIP to a path under /data""" - dest = request.form.get('dest') - if not dest or not dest.startswith('/data/'): - return jsonify({'error': 'Invalid destination path'}), 400 - - if 'file' not in request.files: - return jsonify({'error': 'No file provided'}), 400 - - f = request.files['file'] - if not f.filename.lower().endswith('.zip'): - return jsonify({'error': 'File must be a ZIP archive (.zip)'}), 400 - - os.makedirs(os.path.dirname(dest), exist_ok=True) - f.save(dest) - return jsonify({'success': True, 'path': dest}) - @app.route('/api/system-stats') def system_stats(): """Return current CPU, memory, and disk usage percentages.""" @@ -308,12 +399,7 @@ def system_stats(): def list_files(): """List available GeoJSON files recursively with modification times.""" data_dir = '/data' - - files = { - 'diff': [], - 'osm': [], - 'county': [] - } + files = {'diff': [], 'osm': [], 'county': []} if os.path.exists(data_dir): for root, dirs, filenames in os.walk(data_dir): @@ -324,7 +410,6 @@ def list_files(): rel_path = os.path.relpath(filepath, data_dir).replace('\\', '/') mtime = datetime.fromtimestamp(os.path.getmtime(filepath)).strftime('%Y-%m-%d %H:%M') entry = {'path': rel_path, 'mtime': mtime} - if 'diff' in filename.lower(): files['diff'].append(entry) elif 'osm' in filename.lower(): @@ -344,12 +429,14 @@ def serve_data(filename): _ALLOWED_CONFIGS = {'counties.yml', 'exceptions.yml'} @app.route('/api/config/', methods=['GET']) +@require_auth def download_config(filename): if filename not in _ALLOWED_CONFIGS: return jsonify({'error': 'Not found'}), 404 return send_from_directory('/data', filename, as_attachment=True) @app.route('/api/config/', methods=['POST']) +@require_auth def upload_config(filename): if filename not in _ALLOWED_CONFIGS: return jsonify({'error': 'Not found'}), 404 @@ -426,6 +513,7 @@ def get_counties_api(): return jsonify({'error': str(e)}), 500 @app.route('/api/counties', methods=['POST']) +@require_auth def save_counties(): data = request.json counties = data.get('counties', []) @@ -481,16 +569,15 @@ def get_exceptions(): @app.route('/api/exceptions', methods=['POST']) +@require_auth def save_exceptions(): data = request.json corrections = data.get('corrections', []) - for item in corrections: if not isinstance(item.get('from'), str) or not item['from'].strip(): return jsonify({'error': 'Each correction must have a non-empty "from" field'}), 400 if not isinstance(item.get('to'), str) or not item['to'].strip(): return jsonify({'error': 'Each correction must have a non-empty "to" field'}), 400 - tmp = EXCEPTIONS_FILE + '.tmp' with open(tmp, 'w') as f: yaml.dump({'corrections': corrections}, f, diff --git a/web/static/map.js b/web/static/map.js index 7c43d93..3bfac45 100644 --- a/web/static/map.js +++ b/web/static/map.js @@ -1059,12 +1059,7 @@ async function saveAcceptedItems() { } }); - // Get the date of the loaded data from the server - let dataDate = ''; - try { - const dateResp = await fetch('/api/latest-date'); - if (dateResp.ok) dataDate = (await dateResp.json()).date || ''; - } catch (_) {} + const dataDate = new Date().toISOString().slice(0, 10).replace(/-/g, ''); const prefix = [loadedCounty, loadedDataType, dataDate].filter(Boolean).join('-'); function triggerDownload(features, label) { diff --git a/web/templates/index.html b/web/templates/index.html index c6686c7..2e9168d 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -378,6 +378,7 @@
+ {% if current_user %}
↓ counties.yml @@ -387,9 +388,19 @@
+ {% endif %} Counties Street Exceptions Open Map Viewer +
+ {% if current_user %} + {{ current_user }} +
+ +
+ {% else %} + Login + {% endif %}
@@ -564,10 +575,17 @@