from functools import cache import json from flask import ( Flask, make_response, redirect, request, jsonify, render_template, send_from_directory, send_file, session, url_for, ) import os import json import requests from datetime import datetime import dotenv import markdown import markdown.extensions.fenced_code from flask_session import Session from yubico_client import Yubico from functools import wraps import hmac import hashlib dotenv.load_dotenv() app = Flask(__name__) app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", os.urandom(24)) app.config["SESSION_TYPE"] = "filesystem" app.config["SESSION_PERMANENT"] = False Session(app) # Yubikey settings YUBICO_CLIENT_ID = os.getenv("YUBICO_CLIENT_ID") YUBICO_SECRET_KEY = os.getenv("YUBICO_SECRET_KEY") YUBIKEY_ID = os.getenv("YUBIKEY_ID") # The first 12 characters of your YubiKey OTP # Authentication function def login_required(f): @wraps(f) def decorated_function(*args, **kwargs): if not session.get('authenticated'): return redirect(url_for('login', next=request.url)) return f(*args, **kwargs) return decorated_function def find(name, path): for root, dirs, files in os.walk(path): if name in files: return os.path.join(root, name) # Assets routes @app.route("/assets/<path:path>") def send_assets(path): if path.endswith(".json"): return send_from_directory( "templates/assets", path, mimetype="application/json" ) if os.path.isfile("templates/assets/" + path): return send_from_directory("templates/assets", path) # Try looking in one of the directories filename: str = path.split("/")[-1] if ( filename.endswith(".png") or filename.endswith(".jpg") or filename.endswith(".jpeg") or filename.endswith(".svg") ): if os.path.isfile("templates/assets/img/" + filename): return send_from_directory("templates/assets/img", filename) if os.path.isfile("templates/assets/img/favicon/" + filename): return send_from_directory("templates/assets/img/favicon", filename) return render_template("404.html"), 404 # region Special routes @app.route("/favicon.png") def faviconPNG(): return send_from_directory("templates/assets/img", "favicon.png") @app.route("/.well-known/<path:path>") def wellknown(path): # Try to proxy to https://nathan.woodburn.au/.well-known/ req = requests.get(f"https://nathan.woodburn.au/.well-known/{path}") return make_response( req.content, 200, {"Content-Type": req.headers["Content-Type"]} ) # endregion # region Main routes @app.route("/") def index(): return render_template("index.html", authenticated=session.get('authenticated', False)) # Login and authentication routes @app.route("/login", methods=["GET", "POST"]) def login(): error = None if request.method == "POST": otp = request.form.get("otp", "") # Verify the first 12 characters of the OTP match the expected YubiKey ID if not otp or len(otp) < 12 or otp[:12] != YUBIKEY_ID: error = "Invalid YubiKey OTP" else: try: # Initialize Yubico client client = Yubico(YUBICO_CLIENT_ID, YUBICO_SECRET_KEY) # Verify the OTP with Yubico servers verification = client.verify(otp) if verification: session['authenticated'] = True next_url = request.args.get('next', url_for('emergency')) return redirect(next_url) else: error = "YubiKey authentication failed" except Exception as e: error = f"Authentication error: {str(e)}" custom_instructions = os.getenv("YUBIKEY_INSTRUCTIONS") return render_template("login.html", error=error, custom_instructions=custom_instructions) @app.route("/logout") def logout(): session.pop('authenticated', None) return redirect(url_for('index')) @app.route("/emergency") @login_required def emergency(): # Check if emergency.md exists emergency_path = os.path.join(os.path.dirname(__file__), "emergency.md") if os.path.exists(emergency_path): with open(emergency_path, 'r') as f: emergency_content = f.read() # Convert markdown to HTML with enhanced extensions emergency_html = markdown.markdown( emergency_content, extensions=[ 'fenced_code', 'codehilite', 'tables', 'nl2br', 'sane_lists' # Ensures proper list rendering ] ) return render_template("emergency.html", content=emergency_html) else: return render_template("emergency.html", content="<p>No emergency information available.</p>") @app.route("/<path:path>") def catch_all(path: str): if os.path.isfile("templates/" + path): return render_template(path) # Try with .html if os.path.isfile("templates/" + path + ".html"): return render_template(path + ".html") if os.path.isfile("templates/" + path.strip("/") + ".html"): return render_template(path.strip("/") + ".html") # Try to find a file matching if path.count("/") < 1: # Try to find a file matching filename = find(path, "templates") if filename: return send_file(filename) return render_template("404.html"), 404 # endregion # region Webhook @app.route("/webhook/update", methods=["POST"]) def webhook_update(): # Get the webhook secret from environment webhook_secret = os.getenv("WEBHOOK_SECRET") if not webhook_secret: return jsonify({"error": "Webhook not configured"}), 500 # Verify X-Webhook-Signature header signature = request.headers.get("X-Webhook-Signature") if not signature: return jsonify({"error": "Missing signature"}), 401 # Get request body payload = request.get_data() # Verify signature expected_signature = hmac.new( webhook_secret.encode(), payload, hashlib.sha256 ).hexdigest() if not hmac.compare_digest(signature, expected_signature): return jsonify({"error": "Invalid signature"}), 401 # Process the update try: data = request.json if not data or not isinstance(data, dict) or "content" not in data: return jsonify({"error": "Invalid payload format"}), 400 emergency_content = data["content"] emergency_path = os.path.join(os.path.dirname(__file__), "emergency.md") # Write the new content to the file with open(emergency_path, "w") as f: f.write(emergency_content) return jsonify({"success": True, "message": "Emergency content updated"}), 200 except Exception as e: app.logger.error(f"Webhook error: {str(e)}") return jsonify({"error": f"Update failed: {str(e)}"}), 500 # endregion # region Error Catching # 404 catch all @app.errorhandler(404) def not_found(e): return render_template("404.html"), 404 # endregion if __name__ == "__main__": app.run(debug=True, port=5000, host="0.0.0.0")