Compare commits
7 Commits
1706b8240e
...
main
Author | SHA1 | Date | |
---|---|---|---|
65c32231cc | |||
1d17a6f4a4 | |||
826c6aca07 | |||
471075ee30 | |||
dd90e45410 | |||
72b3b23a63 | |||
9d0d4fb48c |
152
README.md
152
README.md
@@ -1,14 +1,140 @@
|
|||||||
<<<<<<< HEAD
|
|
||||||
# mojofmt
|
|
||||||
|
|
||||||
Formatter for Mojolicious Embedded Perl templates (.ep, .htm.ep, .html.ep)
|
|
||||||
|
|
||||||
=======
|
|
||||||
# Mojolicious Template Formatter
|
# Mojolicious Template Formatter
|
||||||
|
|
||||||
Formatter for Mojolicious Embedded Perl templates (.ep, .htm.ep, .html.ep)
|
Formatter for Mojolicious Embedded Perl templates (.ep, .htm.ep, .html.ep)
|
||||||
|
|
||||||
>>>>>>> 59bfbde88e2686d25b7f6a14692814da8f440e05
|
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).
|
||||||
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
|
## Features
|
||||||
|
|
||||||
@@ -94,7 +220,7 @@ Increase logging:
|
|||||||
- Extended multi-line Perl blocks:
|
- Extended multi-line Perl blocks:
|
||||||
- Detected when <% (or <%-) is on a line by itself, and %> (or -%>) is on a line by itself
|
- 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
|
- 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 delimiter’s indentation
|
- Inner lines are re-indented to match the opening/closing delimiter's indentation
|
||||||
- EOL normalization:
|
- EOL normalization:
|
||||||
- Input CRLF/CR are normalized internally; output can be forced to lf/crlf or preserve the original
|
- Input CRLF/CR are normalized internally; output can be forced to lf/crlf or preserve the original
|
||||||
|
|
||||||
@@ -165,7 +291,7 @@ Directories are walked recursively; only matching files are formatted.
|
|||||||
|
|
||||||
## Tips and caveats
|
## 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 won’t 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.
|
- 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.
|
- Raw blocks: Content inside pre/script/style/textarea is not changed.
|
||||||
- Chomp markers: Left/right chomps (<%- and -%>) are preserved and not moved.
|
- Chomp markers: Left/right chomps (<%- and -%>) are preserved and not moved.
|
||||||
@@ -177,7 +303,7 @@ Directories are walked recursively; only matching files are formatted.
|
|||||||
- perltidy non-zero exit N in debug logs:
|
- 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.
|
- 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.
|
- Ensure perltidy is on PATH or pass --perltidy /path/to/perltidy.
|
||||||
- Extended block didn’t reformat:
|
- Extended block didn't reformat:
|
||||||
- Confirm the delimiters are on their own lines (no code on the <% / %> lines).
|
- 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.
|
- Run with --log-level debug to see whether perltidy or the naive indenter handled the block.
|
||||||
- Spaces around Perl keywords:
|
- Spaces around Perl keywords:
|
||||||
@@ -200,12 +326,14 @@ Generate a diff without writing:
|
|||||||
- Open an issue or pull request with a clear description and a minimal repro template
|
- 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
|
- Please include before/after snippets and your command-line flags
|
||||||
- If you modify formatting rules, add/adjust a self-test where possible
|
- 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
|
## License
|
||||||
|
|
||||||
See LICENSE file in this repository. If you don’t 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
|
## Acknowledgments
|
||||||
|
|
||||||
- Mojolicious and Mojo::Template for the EP syntax
|
- 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
298
format_mojolicious.sh
Executable 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
|
801
mojofmt.py.bak
801
mojofmt.py.bak
@@ -1,801 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
mojofmt: Formatter for Mojolicious Embedded Perl templates (.ep, .htm.ep, .html.ep)
|
|
||||||
|
|
||||||
Features (Phase 1 + additions):
|
|
||||||
- Indent HTML structure and Mojolicious line directives consistently
|
|
||||||
- Preserve chomp markers (<%- ... -%>) and do not alter newline semantics
|
|
||||||
- Handle helper begin/end blocks and Perl brace-based indentation for directives
|
|
||||||
- Treat pre/script/style/textarea content as opaque (unchanged)
|
|
||||||
- Optionally normalize spacing inside <% %> delimiters and after % directives
|
|
||||||
- Integrate with perltidy for Perl code formatting (if available on PATH)
|
|
||||||
- CLI with --write/--check/--diff, --out, --stdin/--stdout modes
|
|
||||||
- --self-test for sanity checks (includes perltidy probe)
|
|
||||||
- Logging: --log-level error|info|debug (and --verbose as shorthand for info)
|
|
||||||
- Optional --perl-keyword-spacing to aggressively insert spaces after Perl keywords
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import difflib
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from dataclasses import dataclass, replace as dc_replace
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Iterable, List, Optional, Tuple
|
|
||||||
|
|
||||||
VERSION = "0.1.7"
|
|
||||||
|
|
||||||
DEFAULT_EXTENSIONS = (".ep", ".htm.ep", ".html.ep")
|
|
||||||
VOID_ELEMENTS = {
|
|
||||||
"area", "base", "br", "col", "embed", "hr", "img", "input",
|
|
||||||
"link", "meta", "param", "source", "track", "wbr",
|
|
||||||
}
|
|
||||||
RAW_ELEMENTS = {"pre", "script", "style", "textarea"}
|
|
||||||
|
|
||||||
logger = logging.getLogger("mojofmt")
|
|
||||||
|
|
||||||
TAG_RE = re.compile(
|
|
||||||
r"""
|
|
||||||
<
|
|
||||||
(?P<slash>/)?
|
|
||||||
(?P<name>[A-Za-z][\w:-]*)
|
|
||||||
(?P<attrs>(?:\s+[^<>]*?)?)
|
|
||||||
(?P<self>/)?
|
|
||||||
>
|
|
||||||
""",
|
|
||||||
re.VERBOSE,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Mojolicious inline tags on a single line: <%...%>
|
|
||||||
TPL_TAG_RE = re.compile(
|
|
||||||
r"""
|
|
||||||
<%
|
|
||||||
(?P<leftchomp>-)? # optional left chomp
|
|
||||||
(?P<kind>==|=|\#)? # kind: ==, =, or #
|
|
||||||
(?P<body>.*?) # inner code/comment (non-greedy, no newlines)
|
|
||||||
(?P<rightchomp>-)? # optional right chomp
|
|
||||||
%>
|
|
||||||
""",
|
|
||||||
re.VERBOSE,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Line directives: starts with % (possibly %= %== %#) after indentation
|
|
||||||
LINE_DIR_RE = re.compile(r"^(?P<indent>\s*)%(?P<kind>==|=|\#)?(?P<body>.*)$")
|
|
||||||
|
|
||||||
# Whitespace condensing for single-line normalization
|
|
||||||
WS_RE = re.compile(r"[ \t]+")
|
|
||||||
|
|
||||||
# begin/end detection (heuristic)
|
|
||||||
BEGIN_RE = re.compile(r"\bbegin\b")
|
|
||||||
END_LINE_RE = re.compile(r"^\s*%\s*end\b")
|
|
||||||
END_TAG_ONLY_RE = re.compile(r"^\s*<%-?\s*end\s*-?%>\s*$")
|
|
||||||
|
|
||||||
# leading } in a directive (e.g., % } or % }} )
|
|
||||||
LEADING_RBRACE_COUNT_RE = re.compile(r"^\s*%\s*(?P<braces>\}+)")
|
|
||||||
|
|
||||||
# <% } %> alone
|
|
||||||
TAG_CLOSING_BRACE_ONLY_RE = re.compile(r"^\s*<%-?\s*\}+\s*-?%>\s*$")
|
|
||||||
|
|
||||||
# Detect raw element opening/closing (as standalone lines)
|
|
||||||
RAW_OPEN_RE = re.compile(r"^\s*<(?P<name>pre|script|style|textarea)\b[^>]*>\s*$", re.I)
|
|
||||||
RAW_CLOSE_RE = re.compile(r"^\s*</(?P<name>pre|script|style|textarea)\s*>\s*$", re.I)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Config:
|
|
||||||
indent_width: int = 2
|
|
||||||
eol: str = "lf" # lf|crlf|preserve
|
|
||||||
normalize_delimiter_spacing: bool = True
|
|
||||||
perltidy_path: Optional[str] = None # if None, use PATH
|
|
||||||
perltidy_options: Optional[List[str]] = None
|
|
||||||
extensions: Tuple[str, ...] = DEFAULT_EXTENSIONS
|
|
||||||
respect_gitignore: bool = True
|
|
||||||
verbose: bool = False # kept for shorthand with --verbose
|
|
||||||
perl_keyword_spacing: bool = False # optional post-pass
|
|
||||||
|
|
||||||
|
|
||||||
def load_config(cli_args: argparse.Namespace) -> Config:
|
|
||||||
cfg = Config()
|
|
||||||
if cli_args.indent is not None:
|
|
||||||
cfg.indent_width = cli_args.indent
|
|
||||||
if cli_args.eol is not None:
|
|
||||||
cfg.eol = cli_args.eol
|
|
||||||
if cli_args.no_space_in_delims:
|
|
||||||
cfg.normalize_delimiter_spacing = False
|
|
||||||
if cli_args.perltidy:
|
|
||||||
cfg.perltidy_path = cli_args.perltidy
|
|
||||||
cfg.verbose = cli_args.verbose
|
|
||||||
cfg.perl_keyword_spacing = getattr(cli_args, "perl_keyword_spacing", False)
|
|
||||||
return cfg
|
|
||||||
|
|
||||||
|
|
||||||
def setup_logging(level_name: Optional[str], verbose_flag: bool) -> None:
|
|
||||||
# Determine level
|
|
||||||
if level_name:
|
|
||||||
name = level_name.lower()
|
|
||||||
elif verbose_flag:
|
|
||||||
name = "info"
|
|
||||||
else:
|
|
||||||
name = "error"
|
|
||||||
level = {
|
|
||||||
"error": logging.ERROR,
|
|
||||||
"warning": logging.WARNING,
|
|
||||||
"info": logging.INFO,
|
|
||||||
"debug": logging.DEBUG,
|
|
||||||
"critical": logging.CRITICAL,
|
|
||||||
}.get(name, logging.ERROR)
|
|
||||||
|
|
||||||
fmt = "mojofmt: %(levelname)s: %(message)s"
|
|
||||||
logging.basicConfig(level=level, format=fmt)
|
|
||||||
|
|
||||||
|
|
||||||
def detect_eol(text: str) -> str:
|
|
||||||
if "\r\n" in text:
|
|
||||||
return "crlf"
|
|
||||||
return "lf"
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_eol(text: str, eol: str) -> str:
|
|
||||||
if eol == "preserve":
|
|
||||||
return text
|
|
||||||
s = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
||||||
if eol == "lf":
|
|
||||||
return s
|
|
||||||
elif eol == "crlf":
|
|
||||||
return s.replace("\n", "\r\n")
|
|
||||||
else:
|
|
||||||
return s
|
|
||||||
|
|
||||||
|
|
||||||
_PERLTIDY_WARNED = False # avoid spamming logs if perltidy missing repeatedly
|
|
||||||
|
|
||||||
|
|
||||||
def run_perltidy(code: str, cfg: Config) -> Tuple[int, str, str]:
|
|
||||||
global _PERLTIDY_WARNED
|
|
||||||
exe = cfg.perltidy_path or shutil.which("perltidy")
|
|
||||||
if not exe:
|
|
||||||
if not _PERLTIDY_WARNED:
|
|
||||||
logger.error("perltidy not found; Perl inside template will not be reformatted")
|
|
||||||
_PERLTIDY_WARNED = True
|
|
||||||
return (127, code, "perltidy not found")
|
|
||||||
args = [exe]
|
|
||||||
default_opts = ["-i=2", "-ci=2", "-l=100", "-q", "-se", "-nbbc", "-noll"]
|
|
||||||
if cfg.perltidy_options:
|
|
||||||
args += cfg.perltidy_options
|
|
||||||
else:
|
|
||||||
args += default_opts
|
|
||||||
logger.debug("Running perltidy: %s", " ".join(args))
|
|
||||||
try:
|
|
||||||
proc = subprocess.run(
|
|
||||||
args,
|
|
||||||
input=code,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if proc.returncode != 0:
|
|
||||||
logger.debug("perltidy non-zero exit %s: %s", proc.returncode, (proc.stderr or "").strip())
|
|
||||||
return (proc.returncode, proc.stdout, proc.stderr)
|
|
||||||
except FileNotFoundError:
|
|
||||||
if not _PERLTIDY_WARNED:
|
|
||||||
logger.error("perltidy not found while executing")
|
|
||||||
_PERLTIDY_WARNED = True
|
|
||||||
return (127, code, "perltidy not found")
|
|
||||||
|
|
||||||
|
|
||||||
def perltidy_probe(cfg: Config) -> Tuple[bool, str]:
|
|
||||||
exe = cfg.perltidy_path or shutil.which("perltidy")
|
|
||||||
if not exe:
|
|
||||||
return (False, "perltidy not found on PATH (install Perl::Tidy or pass --perltidy)")
|
|
||||||
snippet = "my $x= {a=>1,b =>2 };"
|
|
||||||
rc, out, err = run_perltidy(snippet, cfg)
|
|
||||||
if rc != 0:
|
|
||||||
return (False, f"perltidy exit {rc}: {(err or '').strip()}")
|
|
||||||
want = ["my $x = {", "a => 1", "b => 2"]
|
|
||||||
if all(w in out for w in want):
|
|
||||||
return (True, f"perltidy OK: {exe}")
|
|
||||||
if out and out.strip() and out.strip() != snippet:
|
|
||||||
return (True, f"perltidy OK (non-default style): {exe}")
|
|
||||||
return (False, "perltidy produced unexpected output")
|
|
||||||
|
|
||||||
|
|
||||||
def tidy_perl_statement_oneline(code: str, cfg: Config) -> str:
|
|
||||||
rc, out, _ = run_perltidy(code, cfg)
|
|
||||||
if rc != 0:
|
|
||||||
out = code
|
|
||||||
out = out.strip()
|
|
||||||
out = " ".join(out.splitlines())
|
|
||||||
out = WS_RE.sub(" ", out).strip()
|
|
||||||
out = enforce_perl_keyword_spacing(out, cfg.perl_keyword_spacing)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def tidy_perl_expression(code: str, cfg: Config) -> str:
|
|
||||||
wrapped = f"do {{ {code} }}"
|
|
||||||
rc, out, _ = run_perltidy(wrapped, cfg)
|
|
||||||
if rc != 0:
|
|
||||||
inner = code.strip()
|
|
||||||
return enforce_perl_keyword_spacing(inner, cfg.perl_keyword_spacing)
|
|
||||||
text = out
|
|
||||||
try:
|
|
||||||
start = text.index("{")
|
|
||||||
depth = 0
|
|
||||||
end_idx = None
|
|
||||||
for i in range(start, len(text)):
|
|
||||||
ch = text[i]
|
|
||||||
if ch == "{":
|
|
||||||
depth += 1
|
|
||||||
elif ch == "}":
|
|
||||||
depth -= 1
|
|
||||||
if depth == 0:
|
|
||||||
end_idx = i
|
|
||||||
break
|
|
||||||
if end_idx is None:
|
|
||||||
inner = code.strip()
|
|
||||||
else:
|
|
||||||
inner = text[start + 1 : end_idx]
|
|
||||||
except ValueError:
|
|
||||||
inner = code.strip()
|
|
||||||
inner = " ".join(line.strip() for line in inner.splitlines())
|
|
||||||
inner = WS_RE.sub(" ", inner).strip()
|
|
||||||
inner = enforce_perl_keyword_spacing(inner, cfg.perl_keyword_spacing)
|
|
||||||
return inner
|
|
||||||
|
|
||||||
|
|
||||||
def _split_code_and_strings(s: str):
|
|
||||||
chunks = []
|
|
||||||
buf: List[str] = []
|
|
||||||
in_single = in_double = False
|
|
||||||
i = 0
|
|
||||||
while i < len(s):
|
|
||||||
ch = s[i]
|
|
||||||
if not in_single and not in_double:
|
|
||||||
if ch == "'":
|
|
||||||
if buf:
|
|
||||||
chunks.append(("code", "".join(buf)))
|
|
||||||
buf = []
|
|
||||||
in_single = True
|
|
||||||
buf.append(ch)
|
|
||||||
elif ch == '"':
|
|
||||||
if buf:
|
|
||||||
chunks.append(("code", "".join(buf)))
|
|
||||||
buf = []
|
|
||||||
in_double = True
|
|
||||||
buf.append(ch)
|
|
||||||
else:
|
|
||||||
buf.append(ch)
|
|
||||||
elif in_single:
|
|
||||||
buf.append(ch)
|
|
||||||
if ch == "\\":
|
|
||||||
if i + 1 < len(s):
|
|
||||||
buf.append(s[i + 1]); i += 1
|
|
||||||
elif ch == "'":
|
|
||||||
chunks.append(("str", "".join(buf))); buf = []; in_single = False
|
|
||||||
elif in_double:
|
|
||||||
buf.append(ch)
|
|
||||||
if ch == "\\":
|
|
||||||
if i + 1 < len(s):
|
|
||||||
buf.append(s[i + 1]); i += 1
|
|
||||||
elif ch == '"':
|
|
||||||
chunks.append(("str", "".join(buf))); buf = []; in_double = False
|
|
||||||
i += 1
|
|
||||||
if buf:
|
|
||||||
chunks.append(("code" if not (in_single or in_double) else "str", "".join(buf)))
|
|
||||||
return chunks
|
|
||||||
|
|
||||||
|
|
||||||
def _split_unquoted_comment(code_chunk: str):
|
|
||||||
idx = code_chunk.find("#")
|
|
||||||
if idx == -1:
|
|
||||||
return code_chunk, None
|
|
||||||
return code_chunk[:idx], code_chunk[idx:]
|
|
||||||
|
|
||||||
|
|
||||||
def enforce_perl_keyword_spacing(s: str, enable: bool) -> str:
|
|
||||||
if not enable or not s:
|
|
||||||
return s
|
|
||||||
# Add space after control keywords before '('
|
|
||||||
ctrl_paren = re.compile(r"\b(?P<kw>if|elsif|unless|while|until|for|foreach|given|when)\s*\(")
|
|
||||||
# Add space after declarators before sigils/paren
|
|
||||||
decl = re.compile(r"\b(?P<kw>my|our|state|local)\s*(?=[\$\@\%\*\&\\\(])")
|
|
||||||
# sub name spacing and brace spacing
|
|
||||||
sub_named = re.compile(r"\bsub\s*([A-Za-z_]\w*)")
|
|
||||||
sub_named_brace = re.compile(r"\bsub\s+([A-Za-z_]\w*)\s*\{")
|
|
||||||
sub_anon = re.compile(r"\bsub\s*\{")
|
|
||||||
# Calls which often appear without space
|
|
||||||
call_paren = re.compile(r"\b(?P<kw>return|print|say|die|warn|exit)\s*\(")
|
|
||||||
call_space = re.compile(r"\b(?P<kw>return|print|say|die|warn|exit)\s*(?=\S)")
|
|
||||||
# else/continue/do/eval blocks
|
|
||||||
else_brace = re.compile(r"\b(?P<kw>else|continue|do|eval)\s*\{")
|
|
||||||
# Ensure space before a brace after a closing paren: "){" -> ") {"
|
|
||||||
brace_after_paren = re.compile(r"\)\s*\{")
|
|
||||||
# NEW: ensure space between '}' and a following keyword: "}else" -> "} else"
|
|
||||||
brace_then_kw = re.compile(r"\}\s*(?=\b(?:else|elsif|continue|when)\b)")
|
|
||||||
|
|
||||||
out: List[str] = []
|
|
||||||
for kind, chunk in _split_code_and_strings(s):
|
|
||||||
if kind != "code":
|
|
||||||
out.append(chunk)
|
|
||||||
continue
|
|
||||||
code, comment = _split_unquoted_comment(chunk)
|
|
||||||
# Apply transforms on code only
|
|
||||||
code = ctrl_paren.sub(lambda m: f"{m.group('kw')} (", code)
|
|
||||||
code = decl.sub(lambda m: f"{m.group('kw')} ", code)
|
|
||||||
code = sub_named.sub(lambda m: f"sub {m.group(1)}", code)
|
|
||||||
code = sub_named_brace.sub(lambda m: f"sub {m.group(1)} {{", code)
|
|
||||||
code = sub_anon.sub("sub {", code)
|
|
||||||
code = call_paren.sub(lambda m: f"{m.group('kw')} (", code)
|
|
||||||
code = call_space.sub(lambda m: f"{m.group('kw')} ", code)
|
|
||||||
code = brace_then_kw.sub("} ", code) # <- add space after closing brace before keyword
|
|
||||||
code = else_brace.sub(lambda m: f"{m.group('kw')} {{", code)
|
|
||||||
code = brace_after_paren.sub(") {", code)
|
|
||||||
out.append(code + (comment or ""))
|
|
||||||
return "".join(out)
|
|
||||||
|
|
||||||
def normalize_tpl_tag(
|
|
||||||
leftchomp: Optional[str],
|
|
||||||
kind: Optional[str],
|
|
||||||
body: str,
|
|
||||||
rightchomp: Optional[str],
|
|
||||||
cfg: Config,
|
|
||||||
) -> Tuple[str, str, str, str, str]:
|
|
||||||
if not cfg.normalize_delimiter_spacing or (kind == "#"):
|
|
||||||
return ("<%", leftchomp or "", kind or "", body, (rightchomp or "") + "%>")
|
|
||||||
body = body.strip()
|
|
||||||
left_space = " "
|
|
||||||
right_space = " " if rightchomp == "" else ""
|
|
||||||
open_part = "<%" + (leftchomp or "") + (kind or "") + left_space
|
|
||||||
close_part = right_space + (rightchomp or "") + "%>"
|
|
||||||
return (open_part, "", "", body, close_part)
|
|
||||||
|
|
||||||
|
|
||||||
def substitute_tpl_tags_in_line(line: str, cfg: Config) -> str:
|
|
||||||
parts: List[str] = []
|
|
||||||
last = 0
|
|
||||||
for m in TPL_TAG_RE.finditer(line):
|
|
||||||
parts.append(line[last : m.start()])
|
|
||||||
leftchomp = m.group("leftchomp") or ""
|
|
||||||
kind = m.group("kind") or ""
|
|
||||||
body = m.group("body")
|
|
||||||
rightchomp = m.group("rightchomp") or ""
|
|
||||||
open_part, _, _, new_body, close_part = normalize_tpl_tag(
|
|
||||||
leftchomp, kind, body, rightchomp, cfg
|
|
||||||
)
|
|
||||||
if kind == "#":
|
|
||||||
inner = body
|
|
||||||
else:
|
|
||||||
if kind in ("=", "=="):
|
|
||||||
inner = tidy_perl_expression(body, cfg)
|
|
||||||
else:
|
|
||||||
inner = tidy_perl_statement_oneline(body, cfg)
|
|
||||||
parts.append(open_part + inner + close_part)
|
|
||||||
last = m.end()
|
|
||||||
parts.append(line[last:])
|
|
||||||
return "".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
def derive_html_tag_deltas(line_wo_tpl: str) -> Tuple[int, int, Optional[str], Optional[str]]:
|
|
||||||
"""
|
|
||||||
Return (pre_dedent, net_total, raw_open, raw_close):
|
|
||||||
- pre_dedent: end tags at beginning of line (dedent before printing)
|
|
||||||
- net_total: total start tags (+1) minus end tags (-1) across the line for non-void, non-self-closing tags
|
|
||||||
- raw_open, raw_close: raw elements opened/closed on this line if they match exactly
|
|
||||||
"""
|
|
||||||
s = line_wo_tpl
|
|
||||||
|
|
||||||
raw_open = None
|
|
||||||
raw_close = None
|
|
||||||
m_open = RAW_OPEN_RE.match(s)
|
|
||||||
if m_open:
|
|
||||||
raw_open = m_open.group("name").lower()
|
|
||||||
m_close = RAW_CLOSE_RE.match(s)
|
|
||||||
if m_close:
|
|
||||||
raw_close = m_close.group("name").lower()
|
|
||||||
|
|
||||||
pre_dedent = 0
|
|
||||||
i = 0
|
|
||||||
while i < len(s) and s[i].isspace():
|
|
||||||
i += 1
|
|
||||||
while True:
|
|
||||||
m = TAG_RE.match(s, i)
|
|
||||||
if not m:
|
|
||||||
break
|
|
||||||
if m.group("slash"):
|
|
||||||
pre_dedent += 1
|
|
||||||
i = m.end()
|
|
||||||
while i < len(s) and s[i].isspace():
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
|
|
||||||
net = 0
|
|
||||||
for m in TAG_RE.finditer(s):
|
|
||||||
slash = m.group("slash")
|
|
||||||
name = (m.group("name") or "").lower()
|
|
||||||
selfclose = bool(m.group("self"))
|
|
||||||
if slash:
|
|
||||||
net -= 1
|
|
||||||
else:
|
|
||||||
if selfclose or name in VOID_ELEMENTS:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
net += 1
|
|
||||||
|
|
||||||
return pre_dedent, net, raw_open, raw_close
|
|
||||||
|
|
||||||
|
|
||||||
def strip_tpl_tags(line: str) -> str:
|
|
||||||
return TPL_TAG_RE.sub(lambda m: " " * (m.end() - m.start()), line)
|
|
||||||
|
|
||||||
|
|
||||||
def is_standalone_statement_tag(line: str) -> bool:
|
|
||||||
s = line.strip()
|
|
||||||
if not (s.startswith("<%") and s.endswith("%>")):
|
|
||||||
return False
|
|
||||||
if s.startswith("<%=") or s.startswith("<%=="):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def compute_perl_deltas(line: str) -> Tuple[int, int]:
|
|
||||||
"""
|
|
||||||
Return (perl_dedent_before, perl_delta_after_for_next_line).
|
|
||||||
Only line directives (starting with %) and standalone <% ... %> statement lines
|
|
||||||
affect Perl depth. Also account for % end / <% end %> and begin blocks.
|
|
||||||
"""
|
|
||||||
dedent_before = 0
|
|
||||||
delta_after = 0
|
|
||||||
|
|
||||||
if END_LINE_RE.match(line) or END_TAG_ONLY_RE.match(line):
|
|
||||||
dedent_before += 1
|
|
||||||
|
|
||||||
m = LEADING_RBRACE_COUNT_RE.match(line)
|
|
||||||
if m:
|
|
||||||
braces = m.group("braces") or ""
|
|
||||||
dedent_before += len(braces)
|
|
||||||
|
|
||||||
if TAG_CLOSING_BRACE_ONLY_RE.match(line):
|
|
||||||
dedent_before += 1
|
|
||||||
|
|
||||||
is_dir = bool(LINE_DIR_RE.match(line))
|
|
||||||
is_stmt_tag_only = is_standalone_statement_tag(line)
|
|
||||||
|
|
||||||
if is_dir:
|
|
||||||
body = LINE_DIR_RE.match(line).group("body")
|
|
||||||
open_count = body.count("{")
|
|
||||||
close_count = body.count("}")
|
|
||||||
delta_after += (open_count - close_count)
|
|
||||||
if BEGIN_RE.search(line):
|
|
||||||
delta_after += 1
|
|
||||||
elif is_stmt_tag_only:
|
|
||||||
bodies = [m.group("body") or "" for m in TPL_TAG_RE.finditer(line)]
|
|
||||||
open_count = sum(b.count("{") for b in bodies)
|
|
||||||
close_count = sum(b.count("}") for b in bodies)
|
|
||||||
delta_after += (open_count - close_count)
|
|
||||||
if BEGIN_RE.search(line):
|
|
||||||
delta_after += 1
|
|
||||||
|
|
||||||
return dedent_before, delta_after
|
|
||||||
|
|
||||||
|
|
||||||
def format_line_directive(line: str, cfg: Config) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
If the line is a Mojolicious line directive (% ...), return a formatted
|
|
||||||
directive string WITHOUT leading indentation (indent applied separately).
|
|
||||||
Otherwise return None.
|
|
||||||
"""
|
|
||||||
m = LINE_DIR_RE.match(line)
|
|
||||||
if not m:
|
|
||||||
return None
|
|
||||||
kind = m.group("kind") or ""
|
|
||||||
body = m.group("body")
|
|
||||||
|
|
||||||
if kind == "#":
|
|
||||||
if cfg.normalize_delimiter_spacing:
|
|
||||||
trimmed = body.strip()
|
|
||||||
return "%#" + ((" " + trimmed) if trimmed else "")
|
|
||||||
else:
|
|
||||||
return "%#" + body
|
|
||||||
|
|
||||||
if kind in ("=", "=="):
|
|
||||||
inner = tidy_perl_expression(body, cfg)
|
|
||||||
else:
|
|
||||||
inner = tidy_perl_statement_oneline(body, cfg)
|
|
||||||
|
|
||||||
if cfg.normalize_delimiter_spacing:
|
|
||||||
return "%" + kind + ((" " + inner) if inner else "")
|
|
||||||
else:
|
|
||||||
return "%" + kind + ((" " + inner) if inner else "")
|
|
||||||
|
|
||||||
|
|
||||||
def rstrip_trailing_ws(line: str) -> str:
|
|
||||||
return line.rstrip(" \t")
|
|
||||||
|
|
||||||
|
|
||||||
def format_string(src: str, cfg: Config) -> str:
|
|
||||||
original_eol = detect_eol(src)
|
|
||||||
text = src.replace("\r\n", "\n").replace("\r", "\n")
|
|
||||||
|
|
||||||
lines = text.split("\n")
|
|
||||||
html_depth = 0
|
|
||||||
perl_depth = 0
|
|
||||||
in_raw: Optional[str] = None
|
|
||||||
|
|
||||||
out_lines: List[str] = []
|
|
||||||
|
|
||||||
for orig_line in lines:
|
|
||||||
line = orig_line
|
|
||||||
|
|
||||||
if in_raw:
|
|
||||||
m_close = RAW_CLOSE_RE.match(line)
|
|
||||||
if m_close and m_close.group("name").lower() == in_raw:
|
|
||||||
indent_level = max(0, html_depth - 1) + perl_depth
|
|
||||||
indent = " " * (cfg.indent_width * indent_level)
|
|
||||||
new_line = indent + line.lstrip()
|
|
||||||
out_lines.append(rstrip_trailing_ws(new_line))
|
|
||||||
html_depth = max(0, html_depth - 1)
|
|
||||||
in_raw = None
|
|
||||||
else:
|
|
||||||
out_lines.append(line)
|
|
||||||
continue
|
|
||||||
|
|
||||||
perl_dedent_before, perl_delta_after = compute_perl_deltas(line)
|
|
||||||
line_wo_tpl = strip_tpl_tags(line)
|
|
||||||
html_pre_dedent, html_net, raw_open, raw_close = derive_html_tag_deltas(line_wo_tpl)
|
|
||||||
|
|
||||||
base_html_depth = max(0, html_depth - html_pre_dedent)
|
|
||||||
base_perl_depth = max(0, perl_depth - perl_dedent_before)
|
|
||||||
indent_level = max(0, base_html_depth + base_perl_depth)
|
|
||||||
indent = " " * (cfg.indent_width * indent_level)
|
|
||||||
|
|
||||||
formatted_directive = format_line_directive(line, cfg)
|
|
||||||
if formatted_directive is not None:
|
|
||||||
content = formatted_directive
|
|
||||||
else:
|
|
||||||
content = substitute_tpl_tags_in_line(line, cfg).lstrip()
|
|
||||||
|
|
||||||
new_line = indent + content.lstrip()
|
|
||||||
out_lines.append(rstrip_trailing_ws(new_line))
|
|
||||||
|
|
||||||
html_depth = max(0, base_html_depth + html_net + html_pre_dedent)
|
|
||||||
if raw_open and (raw_open.lower() in RAW_ELEMENTS):
|
|
||||||
in_raw = raw_open.lower()
|
|
||||||
perl_depth = max(0, base_perl_depth + perl_delta_after)
|
|
||||||
|
|
||||||
result = "\n".join(out_lines)
|
|
||||||
if not result.endswith("\n"):
|
|
||||||
result += "\n"
|
|
||||||
|
|
||||||
eol_mode = cfg.eol if cfg.eol != "preserve" else original_eol
|
|
||||||
result = normalize_eol(result, eol_mode)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def read_text(path: Path) -> str:
|
|
||||||
with path.open("rb") as f:
|
|
||||||
raw = f.read()
|
|
||||||
try:
|
|
||||||
return raw.decode("utf-8")
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
return raw.decode(errors="replace")
|
|
||||||
|
|
||||||
|
|
||||||
def write_text(path: Path, text: str) -> None:
|
|
||||||
with path.open("wb") as f:
|
|
||||||
f.write(text.encode("utf-8"))
|
|
||||||
|
|
||||||
|
|
||||||
def is_supported_file(path: Path, exts: Tuple[str, ...]) -> bool:
|
|
||||||
name = path.name.lower()
|
|
||||||
return any(name.endswith(ext) for ext in exts)
|
|
||||||
|
|
||||||
|
|
||||||
def iter_files(paths: List[str], exts: Tuple[str, ...]) -> Iterable[Path]:
|
|
||||||
for p in paths:
|
|
||||||
pth = Path(p)
|
|
||||||
if pth.is_dir():
|
|
||||||
for root, _, files in os.walk(pth):
|
|
||||||
for fn in files:
|
|
||||||
fp = Path(root) / fn
|
|
||||||
if is_supported_file(fp, exts):
|
|
||||||
logger.debug("Found file: %s", fp)
|
|
||||||
yield fp
|
|
||||||
else:
|
|
||||||
if is_supported_file(pth, exts):
|
|
||||||
logger.debug("Found file: %s", pth)
|
|
||||||
yield pth
|
|
||||||
|
|
||||||
|
|
||||||
def unified_diff(a: str, b: str, path: Path) -> str:
|
|
||||||
a_lines = a.splitlines(keepends=True)
|
|
||||||
b_lines = b.splitlines(keepends=True)
|
|
||||||
return "".join(
|
|
||||||
difflib.unified_diff(
|
|
||||||
a_lines, b_lines, fromfile=str(path), tofile=str(path) + " (formatted)"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def process_file(path: Path, cfg: Config, write: bool, show_diff: bool, backup: bool = False) -> Tuple[bool, str]:
|
|
||||||
original = read_text(path)
|
|
||||||
formatted = format_string(original, cfg)
|
|
||||||
changed = original != formatted
|
|
||||||
if changed:
|
|
||||||
logger.info("Formatted: %s", path)
|
|
||||||
if show_diff:
|
|
||||||
sys.stdout.write(unified_diff(original, formatted, path))
|
|
||||||
if write:
|
|
||||||
if backup:
|
|
||||||
bak_path = path.with_name(path.name + ".bak")
|
|
||||||
write_text(bak_path, original)
|
|
||||||
logger.info("Backup written: %s", bak_path)
|
|
||||||
write_text(path, formatted)
|
|
||||||
logger.info("Overwritten: %s", path)
|
|
||||||
else:
|
|
||||||
logger.info("Unchanged: %s", path)
|
|
||||||
return changed, formatted
|
|
||||||
|
|
||||||
|
|
||||||
def process_stdin_stdout(cfg: Config) -> int:
|
|
||||||
data = sys.stdin.read()
|
|
||||||
formatted = format_string(data, cfg)
|
|
||||||
sys.stdout.write(formatted)
|
|
||||||
logger.info("Formatted stdin to stdout")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def build_arg_parser() -> argparse.ArgumentParser:
|
|
||||||
p = argparse.ArgumentParser(description="Format Mojolicious templates (.ep, .htm.ep, .html.ep)")
|
|
||||||
p.add_argument("paths", nargs="*", help="Files or directories")
|
|
||||||
p.add_argument("-w", "--write", action="store_true", help="Overwrite files in place (writes a .bak backup)")
|
|
||||||
p.add_argument("-o", "--out", help="Write formatted output to this file (single input file or --stdin). Conflicts with --write/--check/--diff")
|
|
||||||
p.add_argument("--check", action="store_true", help="Exit non-zero if any file would change")
|
|
||||||
p.add_argument("--diff", action="store_true", help="Print unified diff for changes")
|
|
||||||
p.add_argument("--stdin", action="store_true", help="Read from stdin")
|
|
||||||
p.add_argument("--stdout", action="store_true", help="Write to stdout (with --stdin)")
|
|
||||||
p.add_argument("--perltidy", help="Path to perltidy executable (defaults to PATH)")
|
|
||||||
p.add_argument("--indent", type=int, help="Indent width (spaces, default 2)")
|
|
||||||
p.add_argument("--eol", choices=["lf", "crlf", "preserve"], default="lf", help="EOL handling (default lf)")
|
|
||||||
p.add_argument("--no-space-in-delims", action="store_true", help="Do not normalize spaces inside <%% %%> delimiters")
|
|
||||||
p.add_argument("--perl-keyword-spacing", action="store_true", help="Aggressively insert a space after Perl keywords (if(...)->if (...), my$->my $, return(...)->return (...), etc.)")
|
|
||||||
p.add_argument("--self-test", dest="self_test", action="store_true", help="Run internal sanity checks and exit 0/1")
|
|
||||||
p.add_argument("--log-level", choices=["error", "info", "debug"], help="Logging level (default error)")
|
|
||||||
p.add_argument("--verbose", action="store_true", help="Shorthand for --log-level info")
|
|
||||||
p.add_argument("--version", action="store_true", help="Print version and exit")
|
|
||||||
return p
|
|
||||||
|
|
||||||
|
|
||||||
def self_test(cfg: Config) -> int:
|
|
||||||
failures: List[str] = []
|
|
||||||
|
|
||||||
def check(name: str, cond: bool, detail: Optional[str] = None):
|
|
||||||
if not cond:
|
|
||||||
failures.append(name + (": " + detail if detail else ""))
|
|
||||||
|
|
||||||
# T0: perltidy availability and behavior
|
|
||||||
ok, msg = perltidy_probe(cfg)
|
|
||||||
if not ok:
|
|
||||||
failures.append("perltidy: " + msg)
|
|
||||||
else:
|
|
||||||
logger.info(msg)
|
|
||||||
|
|
||||||
# T1: idempotence on a mixed template
|
|
||||||
src_a = "% if (1) {\n<ul>\n% for my $i (1..2) {\n<li><%= $i %></li>\n% }\n</ul>\n% }\n"
|
|
||||||
fmt_a1 = format_string(src_a, cfg)
|
|
||||||
fmt_a2 = format_string(fmt_a1, cfg)
|
|
||||||
check("idempotence", fmt_a1 == fmt_a2)
|
|
||||||
|
|
||||||
# T2: chomp markers preserved
|
|
||||||
src_b = "<li><%= $title -%>\n<%= $sub %></li>\n"
|
|
||||||
fmt_b = format_string(src_b, cfg)
|
|
||||||
check("chomp presence", "-%>" in fmt_b)
|
|
||||||
check("no-left-chomp-added", "<%-" not in fmt_b)
|
|
||||||
|
|
||||||
# T3: raw element inner content unchanged
|
|
||||||
src_c = "<script>\n var x=1; // keep spacing\nif(true){console.log(x)}\n</script>\n"
|
|
||||||
fmt_c = format_string(src_c, cfg)
|
|
||||||
c_lines = src_c.splitlines()
|
|
||||||
f_lines = fmt_c.splitlines()
|
|
||||||
if len(c_lines) >= 3 and len(f_lines) >= 3:
|
|
||||||
check("raw inner unchanged", c_lines[1:-1] == f_lines[1:-1], detail=f"got {f_lines[1:-1]!r}")
|
|
||||||
else:
|
|
||||||
check("raw structure", False, "unexpected line count")
|
|
||||||
|
|
||||||
# T4: delimiter spacing normalization for <% %>
|
|
||||||
src_d = "<%my $x=1;%>\n"
|
|
||||||
fmt_d = format_string(src_d, cfg)
|
|
||||||
check("delimiter spacing", "<% " in fmt_d and "%>" in fmt_d)
|
|
||||||
|
|
||||||
# T5: keyword spacing with flag on
|
|
||||||
cfg_kw = dc_replace(cfg, perl_keyword_spacing=True)
|
|
||||||
fmt_k1 = format_string("<% if($x){ %>\n", cfg_kw)
|
|
||||||
check("kw if(...)", "if (" in fmt_k1 and " {" in fmt_k1)
|
|
||||||
fmt_k2 = format_string("<%= return(1) %>\n", cfg_kw)
|
|
||||||
check("kw return(...)", "return (" in fmt_k2)
|
|
||||||
fmt_k3 = format_string('<% say"hi"; %>\n', cfg_kw)
|
|
||||||
check("kw say \"...\"", 'say "' in fmt_k3)
|
|
||||||
fmt_k4 = format_string("<% my($x,$y)=@_; %>\n", cfg_kw)
|
|
||||||
check("kw my $", "my (" in fmt_k4 and " = @_" in fmt_k4)
|
|
||||||
fmt_k5 = format_string("<% sub foo{ %>\n", cfg_kw)
|
|
||||||
check("kw sub foo {", "sub foo {" in fmt_k5)
|
|
||||||
|
|
||||||
if failures:
|
|
||||||
logger.error("SELF-TEST FAILURES:")
|
|
||||||
for f in failures:
|
|
||||||
logger.error(" - %s", f)
|
|
||||||
return 1
|
|
||||||
logger.info("Self-test passed")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: Optional[List[str]] = None) -> int:
|
|
||||||
parser = build_arg_parser()
|
|
||||||
args = parser.parse_args(argv)
|
|
||||||
|
|
||||||
setup_logging(args.log_level, args.verbose)
|
|
||||||
|
|
||||||
if args.version:
|
|
||||||
print(f"mojofmt {VERSION}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if args.self_test:
|
|
||||||
cfg = load_config(args)
|
|
||||||
return self_test(cfg)
|
|
||||||
|
|
||||||
# Validate --out usage
|
|
||||||
if args.out:
|
|
||||||
if args.write or args.check or args.diff:
|
|
||||||
parser.error("--out conflicts with --write/--check/--diff")
|
|
||||||
cfg = load_config(args)
|
|
||||||
out_path = Path(args.out)
|
|
||||||
if args.stdin:
|
|
||||||
data = sys.stdin.read()
|
|
||||||
formatted = format_string(data, cfg)
|
|
||||||
write_text(out_path, formatted)
|
|
||||||
logger.info("Wrote %s (from stdin)", out_path)
|
|
||||||
return 0
|
|
||||||
# must be exactly one input file
|
|
||||||
if not args.paths or len(args.paths) != 1:
|
|
||||||
parser.error("--out requires exactly one input file (or use --stdin)")
|
|
||||||
in_path = Path(args.paths[0])
|
|
||||||
original = read_text(in_path)
|
|
||||||
formatted = format_string(original, cfg)
|
|
||||||
write_text(out_path, formatted)
|
|
||||||
logger.info("Wrote %s (from %s)", out_path, in_path)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
cfg = load_config(args)
|
|
||||||
|
|
||||||
if args.stdin:
|
|
||||||
return process_stdin_stdout(cfg)
|
|
||||||
|
|
||||||
if not args.paths:
|
|
||||||
parser.error("No input paths provided (or use --stdin).")
|
|
||||||
|
|
||||||
any_changed = False
|
|
||||||
any_error = False
|
|
||||||
|
|
||||||
for path in iter_files(args.paths, cfg.extensions):
|
|
||||||
try:
|
|
||||||
changed, _ = process_file(path, cfg, write=args.write, show_diff=args.diff, backup=args.write)
|
|
||||||
any_changed = any_changed or changed
|
|
||||||
except Exception as e:
|
|
||||||
any_error = True
|
|
||||||
logger.error("Error processing %s: %s", path, e)
|
|
||||||
|
|
||||||
if args.check and any_changed:
|
|
||||||
return 1
|
|
||||||
return 1 if any_error else 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
878
website.py
878
website.py
@@ -1,151 +1,527 @@
|
|||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
import secrets
|
||||||
import tempfile
|
import tempfile
|
||||||
import subprocess
|
import subprocess
|
||||||
from flask import (
|
import re
|
||||||
Flask, request, render_template_string,
|
import logging
|
||||||
jsonify, send_file, redirect, url_for, flash
|
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.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 functools import wraps
|
from functools import wraps
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
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")
|
||||||
|
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()
|
load_dotenv()
|
||||||
|
|
||||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
MOJO_FMT_PATH = os.path.join(BASE_DIR, "mojofmt.py")
|
MOJO_FMT_PATH = os.path.join(BASE_DIR, "mojofmt.py")
|
||||||
|
|
||||||
# Get secrets from environment variables
|
|
||||||
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')
|
||||||
if not SECRET_KEY or not API_TOKEN:
|
DEBUG_MODE = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true'
|
||||||
raise RuntimeError("FLASK_SECRET_KEY and FLASK_API_TOKEN must be set")
|
|
||||||
|
|
||||||
|
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 = Flask(__name__)
|
||||||
app.secret_key = SECRET_KEY
|
app.secret_key = SECRET_KEY
|
||||||
|
|
||||||
# Secure cookies
|
# Enhanced app configuration
|
||||||
app.config.update(
|
app.config.update(
|
||||||
SESSION_COOKIE_HTTPONLY=True,
|
SESSION_COOKIE_HTTPONLY=True,
|
||||||
SESSION_COOKIE_SECURE=True, # requires HTTPS in production
|
SESSION_COOKIE_SECURE=True,
|
||||||
SESSION_COOKIE_SAMESITE='Lax'
|
SESSION_COOKIE_SAMESITE='Lax',
|
||||||
|
MAX_CONTENT_LENGTH=1024 * 1024, # 1MB limit
|
||||||
|
DEBUG=DEBUG_MODE
|
||||||
)
|
)
|
||||||
|
|
||||||
# Security headers with Flask‑Talisman (CSP allowing only self + cdn.jsdelivr.net)
|
# --- LOGGING CONFIGURATION ---
|
||||||
csp = {
|
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'"],
|
'default-src': ["'self'"],
|
||||||
'script-src': ["'self'", 'https://cdn.jsdelivr.net'],
|
'script-src': ["'self'", 'https://cdn.jsdelivr.net'],
|
||||||
'style-src': ["'self'", 'https://cdn.jsdelivr.net', "'unsafe-inline'"],
|
'style-src': ["'self'", 'https://cdn.jsdelivr.net'],
|
||||||
}
|
'img-src': ["'self'", 'data:'],
|
||||||
Talisman(app, content_security_policy=csp)
|
'font-src': ["'self'", 'https://cdn.jsdelivr.net'],
|
||||||
|
'connect-src': ["'self'"],
|
||||||
|
'frame-ancestors': ["'none'"],
|
||||||
|
'base-uri': ["'self'"],
|
||||||
|
'form-action': ["'self'"]
|
||||||
|
}
|
||||||
|
|
||||||
# Rate limiting
|
# Initialize Talisman with enhanced configuration
|
||||||
limiter = Limiter(key_func=get_remote_address, app=app, default_limits=["100/hour"])
|
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'
|
||||||
|
)
|
||||||
|
|
||||||
# Token authentication decorator
|
# 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):
|
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
|
||||||
|
|
||||||
# ----------------------------- HTML TEMPLATE -----------------------------
|
# --- 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>
|
||||||
<head>
|
<head>
|
||||||
<title>Mojolicious Template Code Formatter</title>
|
<title>Mojolicious Template Code Formatter</title>
|
||||||
<style>
|
<style>
|
||||||
body { font-family: 'Segoe UI', Arial, sans-serif; margin:0; padding:0;
|
body {font-family:'Segoe UI', Arial, sans-serif; margin:0; padding:0;
|
||||||
background:linear-gradient(90deg, #fdeff9 0%, #ecb6ff 100%);
|
background:linear-gradient(90deg,#fdeff9 0%,#ecb6ff 100%);
|
||||||
height:100vh; display:flex; flex-direction:column; }
|
height:100vh; display:flex; flex-direction:column;}
|
||||||
header { background:#8247c2; color:#fff; display:flex; align-items:center;
|
header {background:#8247c2; color:#fff; display:flex; align-items:center;
|
||||||
justify-content:space-between; padding:1em; }
|
justify-content:space-between; padding:1em;}
|
||||||
header h1 { margin:0; font-size:1.6em; }
|
header h1 {margin:0; font-size:1.6em;}
|
||||||
.icon-links { display:flex; align-items:center; }
|
.icon-links {display:flex; align-items:center;}
|
||||||
.icon-links a { display:inline-flex; align-items:center; margin-left:16px; color:white; text-decoration:none; }
|
.icon-links a {display:inline-flex; align-items:center; margin-left:16px; color:white;}
|
||||||
.icon-links a svg { width:28px; height:28px; fill: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 ul {margin:0; padding:0; list-style:none; color:#d91454;text-align:center;}
|
||||||
form { flex:1; display:flex; flex-direction:column; margin:0; }
|
.flash-messages {min-height:20px;}
|
||||||
.container { display:flex; flex-direction:row; gap:16px; padding:20px; flex:1;
|
form {flex:1; display:flex; flex-direction:column;}
|
||||||
box-sizing:border-box; height:calc(100vh - 140px); }
|
.container {display:flex; flex-direction:row; gap:16px; padding:20px; flex:1;
|
||||||
.panel { background:#fff; border-radius:10px; box-shadow:0 8px 18px -7px #ac83ce44;
|
box-sizing:border-box; height:calc(100vh - 140px);}
|
||||||
padding:22px; flex:1 1 50%; min-width:300px; display:flex; flex-direction:column; height:100%; }
|
.panel {background:#fff; border-radius:10px; box-shadow:0 8px 18px -7px #ac83ce44;
|
||||||
label { font-weight:bold; margin-bottom:5px; }
|
padding:22px; flex:1 1 50%; min-width:300px; display:flex; flex-direction:column; height:100%;}
|
||||||
.controls { display:flex; gap:8px; margin:14px 0 8px 0; }
|
label {font-weight:bold; margin-bottom:5px;}
|
||||||
button { background:#a950e6; border:none; color:#fff; border-radius:5px;
|
.controls {
|
||||||
padding:9px 16px; font-size:15px; cursor:pointer; box-shadow:0 2px 7px -3px #bb76c1; }
|
display: flex;
|
||||||
button:hover { background:#7634a2; }
|
justify-content: space-between; /* left group stays left, right goes right */
|
||||||
input[type="file"] { margin-bottom:10px; }
|
align-items: center;
|
||||||
textarea { width:100%; flex:1 1 auto; min-height:0; font-family:'Fira Mono', monospace;
|
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;
|
font-size:15px; border:2px solid #bdb0da; background:#f6eafe;
|
||||||
border-radius:7px; padding:8px; color:#432d67; resize:vertical; }
|
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;
|
select {padding:6px 10px; border-radius:5px; border:1px solid #b993d6;
|
||||||
background:#eee0f6; color:#6d378d; font-size:15px; }
|
background:#eee0f6; color:#6d378d; font-size:15px;}
|
||||||
#output_block, #output_code, pre[class*="language-"], code[class*="language-"] {
|
#output_block {background:#16151a !important; color:white !important; border-radius:8px; padding:1em;
|
||||||
font-family:'Fira Mono', monospace !important; font-size:15px !important; line-height:1 !important;
|
|
||||||
}
|
|
||||||
#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;
|
margin-top:10px; overflow-y:auto; resize:vertical; white-space:pre-wrap;
|
||||||
border:2px solid #bdb0da; flex:1 1 auto; min-height:0; }
|
border:2px solid #bdb0da; flex:1 1 auto; min-height:0;}
|
||||||
@media (max-width:800px) {
|
@media (max-width:800px) {.container {flex-direction:column; height:auto;}
|
||||||
.container { flex-direction:column; height:auto; }
|
.panel {height:auto; min-width:0;}}
|
||||||
.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>
|
</style>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/prismjs@1/themes/prism-tomorrow.min.css" rel="stylesheet" />
|
<link href="https://cdn.jsdelivr.net/npm/prismjs@1/themes/prism-tomorrow.min.css"
|
||||||
<script src="https://cdn.jsdelivr.net/npm/prismjs@1/prism.min.js"></script>
|
rel="stylesheet"
|
||||||
<script src="https://cdn.jsdelivr.net/npm/prismjs@1/components/prism-perl.min.js"></script>
|
integrity="sha384-wFjoQjtV1y5jVHbt0p35Ui8aV8GVpEZkyF99OXWqP/eNJDU93D3Ugxkoyh6Y2I4A"
|
||||||
<script src="https://cdn.jsdelivr.net/npm/prismjs@1/components/prism-markup.min.js"></script>
|
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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<h1>Mojolicious Template Code Formatter</h1>
|
<h1>
|
||||||
|
Mojolicious Template Code Formatter
|
||||||
|
<span class="version-label">v{{ formatter_version }}</span>
|
||||||
|
</h1>
|
||||||
<div class="icon-links">
|
<div class="icon-links">
|
||||||
<a href="https://github.com/brianread108" target="_blank" aria-label="GitHub">
|
<a href="https://github.com/brianread108/mojofmt" target="_blank" aria-label="GitHub">
|
||||||
<svg viewBox="0 0 16 16"><path fill-rule="evenodd"
|
<svg viewBox="0 0 16 16" role="img" aria-hidden="true" width="28" height="28" fill="white" xmlns="http://www.w3.org/2000/svg">
|
||||||
d="M8 0C3.58 0 0 3.58 0 8a8..."/></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>
|
||||||
<a href="https://mojolicious.org" target="_blank" aria-label="Mojolicious">
|
<a href="https://mojolicious.org" target="_blank" aria-label="Mojolicious Website">
|
||||||
<svg viewBox="0 0 64 64"><path d="M32 2C20 18..."/></svg>
|
<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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="flash-messages">
|
<div class="flash-messages" id="flash-messages">
|
||||||
{% with messages = get_flashed_messages() %}
|
|
||||||
{% if messages %}<ul>{% for m in messages %}<li>{{ m }}</li>{% endfor %}</ul>{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="/" enctype="multipart/form-data">
|
<form id="mainform" onsubmit="return false;">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<label for="input_text">Input data:</label>
|
<label for="input_text">Input data:</label>
|
||||||
<textarea name="input_text" id="input_text">{{ input_text|default('') }}</textarea>
|
<textarea name="input_text" id="input_text"></textarea>
|
||||||
|
<div class="file-upload">
|
||||||
<label for="input_file">Upload a file:</label>
|
<label for="input_file">Upload a file:</label>
|
||||||
<input type="file" name="input_file" id="input_file" accept=".txt,.mojo,.pl,.html,.tmpl,.tt,.tt2,.template,text/plain">
|
<input type="file" name="input_file" id="input_file" accept=".ep">
|
||||||
<div class="controls">
|
|
||||||
<button type="submit" name="action" value="format">Format</button>
|
|
||||||
<button type="submit" name="action" value="download">Download</button>
|
|
||||||
<button type="button" onclick="clearFields()">Clear</button>
|
|
||||||
</div>
|
</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>
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
|
<div class="output-header">
|
||||||
<label>Formatted Output:</label>
|
<label>Formatted Output:</label>
|
||||||
<div style="margin-top:8px;">
|
<div class="syntax-select">
|
||||||
<label for="syntaxmode">Output Syntax:</label>
|
<label for="syntaxmode">Output Syntax:</label>
|
||||||
<select id="syntaxmode" onchange="highlightOutput()">
|
<select id="syntaxmode" name="syntaxmode">
|
||||||
<option value="none">Plain Text</option>
|
<option value="none">Plain Text</option>
|
||||||
<option value="perl">Perl</option>
|
<option value="perl">Perl</option>
|
||||||
<option value="html">HTML</option>
|
<option value="html">HTML</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<pre id="output_block"><code id="output_code" class="language-none">{% if formatted_text is defined %}{{ formatted_text|e }}{% endif %}</code></pre>
|
</div>
|
||||||
|
<pre id="output_block"><code id="output_code" class="language-none"></code></pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -154,120 +530,304 @@ HTML_TEMPLATE = """
|
|||||||
const inputTextEl = document.getElementById("input_text");
|
const inputTextEl = document.getElementById("input_text");
|
||||||
const inputFileEl = document.getElementById("input_file");
|
const inputFileEl = document.getElementById("input_file");
|
||||||
const outputCodeEl = document.getElementById("output_code");
|
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");
|
||||||
|
|
||||||
function clearOutput() {
|
let currentFormattedText = '';
|
||||||
outputCodeEl.textContent = '';
|
let uploadedFilename = '';
|
||||||
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 = `<ul><li style="color: ${isError ? '#d91454' : '#28a745'}">${message}</li></ul>`;
|
||||||
|
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();
|
clearOutput();
|
||||||
}
|
autoResizeTextarea();
|
||||||
window.clearFields = clearFields;
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
inputTextEl.addEventListener("input", clearOutput);
|
|
||||||
inputFileEl.addEventListener("change", e => {
|
|
||||||
const file = e.target.files[0];
|
|
||||||
if (!file) return;
|
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = ev => {
|
reader.onload = function(e) {
|
||||||
inputTextEl.value = ev.target.result;
|
inputTextEl.value = e.target.result;
|
||||||
|
autoResizeTextarea();
|
||||||
clearOutput();
|
clearOutput();
|
||||||
};
|
};
|
||||||
reader.readAsText(file);
|
reader.readAsText(file);
|
||||||
});
|
});
|
||||||
|
|
||||||
highlightOutput();
|
syntaxModeEl.addEventListener("change", highlightOutput);
|
||||||
});
|
|
||||||
|
function clearOutput() {
|
||||||
|
outputCodeEl.textContent = '';
|
||||||
|
currentFormattedText = '';
|
||||||
|
Prism.highlightElement(outputCodeEl);
|
||||||
|
}
|
||||||
|
|
||||||
function highlightOutput() {
|
function highlightOutput() {
|
||||||
const mode = document.getElementById("syntaxmode").value;
|
outputCodeEl.className = "";
|
||||||
const code = document.getElementById("output_code");
|
if (syntaxModeEl.value === "perl") outputCodeEl.classList.add("language-perl");
|
||||||
code.className = "";
|
else if (syntaxModeEl.value === "html") outputCodeEl.classList.add("language-markup");
|
||||||
if (mode === "perl") code.classList.add("language-perl");
|
else outputCodeEl.classList.add("language-none");
|
||||||
else if (mode === "html") code.classList.add("language-markup");
|
Prism.highlightElement(outputCodeEl);
|
||||||
else code.classList.add("language-none");
|
|
||||||
Prism.highlightElement(code);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# ----------------------------- Core logic -----------------------------
|
# --- ROUTES ---
|
||||||
def run_mojofmt(input_text: str) -> str:
|
|
||||||
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():
|
||||||
input_text, formatted_text = "", None
|
"""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 request.method == "POST":
|
||||||
action = request.form.get("action")
|
# If someone tries to submit the form traditionally, redirect to GET
|
||||||
f_obj = request.files.get("input_file")
|
app.logger.info(f"Traditional form submission redirected from {request.remote_addr}")
|
||||||
input_text = request.form.get("input_text", "")
|
return redirect(url_for('index'))
|
||||||
if f_obj and f_obj.filename:
|
|
||||||
try:
|
# Serve the HTML template
|
||||||
input_text = f_obj.read().decode('utf-8')
|
return render_template_string(
|
||||||
except Exception as e:
|
HTML_TEMPLATE,
|
||||||
flash(f"Error reading uploaded file: {e}")
|
formatter_version=FORMATTER_VERSION
|
||||||
return render_template_string(HTML_TEMPLATE, input_text=input_text)
|
)
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
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:
|
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)
|
formatted_text = run_mojofmt(input_text)
|
||||||
except RuntimeError as e:
|
if remove_empty:
|
||||||
flash(str(e))
|
formatted_text = "\n".join(
|
||||||
return render_template_string(HTML_TEMPLATE, input_text=input_text)
|
line for line in formatted_text.splitlines() if line.strip()
|
||||||
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/<filename>")
|
app.logger.info(f"Successfully formatted text for {request.remote_addr}")
|
||||||
def download_file(filename):
|
return jsonify({"formatted_text": formatted_text})
|
||||||
safe_name = secure_filename(filename)
|
|
||||||
path = os.path.join(tempfile.gettempdir(), safe_name)
|
except ValueError as e:
|
||||||
if not os.path.exists(path):
|
app.logger.warning(f"Validation error from {request.remote_addr}: {e}")
|
||||||
return "File not found", 404
|
return jsonify({"error": str(e)}), 400
|
||||||
resp = send_file(path, as_attachment=True, download_name="formatted_output.txt")
|
except RuntimeError as e:
|
||||||
try:
|
app.logger.error(f"Runtime error from {request.remote_addr}: {e}")
|
||||||
os.unlink(path)
|
return jsonify({"error": "Processing failed"}), 500
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
app.logger.error(f"Unexpected error from {request.remote_addr}: {e}")
|
||||||
return resp
|
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 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", "")
|
|
||||||
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:
|
||||||
|
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})
|
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:
|
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__":
|
if __name__ == "__main__":
|
||||||
app.run(host="0.0.0.0", port=8000, debug=True) # debug=False in prod
|
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)
|
||||||
|
Reference in New Issue
Block a user