From 72b3b23a6352812260d26457bb32878d2134fd06 Mon Sep 17 00:00:00 2001 From: Brian Read Date: Sun, 10 Aug 2025 14:49:10 +0100 Subject: [PATCH] Make website work with ajax --- website.py | 552 +++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 410 insertions(+), 142 deletions(-) diff --git a/website.py b/website.py index 2ab0e69..cd00b7e 100644 --- a/website.py +++ b/website.py @@ -1,51 +1,84 @@ import os +import sys +import secrets import tempfile import subprocess -from flask import ( - Flask, request, render_template_string, - jsonify, send_file, redirect, url_for, flash -) +from flask import Flask, request, render_template_string, jsonify, send_file, redirect, url_for, flash from werkzeug.utils import secure_filename from flask_talisman import Talisman from flask_limiter import Limiter from flask_limiter.util import get_remote_address +from flask_cors import CORS from functools import wraps from dotenv import load_dotenv +from flask import send_file +import json -# Load environment variables from .env (for local dev only) +# --- ENV GENERATOR --- +def generate_env(): + secret_key = secrets.token_urlsafe(32) + api_token = secrets.token_hex(20) + with open(".env", "w") as f: + f.write(f"FLASK_SECRET_KEY={secret_key}\n") + f.write(f"FLASK_API_TOKEN={api_token}\n") + print("✅ .env generated") + print(f"FLASK_SECRET_KEY={secret_key}") + print(f"FLASK_API_TOKEN={api_token}") + print("⚠ Keep .env out of version control!") + sys.exit(0) + +if "--genenv" in sys.argv: + generate_env() + +# --- LOAD ENV --- load_dotenv() - BASE_DIR = os.path.dirname(os.path.abspath(__file__)) MOJO_FMT_PATH = os.path.join(BASE_DIR, "mojofmt.py") -# Get secrets from environment variables SECRET_KEY = os.environ.get('FLASK_SECRET_KEY') API_TOKEN = os.environ.get('FLASK_API_TOKEN') if not SECRET_KEY or not API_TOKEN: - raise RuntimeError("FLASK_SECRET_KEY and FLASK_API_TOKEN must be set") + raise RuntimeError("FLASK_SECRET_KEY and FLASK_API_TOKEN must be set (use --genenv to create .env)") app = Flask(__name__) app.secret_key = SECRET_KEY - -# Secure cookies app.config.update( SESSION_COOKIE_HTTPONLY=True, - SESSION_COOKIE_SECURE=True, # requires HTTPS in production + SESSION_COOKIE_SECURE=True, SESSION_COOKIE_SAMESITE='Lax' ) -# Security headers with Flask‑Talisman (CSP allowing only self + cdn.jsdelivr.net) +# Enable CORS for all routes +CORS(app) + +# CSP — only self and cdn.jsdelivr.net for scripts/styles csp = { 'default-src': ["'self'"], - 'script-src': ["'self'", 'https://cdn.jsdelivr.net'], + 'script-src': ["'self'", 'https://cdn.jsdelivr.net', "'unsafe-inline'"], 'style-src': ["'self'", 'https://cdn.jsdelivr.net', "'unsafe-inline'"], } Talisman(app, content_security_policy=csp) -# Rate limiting limiter = Limiter(key_func=get_remote_address, app=app, default_limits=["100/hour"]) -# Token authentication decorator +def get_formatter_version(): + try: + result = subprocess.run( + ["python3", MOJO_FMT_PATH, "--version"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + if result.returncode == 0: + return result.stdout.strip() + else: + return "Unknown" + except Exception as e: + app.logger.warning(f"Could not get formatter version: {e}") + return "Unknown" + +FORMATTER_VERSION = get_formatter_version() + def require_api_token(f): @wraps(f) def decorated(*args, **kwargs): @@ -55,97 +88,175 @@ def require_api_token(f): return f(*args, **kwargs) return decorated -# ----------------------------- HTML TEMPLATE ----------------------------- HTML_TEMPLATE = """ Mojolicious Template Code Formatter - - - - + + + +
-

Mojolicious Template Code Formatter

- +

+ Mojolicious Template Code Formatter + v{{ formatter_version }} +

