Add auth system, fix map defaults, controls overflow, fix latest-date ref
This commit is contained in:
+133
-46
@@ -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/<filename>', 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/<filename>', 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,
|
||||
|
||||
Reference in New Issue
Block a user