Add shell script to use API and update security for website

This commit is contained in:
2025-08-11 06:27:21 +01:00
parent 72b3b23a63
commit dd90e45410
2 changed files with 624 additions and 50 deletions

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="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"; %><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

View File

@@ -3,15 +3,18 @@ import sys
import secrets import secrets
import tempfile import tempfile
import subprocess 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 flask import Flask, request, render_template_string, jsonify, send_file, redirect, url_for, flash
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from werkzeug.exceptions import RequestEntityTooLarge
from flask_talisman import Talisman from flask_talisman import Talisman
from flask_limiter import Limiter from flask_limiter import Limiter
from flask_limiter.util import get_remote_address from flask_limiter.util import get_remote_address
from flask_cors import CORS from flask_cors import CORS
from functools import wraps from functools import wraps
from dotenv import load_dotenv from dotenv import load_dotenv
from flask import send_file
import json import json
# --- ENV GENERATOR --- # --- ENV GENERATOR ---
@@ -21,6 +24,7 @@ def generate_env():
with open(".env", "w") as f: with open(".env", "w") as f:
f.write(f"FLASK_SECRET_KEY={secret_key}\n") f.write(f"FLASK_SECRET_KEY={secret_key}\n")
f.write(f"FLASK_API_TOKEN={api_token}\n") f.write(f"FLASK_API_TOKEN={api_token}\n")
f.write(f"FLASK_DEBUG=False\n")
print("✅ .env generated") print("✅ .env generated")
print(f"FLASK_SECRET_KEY={secret_key}") print(f"FLASK_SECRET_KEY={secret_key}")
print(f"FLASK_API_TOKEN={api_token}") 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') SECRET_KEY = os.environ.get('FLASK_SECRET_KEY')
API_TOKEN = os.environ.get('FLASK_API_TOKEN') 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: 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)") raise RuntimeError("FLASK_SECRET_KEY and FLASK_API_TOKEN must be set (use --genenv to create .env)")
# --- FLASK APP CONFIGURATION ---
app = Flask(__name__) app = Flask(__name__)
app.secret_key = SECRET_KEY app.secret_key = SECRET_KEY
# Enhanced app configuration
app.config.update( app.config.update(
SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SECURE=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 # Enable CORS for all routes
CORS(app) CORS(app)
# CSP — only self and cdn.jsdelivr.net for scripts/styles # Enhanced rate limiting configuration
csp = { limiter = Limiter(
'default-src': ["'self'"], key_func=get_remote_address,
'script-src': ["'self'", 'https://cdn.jsdelivr.net', "'unsafe-inline'"], app=app,
'style-src': ["'self'", 'https://cdn.jsdelivr.net', "'unsafe-inline'"], default_limits=["1000/day", "100/hour"],
} storage_uri="memory://" # Use Redis in production
Talisman(app, content_security_policy=csp) )
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
def get_formatter_version(): # 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: 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( result = subprocess.run(
["python3", MOJO_FMT_PATH, "--version"], ["python3", MOJO_FMT_PATH, "--version"],
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
text=True text=True,
timeout=10 # Add timeout
) )
if result.returncode == 0: if result.returncode == 0:
return result.stdout.strip() return result.stdout.strip()
else: 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" return "Unknown"
except Exception as e: except Exception as e:
app.logger.warning(f"Could not get formatter version: {e}") app.logger.warning(f"Could not get formatter version: {e}")
return "Unknown" 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() FORMATTER_VERSION = get_formatter_version()
# --- AUTHENTICATION ---
def require_api_token(f): def require_api_token(f):
"""API token authentication decorator (unchanged)"""
@wraps(f) @wraps(f)
def decorated(*args, **kwargs): def decorated(*args, **kwargs):
auth = request.headers.get('Authorization', '') auth = request.headers.get('Authorization', '')
if not auth.startswith('Bearer ') or auth[len('Bearer '):] != API_TOKEN: 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 jsonify({"error": "Unauthorized"}), 401
return f(*args, **kwargs) return f(*args, **kwargs)
return decorated 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 = """ HTML_TEMPLATE = """
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
@@ -459,29 +708,15 @@ HTML_TEMPLATE = """
</html> </html>
""" """
def run_mojofmt(input_text: str) -> str: # --- ROUTES ---
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()
@app.route("/", methods=["GET", "POST"]) @app.route("/", methods=["GET", "POST"])
def index(): def index():
"""Main page route with enhanced security"""
# Handle both GET and POST requests # Handle both GET and POST requests
# POST requests are redirected to use AJAX instead # POST requests are redirected to use AJAX instead
if request.method == "POST": if request.method == "POST":
# If someone tries to submit the form traditionally, redirect to GET # 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')) return redirect(url_for('index'))
# Serve the HTML template # Serve the HTML template
@@ -490,52 +725,93 @@ def index():
formatter_version=FORMATTER_VERSION formatter_version=FORMATTER_VERSION
) )
@app.route("/api/format_ajax", methods=["POST"]) @app.route("/api/format_ajax", methods=["POST"])
@limiter.limit("10/minute") @limiter.limit("5/minute") # Stricter rate limiting
def api_format_ajax(): def api_format_ajax():
"""AJAX endpoint for formatting text""" """AJAX endpoint for formatting text with enhanced security"""
if not request.is_json: 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 return jsonify({"error": "JSON body required"}), 400
try:
data = request.get_json() data = request.get_json()
validate_api_input(data) # Enhanced input validation
input_text = data.get("input_text", "") input_text = data.get("input_text", "")
remove_empty = bool(data.get("remove_empty", False)) remove_empty = bool(data.get("remove_empty", False))
if not input_text.strip(): app.logger.info(f"Processing format request from {request.remote_addr}, size: {len(input_text)} chars")
return jsonify({"error": "No input data provided."}), 400
try:
formatted_text = run_mojofmt(input_text) formatted_text = run_mojofmt(input_text)
if remove_empty: if remove_empty:
formatted_text = "\n".join( formatted_text = "\n".join(
line for line in formatted_text.splitlines() if line.strip() 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.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"]) @app.route("/api/format", methods=["POST"])
@limiter.limit("10/minute") @limiter.limit("5/minute") # Stricter rate limiting
@require_api_token @require_api_token
def api_format(): def api_format():
"""Original API endpoint with token authentication""" """Original API endpoint with token authentication and enhanced security"""
if not request.is_json: 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 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: 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) formatted = run_mojofmt(text)
if remove_empty: if remove_empty:
formatted = "\n".join([line for line in formatted.splitlines() if line.strip()]) 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
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__": if __name__ == "__main__":
app.run(host="0.0.0.0", port=8000, debug=True) 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)