Compare commits

..

9 Commits

3 changed files with 1272 additions and 6 deletions

147
README.md
View File

@@ -1,7 +1,140 @@
# Mojolicious Template Formatter
Formatter for Mojolicious Embedded Perl templates (.ep, .htm.ep, .html.ep)
mojofmt formats HTML and Mojolicious EP templates without breaking embedded Perl. It understands line directives (% ...), inline tags (<% ... %>), raw HTML blocks, and can reformat multi-line Perl blocks inside <% ... %> using perltidy (with a safe fallback if perltidy isnt available).
mojofmt formats HTML and Mojolicious EP templates without breaking embedded Perl. It understands line directives (% ...), inline tags (<% ... %>), raw HTML blocks, and can reformat multi-line Perl blocks inside <% ... %> using perltidy (with a safe fallback if perltidy isn't available).
## Online Web Interface
**Try it online:** [https://mojofmt.bjsystems.co.uk](https://mojofmt.bjsystems.co.uk)
The web interface provides:
- Real-time formatting with syntax highlighting
- File upload support for .ep files
- Download formatted results
- No registration required
- Secure processing with rate limiting
## API Access
### REST API Endpoint
The formatter is available as a REST API for integration into your development workflow:
**Base URL:** `https://mojofmt.bjsystems.co.uk/api/format`
**Authentication:** Bearer token required
**Request API Token:** Please create an issue in this repository requesting an API token. Include:
- Your intended use case
- Expected usage volume
- Contact information
### API Usage
```bash
curl -X POST https://mojofmt.bjsystems.co.uk/api/format \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-d '{
"text": "<% my $name = \"World\"; %><h1>Hello <%= $name %>!</h1>",
"remove_empty": false
}'
```
**Response:**
```json
{
"formatted_text": "<%\nmy $name = \"World\";\n%>\n<h1>Hello <%= $name %>!</h1>"
}
```
### API Parameters
- `text` (string, required): The Mojolicious template code to format
- `remove_empty` (boolean, optional): Remove empty lines from output (default: false)
### Rate Limits
- 5 requests per minute per IP for formatting endpoints
- 1000 requests per day, 100 requests per hour for general usage
## Bash Script for API Integration
A convenient bash script is provided for command-line API access:
### Installation
```bash
# Download the script
wget https://raw.githubusercontent.com/yourusername/mojofmt/main/format_mojolicious.sh
chmod +x format_mojolicious.sh
```
### Setup Authentication
Create a `.env` file with your API token:
```bash
echo "FLASK_API_TOKEN=your_token_here" > .env
```
Or set as environment variable:
```bash
export FLASK_API_TOKEN=your_token_here
```
### Script Usage
**Basic formatting (interactive input):**
```bash
./format_mojolicious.sh
# Enter your template code, then Ctrl+D
```
**Format from file:**
```bash
./format_mojolicious.sh --file template.ep
```
**Format and save to file:**
```bash
./format_mojolicious.sh --file input.ep --output formatted.ep
```
**Remove empty lines:**
```bash
./format_mojolicious.sh --remove-empty --file template.ep
```
**Use different API URL:**
```bash
./format_mojolicious.sh --url https://your-server.com --file template.ep
```
**Pipe input:**
```bash
cat template.ep | ./format_mojolicious.sh
echo '<% my $x = 1; %><p><%= $x %></p>' | ./format_mojolicious.sh
```
### Script Options
- `-u, --url URL`: API base URL (default: https://mojofmt.bjsystems.co.uk)
- `-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 interactive input
- `-o, --output FILE`: Write output to file instead of stdout
- `-h, --help`: Show help message
### Authentication Priority
The script 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`
## Features
@@ -87,7 +220,7 @@ Increase logging:
- Extended multi-line Perl blocks:
- Detected when <% (or <%-) is on a line by itself, and %> (or -%>) is on a line by itself
- The inner Perl is dedented, wrapped in do { ... } and run through perltidy; if that fails or perltidy is missing, a brace-aware fallback indenter is used
- Inner lines are re-indented to match the opening/closing delimiters indentation
- Inner lines are re-indented to match the opening/closing delimiter's indentation
- EOL normalization:
- Input CRLF/CR are normalized internally; output can be forced to lf/crlf or preserve the original
@@ -158,7 +291,7 @@ Directories are walked recursively; only matching files are formatted.
## Tips and caveats
- perltidy recommended: For best results on complex Perl inside templates, install perltidy. mojofmt falls back to a brace-aware indenter for extended blocks, but wont do token-level Perl formatting without perltidy.
- perltidy recommended: For best results on complex Perl inside templates, install perltidy. mojofmt falls back to a brace-aware indenter for extended blocks, but won't do token-level Perl formatting without perltidy.
- Extended block detection: Only triggers when the opening <% (or <%-) and closing %> (or -%>) are on their own lines. Inline <% ... %> on the same line are handled by the inline path.
- Raw blocks: Content inside pre/script/style/textarea is not changed.
- Chomp markers: Left/right chomps (<%- and -%>) are preserved and not moved.
@@ -170,7 +303,7 @@ Directories are walked recursively; only matching files are formatted.
- perltidy non-zero exit N in debug logs:
- mojofmt wraps extended blocks in do { ... } for perltidy; if it still fails, run perltidy manually on the wrapper to see the error.
- Ensure perltidy is on PATH or pass --perltidy /path/to/perltidy.
- Extended block didnt reformat:
- Extended block didn't reformat:
- Confirm the delimiters are on their own lines (no code on the <% / %> lines).
- Run with --log-level debug to see whether perltidy or the naive indenter handled the block.
- Spaces around Perl keywords:
@@ -193,12 +326,14 @@ Generate a diff without writing:
- Open an issue or pull request with a clear description and a minimal repro template
- Please include before/after snippets and your command-line flags
- If you modify formatting rules, add/adjust a self-test where possible
- **For API access:** Create an issue requesting an API token with your use case details
## License
See LICENSE file in this repository. If you dont have one yet, consider MIT or Apache-2.0.
See LICENSE file in this repository. If you don't have one yet, consider MIT or Apache-2.0.
## Acknowledgments
- Mojolicious and Mojo::Template for the EP syntax
- Perl::Tidy for robust Perl formatting
- Perl::Tidy for robust Perl formatting

298
format_mojolicious.sh Executable file
View File

@@ -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="https://mojofmt.bjsystems.co.uk"
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"; %><h1>Hello <%= \$name %>!</h1>'
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

833
website.py Normal file
View File

@@ -0,0 +1,833 @@
import os
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
import json
# --- 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")
f.write(f"FLASK_DEBUG=False\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")
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',
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)
# 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
)
# --- 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 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,
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 = """
<!DOCTYPE html>
<html>
<head>
<title>Mojolicious Template Code Formatter</title>
<style>
body {font-family:'Segoe UI', Arial, sans-serif; margin:0; padding:0;
background:linear-gradient(90deg,#fdeff9 0%,#ecb6ff 100%);
height:100vh; display:flex; flex-direction:column;}
header {background:#8247c2; color:#fff; display:flex; align-items:center;
justify-content:space-between; padding:1em;}
header h1 {margin:0; font-size:1.6em;}
.icon-links {display:flex; align-items:center;}
.icon-links a {display:inline-flex; align-items:center; margin-left:16px; color:white;}
.icon-links a svg {width:28px; height:28px; fill:white;}
.flash-messages ul {margin:0; padding:0; list-style:none; color:#d91454;text-align:center;}
.flash-messages {min-height:20px;}
form {flex:1; display:flex; flex-direction:column;}
.container {display:flex; flex-direction:row; gap:16px; padding:20px; flex:1;
box-sizing:border-box; height:calc(100vh - 140px);}
.panel {background:#fff; border-radius:10px; box-shadow:0 8px 18px -7px #ac83ce44;
padding:22px; flex:1 1 50%; min-width:300px; display:flex; flex-direction:column; height:100%;}
label {font-weight:bold; margin-bottom:5px;}
.controls {
display: flex;
justify-content: space-between; /* left group stays left, right goes right */
align-items: center;
margin: 14px 0 8px 0;
}
.controls-left,
.controls-right {
display: flex;
gap: 8px;
}
.version-label {
font-size: 0.8em; /* smaller than title */
font-weight: normal; /* keep normal weight */
margin-left: 8px; /* space between title and version */
opacity: 0.8; /* slightly subdued */
}
.file-upload {
display: flex;
align-items: center; /* perfect vertical centering */
gap: 0.9rem; /* nice spacing, tweak as needed */
margin-top: 18px; /* whitespace above this row */
margin-bottom: 10px; /* space below */
}
.file-upload label {
margin: 0;
font-weight: bold;
white-space: nowrap;
line-height: 1.5;
/* align baseline to button - fixes Chrome/Firefox difference */
display: flex;
align-items: center;
}
.file-upload input[type="file"] {
margin: 0;
padding: 3px 0;
font-size: 1em;
/* Remove outline/extra vertical space browsers add */
vertical-align: middle;
}
button {background:#a950e6; border:none; color:#fff; border-radius:5px;
padding:9px 16px; font-size:15px; cursor:pointer; box-shadow:0 2px 7px -3px #bb76c1;}
button:hover {background:#7634a2;}
button:disabled {background:#ccc; cursor:not-allowed;}
input[type="file"] {margin-bottom:10px;}
textarea {width:100%; flex:1 1 auto; min-height:30px; max-height:60vh; font-family:'Fira Mono', monospace;
font-size:15px; border:2px solid #bdb0da; background:#f6eafe;
border-radius:7px; color:#432d67; resize:vertical; transition:height .2s; margin-left:auto;margin-right:auto;}
select {padding:6px 10px; border-radius:5px; border:1px solid #b993d6;
background:#eee0f6; color:#6d378d; font-size:15px;}
#output_block {background:#16151a !important; color:white !important; border-radius:8px; padding:1em;
margin-top:10px; overflow-y:auto; resize:vertical; white-space:pre-wrap;
border:2px solid #bdb0da; flex:1 1 auto; min-height:0;}
@media (max-width:800px) {.container {flex-direction:column; height:auto;}
.panel {height:auto; min-width:0;}}
.output-header {
margin-top: 8px;
display: flex;
justify-content: space-between; /* Push left and right sections apart */
align-items: center; /* Vertically align */
}
.syntax-select {
display: flex;
align-items: center;
gap: 6px; /* Space between label and dropdown */
}
</style>
<link href="https://cdn.jsdelivr.net/npm/prismjs@1/themes/prism-tomorrow.min.css"
rel="stylesheet"
integrity="sha384-wFjoQjtV1y5jVHbt0p35Ui8aV8GVpEZkyF99OXWqP/eNJDU93D3Ugxkoyh6Y2I4A"
crossorigin="anonymous" />
<script src="https://cdn.jsdelivr.net/npm/prismjs@1/prism.min.js"
integrity="sha384-guvyurEPUUeAKyomgXWf/3v1dYx+etnMZ0CeHWsUXSqT1sRwh4iLpr9Z+Lw631fX"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/prismjs@1/components/prism-markup.min.js"
integrity="sha384-HkMr0bZB9kBW4iVtXn6nd35kO/L/dQtkkUBkL9swzTEDMdIe5ExJChVDSnC79aNA"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/prismjs@1/components/prism-perl.min.js"
integrity="sha384-TBezSCOvSMb3onoz0oj0Yi0trDW0ZQIz7CaneDU5q4gsUSqaPKMD6DlepFFJj+qa"
crossorigin="anonymous"></script>
</head>
<body>
<header>
<h1>
Mojolicious Template Code Formatter
<span class="version-label">v{{ formatter_version }}</span>
</h1>
<div class="icon-links">
<a href="https://github.com/brianread108/mojofmt" target="_blank" aria-label="GitHub">
<svg viewBox="0 0 16 16" role="img" aria-hidden="true" width="28" height="28" fill="white" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd"
d="M8 0C3.58 0 0 3.58 0 8a8.003 8.003 0 0 0 5.47 7.59c.4.07.55-.17.55-.38
0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94
-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53
.63-.01 1.08.58 1.23.82.72 1.21
1.87.87 2.33.66.07-.52.28-.87.51-1.07
-1.78-.2-3.64-.89-3.64-3.95
0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12
0 0 .67-.21 2.2.82a7.5 7.5 0 0 1
4.01 0c1.53-1.04 2.2-.82 2.2-.82
.44 1.1.16 1.92.08 2.12.51.56.82
1.27.82 2.15 0 3.07-1.87 3.75-3.65
3.95.29.25.54.73.54 1.48
0 1.07-.01 1.93-.01 2.2
0 .21.15.46.55.38A8.003 8.003 0 0 0
16 8c0-4.42-3.58-8-8-8z"/>
</svg>
</a>
<a href="https://mojolicious.org" target="_blank" aria-label="Mojolicious Website">
<svg viewBox="0 0 64 64" width="28" height="28" role="img" aria-hidden="false" fill="white" xmlns="http://www.w3.org/2000/svg">
<path d="M32 2C20 18 20 30 20 40a12 12 0 0 0 24 0c0-14-12-24-12-38zM32 56a16 16 0 0 1-16-16c0-12 16-20 16-38 8 16 16 24 16 38a16 16 0 0 1-16 16z"/>
</svg>
</a>
</div>
</header>
<div class="flash-messages" id="flash-messages">
</div>
<form id="mainform" onsubmit="return false;">
<div class="container">
<div class="panel">
<label for="input_text">Input data:</label>
<textarea name="input_text" id="input_text"></textarea>
<div class="file-upload">
<label for="input_file">Upload a file:</label>
<input type="file" name="input_file" id="input_file" accept=".ep">
</div>
<div class="controls">
<div class="controls-left">
<button type="button" id="format_btn">Format</button>
</div>
<div class="controls-right">
<button type="button" id="download_btn">Download</button>
<button type="button" id="clear_btn">Clear</button>
</div>
</div>
<label style="margin-top:12px;">
<input type="checkbox" name="remove_empty" id="remove_empty">
Remove empty lines from output
</label>
</div>
<div class="panel">
<div class="output-header">
<label>Formatted Output:</label>
<div class="syntax-select">
<label for="syntaxmode">Output Syntax:</label>
<select id="syntaxmode" name="syntaxmode">
<option value="none">Plain Text</option>
<option value="perl">Perl</option>
<option value="html">HTML</option>
</select>
</div>
</div>
<pre id="output_block"><code id="output_code" class="language-none"></code></pre>
</div>
</div>
</form>
<script>
document.addEventListener("DOMContentLoaded", function() {
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 = '';
// Prevent form submission completely
mainForm.addEventListener("submit", function(e) {
e.preventDefault();
return false;
});
// Flash message functions
function showFlashMessage(message, isError = true) {
flashMessagesEl.innerHTML = `<ul><li style="color: ${isError ? '#d91454' : '#28a745'}">${message}</li></ul>`;
setTimeout(() => {
flashMessagesEl.innerHTML = '';
}, 5000);
}
// 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();
autoResizeTextarea();
});
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 = 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();
});
</script>
</body>
</html>
"""
# --- 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
return render_template_string(
HTML_TEMPLATE,
formatter_version=FORMATTER_VERSION
)
@app.route("/api/format_ajax", methods=["POST"])
@limiter.limit("5/minute") # Stricter rate limiting
def api_format_ajax():
"""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
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:
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("5/minute") # Stricter rate limiting
@require_api_token
def api_format():
"""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
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:
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.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)