+
-
- {% with messages = get_flashed_messages() %} - {% if messages %}{% endif %} - {% endwith %} +
-
+
- - - -
- - - -
+ +
+ + +
+
+
+ +
+
+ + +
+
+
-
-
{% if formatted_text is defined %}{{ formatted_text|e }}{% endif %}
+
@@ -154,48 +265,202 @@ HTML_TEMPLATE = """ const inputTextEl = document.getElementById("input_text"); const inputFileEl = document.getElementById("input_file"); const outputCodeEl = document.getElementById("output_code"); + const clearBtnEl = document.getElementById("clear_btn"); + const formatBtn = document.getElementById("format_btn"); + const downloadBtn = document.getElementById("download_btn"); + const syntaxModeEl = document.getElementById("syntaxmode"); + const removeEmptyEl = document.getElementById("remove_empty"); + const flashMessagesEl = document.getElementById("flash-messages"); + const mainForm = document.getElementById("mainform"); + + let currentFormattedText = ''; + let uploadedFilename = ''; - function clearOutput() { - outputCodeEl.textContent = ''; - Prism.highlightElement(outputCodeEl); + // Prevent form submission completely + mainForm.addEventListener("submit", function(e) { + e.preventDefault(); + return false; + }); + + // Flash message functions + function showFlashMessage(message, isError = true) { + flashMessagesEl.innerHTML = ``; + setTimeout(() => { + flashMessagesEl.innerHTML = ''; + }, 5000); } - function clearFields() { - inputTextEl.value = ''; + + // Update file input display + function updateFileInputDisplay() { + const fileInput = inputFileEl; + const label = fileInput.parentElement; + const displaySpan = label.querySelector('.filename-display') || document.createElement('span'); + displaySpan.className = 'filename-display'; + displaySpan.style.marginLeft = '10px'; + displaySpan.style.fontWeight = 'normal'; + displaySpan.style.color = '#666'; + + if (uploadedFilename) { + displaySpan.textContent = `(${uploadedFilename})`; + } else { + displaySpan.textContent = '(none)'; + } + + if (!label.querySelector('.filename-display')) { + label.appendChild(displaySpan); + } + } + + // Initialize filename display + updateFileInputDisplay(); + + // Resize textarea + function autoResizeTextarea() { + inputTextEl.style.height = "auto"; + let max = Math.max(60, Math.round(window.innerHeight*0.6)); + inputTextEl.style.height = Math.min(inputTextEl.scrollHeight, max) + "px"; + } + + inputTextEl.addEventListener("input", function() { clearOutput(); - } - window.clearFields = clearFields; + autoResizeTextarea(); + }); - inputTextEl.addEventListener("input", clearOutput); - inputFileEl.addEventListener("change", e => { - const file = e.target.files[0]; - if (!file) return; + clearBtnEl.addEventListener("click", function() { + inputTextEl.value = ''; + inputFileEl.value = ''; + uploadedFilename = ''; + updateFileInputDisplay(); + autoResizeTextarea(); + clearOutput(); + }); + + inputFileEl.addEventListener("change", function(event) { + const file = event.target.files[0]; + if (!file) { + uploadedFilename = ''; + updateFileInputDisplay(); + return; + } + + uploadedFilename = file.name; + updateFileInputDisplay(); + const reader = new FileReader(); - reader.onload = ev => { - inputTextEl.value = ev.target.result; + reader.onload = function(e) { + inputTextEl.value = e.target.result; + autoResizeTextarea(); clearOutput(); }; reader.readAsText(file); }); + syntaxModeEl.addEventListener("change", highlightOutput); + + function clearOutput() { + outputCodeEl.textContent = ''; + currentFormattedText = ''; + Prism.highlightElement(outputCodeEl); + } + + function highlightOutput() { + outputCodeEl.className = ""; + if (syntaxModeEl.value === "perl") outputCodeEl.classList.add("language-perl"); + else if (syntaxModeEl.value === "html") outputCodeEl.classList.add("language-markup"); + else outputCodeEl.classList.add("language-none"); + Prism.highlightElement(outputCodeEl); + } + + // Generate download filename + function getDownloadFilename() { + if (uploadedFilename) { + const lastDotIndex = uploadedFilename.lastIndexOf('.'); + if (lastDotIndex > 0) { + const name = uploadedFilename.substring(0, lastDotIndex); + const ext = uploadedFilename.substring(lastDotIndex); + return `${name}_fmt${ext}`; + } else { + return `${uploadedFilename}_fmt.txt`; + } + } else { + return 'formatted_fmt.txt'; + } + } + + // Format button AJAX handler + formatBtn.addEventListener("click", function() { + const inputText = inputTextEl.value.trim(); + if (!inputText) { + showFlashMessage("No input data provided."); + return; + } + + // Show formatting state + const originalText = formatBtn.textContent; + formatBtn.textContent = "Formatting..."; + formatBtn.disabled = true; + + // Prepare data + const formData = { + input_text: inputText, + remove_empty: removeEmptyEl.checked + }; + + // Make AJAX request + fetch('/api/format_ajax', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(formData) + }) + .then(response => response.json()) + .then(data => { + if (data.error) { + showFlashMessage(data.error); + } else { + currentFormattedText = data.formatted_text; + outputCodeEl.textContent = currentFormattedText; + highlightOutput(); + } + }) + .catch(error => { + showFlashMessage("Error formatting text: " + error.message); + }) + .finally(() => { + formatBtn.textContent = originalText; + formatBtn.disabled = false; + }); + }); + + // Download button AJAX handler + downloadBtn.addEventListener("click", function() { + if (!currentFormattedText) { + showFlashMessage("No formatted text to download. Please format first."); + return; + } + + // Create and trigger download with proper filename + const blob = new Blob([currentFormattedText], { type: 'text/plain' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = getDownloadFilename(); + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + }); + highlightOutput(); }); - - function highlightOutput() { - const mode = document.getElementById("syntaxmode").value; - const code = document.getElementById("output_code"); - code.className = ""; - if (mode === "perl") code.classList.add("language-perl"); - else if (mode === "html") code.classList.add("language-markup"); - else code.classList.add("language-none"); - Prism.highlightElement(code); - } """ -# ----------------------------- Core logic ----------------------------- def run_mojofmt(input_text: str) -> str: + app.logger.debug("Running mojofmt") with tempfile.TemporaryDirectory() as tmpdir: in_path = os.path.join(tmpdir, "input.txt") out_path = os.path.join(tmpdir, "output.txt") @@ -210,64 +475,67 @@ def run_mojofmt(input_text: str) -> str: with open(out_path, 'r', encoding='utf-8') as f: return f.read() + @app.route("/", methods=["GET", "POST"]) def index(): - input_text, formatted_text = "", None + # Handle both GET and POST requests + # POST requests are redirected to use AJAX instead if request.method == "POST": - action = request.form.get("action") - f_obj = request.files.get("input_file") - input_text = request.form.get("input_text", "") - if f_obj and f_obj.filename: - try: - input_text = f_obj.read().decode('utf-8') - except Exception as e: - flash(f"Error reading uploaded file: {e}") - return render_template_string(HTML_TEMPLATE, input_text=input_text) + # If someone tries to submit the form traditionally, redirect to GET + return redirect(url_for('index')) + + # Serve the HTML template + return render_template_string( + HTML_TEMPLATE, + formatter_version=FORMATTER_VERSION + ) - if action in ("format", "download"): - if not input_text.strip(): - flash("No input data provided.") - return render_template_string(HTML_TEMPLATE, input_text=input_text) - try: - formatted_text = run_mojofmt(input_text) - except RuntimeError as e: - flash(str(e)) - return render_template_string(HTML_TEMPLATE, input_text=input_text) - if action == "download": - tmpfile = tempfile.NamedTemporaryFile(delete=False, mode='w', encoding='utf-8', suffix='.txt') - tmpfile.write(formatted_text) - tmpfile.close() - return redirect(url_for('download_file', filename=os.path.basename(tmpfile.name))) - return render_template_string(HTML_TEMPLATE, input_text=input_text, formatted_text=formatted_text) -@app.route("/download/") -def download_file(filename): - safe_name = secure_filename(filename) - path = os.path.join(tempfile.gettempdir(), safe_name) - if not os.path.exists(path): - return "File not found", 404 - resp = send_file(path, as_attachment=True, download_name="formatted_output.txt") +@app.route("/api/format_ajax", methods=["POST"]) +@limiter.limit("10/minute") +def api_format_ajax(): + """AJAX endpoint for formatting text""" + if not request.is_json: + return jsonify({"error": "JSON body required"}), 400 + + data = request.get_json() + input_text = data.get("input_text", "") + remove_empty = bool(data.get("remove_empty", False)) + + if not input_text.strip(): + return jsonify({"error": "No input data provided."}), 400 + try: - os.unlink(path) - except Exception: - pass - return resp + formatted_text = run_mojofmt(input_text) + if remove_empty: + formatted_text = "\n".join( + line for line in formatted_text.splitlines() if line.strip() + ) + return jsonify({"formatted_text": formatted_text}) + except RuntimeError as e: + return jsonify({"error": str(e)}), 500 + @app.route("/api/format", methods=["POST"]) @limiter.limit("10/minute") @require_api_token def api_format(): + """Original API endpoint with token authentication""" if not request.is_json: return jsonify({"error": "JSON body required"}), 400 data = request.get_json() text = data.get("text", "") + remove_empty = bool(data.get("remove_empty")) if not text: return jsonify({"error": "Missing 'text'"}), 400 try: formatted = run_mojofmt(text) + if remove_empty: + formatted = "\n".join([line for line in formatted.splitlines() if line.strip()]) return jsonify({"formatted_text": formatted}) except RuntimeError as e: return jsonify({"error": str(e)}), 500 + if __name__ == "__main__": - app.run(host="0.0.0.0", port=8000, debug=True) # debug=False in prod \ No newline at end of file + app.run(host="0.0.0.0", port=8000, debug=True) \ No newline at end of file