diff --git a/format_mojolicious.sh b/format_mojolicious.sh
new file mode 100755
index 0000000..d435db8
--- /dev/null
+++ b/format_mojolicious.sh
@@ -0,0 +1,298 @@
+#!/bin/bash
+
+# Mojolicious Template Formatter API Script with Authentication
+# Usage: ./format_mojolicious_auth.sh [OPTIONS]
+#
+# Options:
+# -u, --url URL API base URL (default: http://localhost:8000)
+# -t, --token TOKEN API token (overrides .env and environment)
+# -r, --remove-empty Remove empty lines from output
+# -f, --file FILE Read input from file instead of heredoc
+# -o, --output FILE Write output to file instead of stdout
+# -h, --help Show this help message
+#
+# Authentication:
+# The script automatically looks for API tokens in this order:
+# 1. Command line --token option
+# 2. .env file (FLASK_API_TOKEN=...)
+# 3. Environment variable FLASK_API_TOKEN
+# 4. Environment variable API_TOKEN
+
+set -euo pipefail
+
+# Default values
+API_URL="http://localhost:8000"
+API_TOKEN=""
+REMOVE_EMPTY=false
+INPUT_FILE=""
+OUTPUT_FILE=""
+
+# Function to show help
+show_help() {
+ cat << EOF
+Mojolicious Template Formatter API Script with Authentication
+
+Usage: $0 [OPTIONS]
+
+Options:
+ -u, --url URL API base URL (default: http://localhost:8000)
+ -t, --token TOKEN API token (overrides .env and environment)
+ -r, --remove-empty Remove empty lines from output
+ -f, --file FILE Read input from file instead of heredoc
+ -o, --output FILE Write output to file instead of stdout
+ -h, --help Show this help message
+
+Authentication:
+ The script automatically looks for API tokens in this order:
+ 1. Command line --token option
+ 2. .env file (FLASK_API_TOKEN=...)
+ 3. Environment variable FLASK_API_TOKEN
+ 4. Environment variable API_TOKEN
+
+Examples:
+ # Format heredoc (uses token from .env or environment)
+ $0
+
+ # Format with specific token
+ $0 --token your_api_token_here
+
+ # Format with empty line removal
+ $0 --remove-empty
+
+ # Format from file
+ $0 --file template.ep
+
+ # Format and save to file
+ $0 --file input.ep --output formatted.ep
+
+ # Use different API URL
+ $0 --url https://your-server.com
+
+ # Format heredoc inline
+ $0 <<< '<% my \$name = "World"; %>
Hello <%= \$name %>!
'
+
+Environment Setup:
+ Create a .env file with:
+ FLASK_API_TOKEN=your_token_here
+
+ Or set environment variable:
+ export FLASK_API_TOKEN=your_token_here
+EOF
+}
+
+# Function to load API token from .env file
+load_token_from_env() {
+ local env_file=".env"
+
+ # Look for .env in current directory first
+ if [[ -f "$env_file" ]]; then
+ # Read FLASK_API_TOKEN from .env file
+ local token
+ token=$(grep "^FLASK_API_TOKEN=" "$env_file" 2>/dev/null | cut -d'=' -f2- | tr -d '"' | tr -d "'")
+ if [[ -n "$token" ]]; then
+ echo "$token"
+ return 0
+ fi
+ fi
+
+ # Look for .env in script directory
+ local script_dir
+ script_dir=$(dirname "$(readlink -f "$0")")
+ env_file="$script_dir/.env"
+
+ if [[ -f "$env_file" ]]; then
+ local token
+ token=$(grep "^FLASK_API_TOKEN=" "$env_file" 2>/dev/null | cut -d'=' -f2- | tr -d '"' | tr -d "'")
+ if [[ -n "$token" ]]; then
+ echo "$token"
+ return 0
+ fi
+ fi
+
+ return 1
+}
+
+# Function to get API token from various sources
+get_api_token() {
+ # 1. Use command line token if provided
+ if [[ -n "$API_TOKEN" ]]; then
+ echo "$API_TOKEN"
+ return 0
+ fi
+
+ # 2. Try to load from .env file
+ local token
+ if token=$(load_token_from_env); then
+ echo "$token"
+ return 0
+ fi
+
+ # 3. Try environment variable FLASK_API_TOKEN
+ if [[ -n "${FLASK_API_TOKEN:-}" ]]; then
+ echo "$FLASK_API_TOKEN"
+ return 0
+ fi
+
+ # 4. Try environment variable API_TOKEN
+ if [[ -n "${API_TOKEN:-}" ]]; then
+ echo "$API_TOKEN"
+ return 0
+ fi
+
+ # No token found
+ return 1
+}
+
+# Parse command line arguments
+while [[ $# -gt 0 ]]; do
+ case $1 in
+ -u|--url)
+ API_URL="$2"
+ shift 2
+ ;;
+ -t|--token)
+ API_TOKEN="$2"
+ shift 2
+ ;;
+ -r|--remove-empty)
+ REMOVE_EMPTY=true
+ shift
+ ;;
+ -f|--file)
+ INPUT_FILE="$2"
+ shift 2
+ ;;
+ -o|--output)
+ OUTPUT_FILE="$2"
+ shift 2
+ ;;
+ -h|--help)
+ show_help
+ exit 0
+ ;;
+ *)
+ echo "Unknown option: $1" >&2
+ echo "Use --help for usage information" >&2
+ exit 1
+ ;;
+ esac
+done
+
+# Get API token
+if ! API_TOKEN=$(get_api_token); then
+ cat << EOF >&2
+Error: No API token found!
+
+The script needs an API token for authentication. Please provide one using:
+
+1. Command line option:
+ $0 --token your_token_here
+
+2. .env file in current or script directory:
+ echo "FLASK_API_TOKEN=your_token_here" > .env
+
+3. Environment variable:
+ export FLASK_API_TOKEN=your_token_here
+
+To generate a new token, run:
+ cd /path/to/your/flask/app
+ python3 website_fixed_auth.py --genenv
+
+EOF
+ exit 1
+fi
+
+# Function to format text via API with authentication
+format_text() {
+ local input_text="$1"
+ local api_endpoint="${API_URL}/api/format"
+
+ # Prepare JSON payload
+ local json_payload
+ json_payload=$(jq -n \
+ --arg text "$input_text" \
+ --argjson remove_empty "$REMOVE_EMPTY" \
+ '{text: $text, remove_empty: $remove_empty}')
+
+ # Make authenticated API request
+ local response
+ response=$(curl -s -X POST \
+ -H "Content-Type: application/json" \
+ -H "Authorization: Bearer $API_TOKEN" \
+ -d "$json_payload" \
+ "$api_endpoint")
+
+ # Check for errors
+ local error
+ error=$(echo "$response" | jq -r '.error // empty')
+ if [[ -n "$error" ]]; then
+ case "$error" in
+ "Invalid origin"|"Invalid CSRF token"|"Invalid API token"|"Invalid token"|"Unauthorized")
+ echo "Authentication Error: $error" >&2
+ echo "Please check your API token and try again." >&2
+ echo "Use --help for authentication setup instructions." >&2
+ ;;
+ "Rate limit exceeded")
+ echo "Rate Limit Error: $error" >&2
+ echo "Please wait a moment and try again." >&2
+ ;;
+ *)
+ echo "API Error: $error" >&2
+ ;;
+ esac
+ exit 1
+ fi
+
+ # Extract formatted text
+ echo "$response" | jq -r '.formatted_text'
+}
+
+# Validate API token format (basic check)
+if [[ ! "$API_TOKEN" =~ ^[a-fA-F0-9]{40}$ ]]; then
+ echo "Warning: API token format looks unusual (expected 40 hex characters)" >&2
+ echo "Token: ${API_TOKEN:0:10}..." >&2
+fi
+
+# Get input text
+if [[ -n "$INPUT_FILE" ]]; then
+ # Read from file
+ if [[ ! -f "$INPUT_FILE" ]]; then
+ echo "Error: File '$INPUT_FILE' not found" >&2
+ exit 1
+ fi
+ input_text=$(cat "$INPUT_FILE")
+elif [[ ! -t 0 ]]; then
+ # Read from stdin/pipe
+ input_text=$(cat)
+else
+ # Interactive heredoc input
+ echo "Enter your Mojolicious template code (end with Ctrl+D):"
+ input_text=$(cat)
+fi
+
+# Check if input is empty
+if [[ -z "${input_text// }" ]]; then
+ echo "Error: No input provided" >&2
+ exit 1
+fi
+
+# Validate input size (client-side check)
+input_length=${#input_text}
+if [[ $input_length -gt 10000 ]]; then
+ echo "Error: Input too large ($input_length characters, max 10,000)" >&2
+ exit 1
+fi
+
+# Format the text
+echo "Formatting $input_length characters..." >&2
+formatted_text=$(format_text "$input_text")
+
+# Output result
+if [[ -n "$OUTPUT_FILE" ]]; then
+ echo "$formatted_text" > "$OUTPUT_FILE"
+ echo "Formatted text saved to: $OUTPUT_FILE" >&2
+else
+ echo "$formatted_text"
+fi
+
+echo "Formatting completed successfully." >&2
\ No newline at end of file
diff --git a/website.py b/website.py
index cd00b7e..42be81d 100644
--- a/website.py
+++ b/website.py
@@ -3,15 +3,18 @@ import sys
import secrets
import tempfile
import subprocess
+import re
+import logging
+from logging.handlers import RotatingFileHandler
from flask import Flask, request, render_template_string, jsonify, send_file, redirect, url_for, flash
from werkzeug.utils import secure_filename
+from werkzeug.exceptions import RequestEntityTooLarge
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
# --- ENV GENERATOR ---
@@ -21,6 +24,7 @@ def generate_env():
with open(".env", "w") as f:
f.write(f"FLASK_SECRET_KEY={secret_key}\n")
f.write(f"FLASK_API_TOKEN={api_token}\n")
+ f.write(f"FLASK_DEBUG=False\n")
print("✅ .env generated")
print(f"FLASK_SECRET_KEY={secret_key}")
print(f"FLASK_API_TOKEN={api_token}")
@@ -37,57 +41,302 @@ MOJO_FMT_PATH = os.path.join(BASE_DIR, "mojofmt.py")
SECRET_KEY = os.environ.get('FLASK_SECRET_KEY')
API_TOKEN = os.environ.get('FLASK_API_TOKEN')
+DEBUG_MODE = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true'
+
if not SECRET_KEY or not API_TOKEN:
raise RuntimeError("FLASK_SECRET_KEY and FLASK_API_TOKEN must be set (use --genenv to create .env)")
+# --- FLASK APP CONFIGURATION ---
app = Flask(__name__)
app.secret_key = SECRET_KEY
+
+# Enhanced app configuration
app.config.update(
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SECURE=True,
- SESSION_COOKIE_SAMESITE='Lax'
+ SESSION_COOKIE_SAMESITE='Lax',
+ MAX_CONTENT_LENGTH=1024 * 1024, # 1MB limit
+ DEBUG=DEBUG_MODE
)
+# --- LOGGING CONFIGURATION ---
+if not app.debug:
+ if not os.path.exists('logs'):
+ os.mkdir('logs')
+ file_handler = RotatingFileHandler('logs/app.log', maxBytes=10240, backupCount=10)
+ file_handler.setFormatter(logging.Formatter(
+ '%(asctime)s %(levelname)s %(name)s %(threadName)s : %(message)s'
+ ))
+ file_handler.setLevel(logging.INFO)
+ app.logger.addHandler(file_handler)
+ app.logger.setLevel(logging.INFO)
+ app.logger.info('Flask application startup')
+
+# --- SECURITY CONFIGURATION ---
+def configure_security_headers(app):
+ """Configure security headers for Flask-Talisman 1.0.0 compatibility"""
+
+ # Enhanced CSP configuration - different for development vs production
+ if app.debug:
+ # Development CSP - allows inline styles and scripts for easier development
+ csp = {
+ 'default-src': ["'self'"],
+ 'script-src': ["'self'", 'https://cdn.jsdelivr.net', "'unsafe-inline'"],
+ 'style-src': ["'self'", 'https://cdn.jsdelivr.net', "'unsafe-inline'"],
+ 'img-src': ["'self'", 'data:'],
+ 'font-src': ["'self'", 'https://cdn.jsdelivr.net'],
+ 'connect-src': ["'self'"],
+ 'frame-ancestors': ["'none'"],
+ 'base-uri': ["'self'"],
+ 'form-action': ["'self'"]
+ }
+ else:
+ # Production CSP - strict security policy
+ csp = {
+ 'default-src': ["'self'"],
+ 'script-src': ["'self'", 'https://cdn.jsdelivr.net'],
+ 'style-src': ["'self'", 'https://cdn.jsdelivr.net'],
+ 'img-src': ["'self'", 'data:'],
+ 'font-src': ["'self'", 'https://cdn.jsdelivr.net'],
+ 'connect-src': ["'self'"],
+ 'frame-ancestors': ["'none'"],
+ 'base-uri': ["'self'"],
+ 'form-action': ["'self'"]
+ }
+
+ # Initialize Talisman with enhanced configuration
+ Talisman(app,
+ content_security_policy=csp,
+ force_https=not app.debug, # Only force HTTPS in production
+ strict_transport_security=not app.debug, # Only enable HSTS in production
+ strict_transport_security_max_age=31536000 if not app.debug else 0,
+ strict_transport_security_include_subdomains=not app.debug,
+ frame_options='DENY',
+ x_content_type_options=True,
+ referrer_policy='strict-origin-when-cross-origin'
+ )
+
+ # Manual headers for Flask-Talisman 1.0.0 compatibility
+ @app.after_request
+ def add_security_headers(response):
+ # Disable deprecated X-XSS-Protection (version 1.0.0 compatibility)
+ response.headers['X-XSS-Protection'] = '0'
+
+ # Add Permissions-Policy for privacy (version 1.0.0 compatibility)
+ response.headers['Permissions-Policy'] = 'browsing-topics=()'
+
+ # Additional security headers
+ response.headers['X-Download-Options'] = 'noopen'
+ response.headers['X-Permitted-Cross-Domain-Policies'] = 'none'
+
+ return response
+
+# Apply security configuration
+configure_security_headers(app)
+
# 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', "'unsafe-inline'"],
- 'style-src': ["'self'", 'https://cdn.jsdelivr.net', "'unsafe-inline'"],
-}
-Talisman(app, content_security_policy=csp)
+# Enhanced rate limiting configuration
+limiter = Limiter(
+ key_func=get_remote_address,
+ app=app,
+ default_limits=["1000/day", "100/hour"],
+ storage_uri="memory://" # Use Redis in production
+)
-limiter = Limiter(key_func=get_remote_address, app=app, default_limits=["100/hour"])
+# --- INPUT VALIDATION ---
+def validate_input_text(text: str) -> bool:
+ """Validate input text for security"""
+ # Size limit (1MB)
+ if len(text.encode('utf-8')) > 1024 * 1024:
+ return False
+
+ # Content validation - only allow printable characters and common whitespace
+ if not re.match(r'^[\x20-\x7E\s]*$', text):
+ return False
+
+ return True
-def get_formatter_version():
+def validate_api_input(data):
+ """Validate API input data"""
+ if not isinstance(data, dict):
+ raise ValueError("Invalid data format")
+
+ input_text = data.get("input_text", "")
+ if not isinstance(input_text, str):
+ raise ValueError("input_text must be a string")
+
+ if len(input_text.strip()) == 0:
+ raise ValueError("input_text cannot be empty")
+
+ if len(input_text.encode('utf-8')) > 1024 * 1024: # 1MB
+ raise ValueError("input_text too large")
+
+ return True
+
+# --- FILE UPLOAD VALIDATION ---
+ALLOWED_EXTENSIONS = {'.ep'}
+MAX_FILE_SIZE = 1024 * 1024 # 1MB
+
+def validate_file_upload(file):
+ """Enhanced file validation"""
+ if not file or not file.filename:
+ return False, "No file provided"
+
+ # Check file extension
+ filename = secure_filename(file.filename)
+ if not any(filename.endswith(ext) for ext in ALLOWED_EXTENSIONS):
+ return False, "Invalid file type"
+
+ # Check file size
+ file.seek(0, 2) # Seek to end
+ size = file.tell()
+ file.seek(0) # Reset
+
+ if size > MAX_FILE_SIZE:
+ return False, "File too large"
+
+ # Basic content validation
try:
+ content = file.read().decode('utf-8')
+ file.seek(0) # Reset
+
+ # Validate content is text
+ if not content.isprintable() and not all(c in '\n\r\t' for c in content if not c.isprintable()):
+ return False, "Invalid file content"
+
+ except UnicodeDecodeError:
+ return False, "File must be valid UTF-8 text"
+
+ return True, "Valid file"
+
+# --- SECURE SUBPROCESS EXECUTION ---
+def get_formatter_version():
+ """Get formatter version safely"""
+ try:
+ if not os.path.exists(MOJO_FMT_PATH):
+ app.logger.warning(f"Formatter script not found at {MOJO_FMT_PATH}")
+ return "Unknown"
+
result = subprocess.run(
["python3", MOJO_FMT_PATH, "--version"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
- text=True
+ text=True,
+ timeout=10 # Add timeout
)
if result.returncode == 0:
return result.stdout.strip()
else:
+ app.logger.warning(f"Could not get formatter version: {result.stderr}")
return "Unknown"
+ except subprocess.TimeoutExpired:
+ app.logger.warning("Formatter version check timed out")
+ return "Unknown"
except Exception as e:
app.logger.warning(f"Could not get formatter version: {e}")
return "Unknown"
+def run_mojofmt(input_text: str) -> str:
+ """Secure mojofmt execution with comprehensive validation"""
+ # Validate input first
+ if not validate_input_text(input_text):
+ raise ValueError("Invalid input text")
+
+ 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")
+
+ # Secure file writing
+ try:
+ with open(in_path, 'w', encoding='utf-8') as f:
+ f.write(input_text)
+ except Exception as e:
+ app.logger.error(f"Failed to write input file: {e}")
+ raise RuntimeError("Failed to write input file")
+
+ # Validate formatter script exists
+ if not os.path.exists(MOJO_FMT_PATH):
+ app.logger.error(f"Formatter script not found at {MOJO_FMT_PATH}")
+ raise RuntimeError("Formatter script not found")
+
+ # Secure subprocess execution with timeout
+ try:
+ result = subprocess.run(
+ ['python3', MOJO_FMT_PATH, '-o', out_path, in_path],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ timeout=30, # Add timeout
+ cwd=tmpdir # Set working directory
+ )
+ except subprocess.TimeoutExpired:
+ app.logger.error("Formatting operation timed out")
+ raise RuntimeError("Formatting operation timed out")
+ except Exception as e:
+ app.logger.error(f"Subprocess execution failed: {e}")
+ raise RuntimeError("Formatting failed")
+
+ if result.returncode != 0:
+ # Don't expose internal error details
+ app.logger.error(f"mojofmt failed with return code {result.returncode}: {result.stderr}")
+ raise RuntimeError("Formatting failed")
+
+ try:
+ with open(out_path, 'r', encoding='utf-8') as f:
+ return f.read()
+ except Exception as e:
+ app.logger.error(f"Failed to read output file: {e}")
+ raise RuntimeError("Failed to read output file")
+
FORMATTER_VERSION = get_formatter_version()
+# --- AUTHENTICATION ---
def require_api_token(f):
+ """API token authentication decorator (unchanged)"""
@wraps(f)
def decorated(*args, **kwargs):
auth = request.headers.get('Authorization', '')
if not auth.startswith('Bearer ') or auth[len('Bearer '):] != API_TOKEN:
+ app.logger.warning(f"Unauthorized API access attempt from {request.remote_addr}")
return jsonify({"error": "Unauthorized"}), 401
return f(*args, **kwargs)
return decorated
+# --- ERROR HANDLERS ---
+@app.errorhandler(RequestEntityTooLarge)
+def handle_file_too_large(e):
+ app.logger.warning(f"File too large from {request.remote_addr}")
+ return jsonify({"error": "File too large"}), 413
+
+@app.errorhandler(400)
+def handle_bad_request(e):
+ app.logger.warning(f"Bad request from {request.remote_addr}: {e}")
+ return jsonify({"error": "Bad request"}), 400
+
+@app.errorhandler(404)
+def handle_not_found(e):
+ return jsonify({"error": "Not found"}), 404
+
+@app.errorhandler(429)
+def handle_rate_limit(e):
+ app.logger.warning(f"Rate limit exceeded from {request.remote_addr}")
+ return jsonify({"error": "Rate limit exceeded"}), 429
+
+@app.errorhandler(500)
+def handle_internal_error(e):
+ app.logger.error(f"Internal server error: {e}")
+ return jsonify({"error": "Internal server error"}), 500
+
+@app.errorhandler(Exception)
+def handle_exception(e):
+ """Global exception handler"""
+ app.logger.error(f"Unhandled exception: {e}", exc_info=True)
+ return jsonify({"error": "Internal server error"}), 500
+
+# --- HTML TEMPLATE (unchanged) ---
HTML_TEMPLATE = """
@@ -459,29 +708,15 @@ HTML_TEMPLATE = """
"""
-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")
- with open(in_path, 'w', encoding='utf-8') as f:
- f.write(input_text)
- result = subprocess.run(
- ['python3', MOJO_FMT_PATH, '-o', out_path, in_path],
- stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
- )
- if result.returncode != 0:
- raise RuntimeError(f"mojofmt failed:\n{result.stderr.strip()}")
- with open(out_path, 'r', encoding='utf-8') as f:
- return f.read()
-
-
+# --- ROUTES ---
@app.route("/", methods=["GET", "POST"])
def index():
+ """Main page route with enhanced security"""
# Handle both GET and POST requests
# POST requests are redirected to use AJAX instead
if request.method == "POST":
# If someone tries to submit the form traditionally, redirect to GET
+ app.logger.info(f"Traditional form submission redirected from {request.remote_addr}")
return redirect(url_for('index'))
# Serve the HTML template
@@ -490,52 +725,93 @@ def index():
formatter_version=FORMATTER_VERSION
)
-
@app.route("/api/format_ajax", methods=["POST"])
-@limiter.limit("10/minute")
+@limiter.limit("5/minute") # Stricter rate limiting
def api_format_ajax():
- """AJAX endpoint for formatting text"""
+ """AJAX endpoint for formatting text with enhanced security"""
if not request.is_json:
+ app.logger.warning(f"Non-JSON request to format_ajax from {request.remote_addr}")
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:
+ data = request.get_json()
+ validate_api_input(data) # Enhanced input validation
+
+ input_text = data.get("input_text", "")
+ remove_empty = bool(data.get("remove_empty", False))
+
+ app.logger.info(f"Processing format request from {request.remote_addr}, size: {len(input_text)} chars")
+
formatted_text = run_mojofmt(input_text)
if remove_empty:
formatted_text = "\n".join(
line for line in formatted_text.splitlines() if line.strip()
)
+
+ app.logger.info(f"Successfully formatted text for {request.remote_addr}")
return jsonify({"formatted_text": formatted_text})
+
+ except ValueError as e:
+ app.logger.warning(f"Validation error from {request.remote_addr}: {e}")
+ return jsonify({"error": str(e)}), 400
except RuntimeError as e:
- return jsonify({"error": str(e)}), 500
-
+ app.logger.error(f"Runtime error from {request.remote_addr}: {e}")
+ return jsonify({"error": "Processing failed"}), 500
+ except Exception as e:
+ app.logger.error(f"Unexpected error from {request.remote_addr}: {e}")
+ return jsonify({"error": "Internal server error"}), 500
@app.route("/api/format", methods=["POST"])
-@limiter.limit("10/minute")
+@limiter.limit("5/minute") # Stricter rate limiting
@require_api_token
def api_format():
- """Original API endpoint with token authentication"""
+ """Original API endpoint with token authentication and enhanced security"""
if not request.is_json:
+ app.logger.warning(f"Non-JSON request to format API from {request.remote_addr}")
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:
+ data = request.get_json()
+
+ # Validate input using the same validation as AJAX endpoint
+ input_data = {
+ "input_text": data.get("text", ""),
+ "remove_empty": data.get("remove_empty", False)
+ }
+ validate_api_input(input_data)
+
+ text = input_data["input_text"]
+ remove_empty = bool(input_data["remove_empty"])
+
+ app.logger.info(f"Processing authenticated API request from {request.remote_addr}, size: {len(text)} chars")
+
formatted = run_mojofmt(text)
if remove_empty:
formatted = "\n".join([line for line in formatted.splitlines() if line.strip()])
+
+ app.logger.info(f"Successfully processed authenticated API request from {request.remote_addr}")
return jsonify({"formatted_text": formatted})
+
+ except ValueError as e:
+ app.logger.warning(f"API validation error from {request.remote_addr}: {e}")
+ return jsonify({"error": str(e)}), 400
except RuntimeError as e:
- return jsonify({"error": str(e)}), 500
+ app.logger.error(f"API runtime error from {request.remote_addr}: {e}")
+ return jsonify({"error": "Processing failed"}), 500
+ except Exception as e:
+ app.logger.error(f"API unexpected error from {request.remote_addr}: {e}")
+ return jsonify({"error": "Internal server error"}), 500
+# --- HEALTH CHECK ENDPOINT ---
+@app.route("/health", methods=["GET"])
+def health_check():
+ """Health check endpoint"""
+ return jsonify({
+ "status": "healthy",
+ "version": FORMATTER_VERSION,
+ "debug": app.debug
+ })
if __name__ == "__main__":
- app.run(host="0.0.0.0", port=8000, debug=True)
\ No newline at end of file
+ app.logger.info(f"Starting Flask application in {'debug' if app.debug else 'production'} mode")
+ app.run(host="0.0.0.0", port=8000, debug=app.debug)
\ No newline at end of file