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 %}
{% for m in messages %}- {{ m }}
{% endfor %}
{% endif %}
- {% endwith %}
+
-
@@ -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