forked from claw/flaskpaste
Audit logging: - audit_log table with event tracking - app/audit.py module with log_event(), query_audit_log() - GET /audit endpoint (admin only) - configurable retention and cleanup Prometheus metrics: - app/metrics.py with custom counters - paste create/access/delete, rate limit, PoW, dedup metrics - instrumentation in API routes CLI clipboard integration: - fpaste create -C/--clipboard (read from clipboard) - fpaste create --copy-url (copy result URL) - fpaste get -c/--copy (copy content) - cross-platform: xclip, xsel, pbcopy, wl-copy Shell completions: - completions/ directory with bash/zsh/fish scripts - fpaste completion --shell command
242 lines
7.3 KiB
Python
242 lines
7.3 KiB
Python
"""Flask application factory."""
|
|
|
|
import logging
|
|
import os
|
|
import sys
|
|
import uuid
|
|
|
|
from flask import Flask, Response, g, request
|
|
from werkzeug.exceptions import HTTPException
|
|
|
|
from app.config import VERSION, config
|
|
|
|
|
|
def setup_logging(app: Flask) -> None:
|
|
"""Configure structured logging."""
|
|
log_level = logging.DEBUG if app.debug else logging.INFO
|
|
if app.debug:
|
|
log_format = "%(asctime)s %(levelname)s [%(name)s] %(message)s"
|
|
else:
|
|
log_format = (
|
|
'{"time":"%(asctime)s","level":"%(levelname)s",'
|
|
'"logger":"%(name)s","message":"%(message)s"}'
|
|
)
|
|
|
|
logging.basicConfig(
|
|
level=log_level,
|
|
format=log_format,
|
|
stream=sys.stdout,
|
|
)
|
|
|
|
# Reduce noise from werkzeug in production
|
|
if not app.debug:
|
|
logging.getLogger("werkzeug").setLevel(logging.WARNING)
|
|
|
|
app.logger.info("FlaskPaste starting", extra={"config": type(app.config).__name__})
|
|
|
|
|
|
def setup_security_headers(app: Flask) -> None:
|
|
"""Add security headers to all responses."""
|
|
|
|
@app.after_request
|
|
def add_security_headers(response: Response) -> Response:
|
|
"""Apply security headers to response.
|
|
|
|
Headers follow OWASP recommendations for API security.
|
|
"""
|
|
# Prevent MIME type sniffing
|
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
# Prevent clickjacking
|
|
response.headers["X-Frame-Options"] = "DENY"
|
|
# Referrer policy
|
|
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
|
# Content Security Policy (restrictive for API)
|
|
response.headers["Content-Security-Policy"] = "default-src 'none'; frame-ancestors 'none'"
|
|
# Permissions policy
|
|
response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
|
|
# HSTS - enforce HTTPS (1 year, include subdomains)
|
|
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
|
# Prevent caching of sensitive paste data
|
|
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, private"
|
|
response.headers["Pragma"] = "no-cache"
|
|
|
|
return response
|
|
|
|
|
|
def setup_request_id(app: Flask) -> None:
|
|
"""Add request ID tracking for log correlation and tracing."""
|
|
|
|
@app.before_request
|
|
def assign_request_id() -> None:
|
|
"""Assign unique request ID from header or generate new UUID."""
|
|
request_id = request.headers.get("X-Request-ID", "").strip()
|
|
if not request_id:
|
|
request_id = str(uuid.uuid4())
|
|
g.request_id = request_id
|
|
|
|
@app.after_request
|
|
def add_request_id_header(response: Response) -> Response:
|
|
"""Echo request ID in response header and log access."""
|
|
request_id = getattr(g, "request_id", None)
|
|
if request_id:
|
|
response.headers["X-Request-ID"] = request_id
|
|
|
|
# Access logging with request ID
|
|
app.logger.info(
|
|
"%s %s %s [rid=%s]",
|
|
request.method,
|
|
request.path,
|
|
response.status_code,
|
|
request_id or "-",
|
|
)
|
|
return response
|
|
|
|
|
|
def setup_error_handlers(app: Flask) -> None:
|
|
"""Register global error handlers with JSON responses."""
|
|
import json
|
|
|
|
@app.errorhandler(400)
|
|
def bad_request(error: HTTPException) -> Response:
|
|
"""Handle 400 Bad Request errors."""
|
|
app.logger.warning("Bad request: %s [rid=%s]", request.path, getattr(g, "request_id", "-"))
|
|
return Response(
|
|
json.dumps({"error": "Bad request"}),
|
|
status=400,
|
|
mimetype="application/json",
|
|
)
|
|
|
|
@app.errorhandler(404)
|
|
def not_found(error: HTTPException) -> Response:
|
|
"""Handle 404 Not Found errors."""
|
|
return Response(
|
|
json.dumps({"error": "Not found"}),
|
|
status=404,
|
|
mimetype="application/json",
|
|
)
|
|
|
|
@app.errorhandler(429)
|
|
def rate_limit_exceeded(error: HTTPException) -> Response:
|
|
"""Handle 429 Too Many Requests errors."""
|
|
app.logger.warning(
|
|
"Rate limit exceeded: %s from %s [rid=%s]",
|
|
request.path,
|
|
request.remote_addr,
|
|
getattr(g, "request_id", "-"),
|
|
)
|
|
return Response(
|
|
json.dumps({"error": "Rate limit exceeded", "retry_after": error.description}),
|
|
status=429,
|
|
mimetype="application/json",
|
|
)
|
|
|
|
@app.errorhandler(500)
|
|
def internal_error(error: HTTPException) -> Response:
|
|
"""Handle 500 Internal Server errors."""
|
|
app.logger.error(
|
|
"Internal error: %s - %s [rid=%s]",
|
|
request.path,
|
|
str(error),
|
|
getattr(g, "request_id", "-"),
|
|
)
|
|
return Response(
|
|
json.dumps({"error": "Internal server error"}),
|
|
status=500,
|
|
mimetype="application/json",
|
|
)
|
|
|
|
@app.errorhandler(Exception)
|
|
def handle_exception(error: Exception) -> Response:
|
|
"""Handle unhandled exceptions with generic 500 response."""
|
|
app.logger.exception(
|
|
"Unhandled exception: %s [rid=%s]", str(error), getattr(g, "request_id", "-")
|
|
)
|
|
return Response(
|
|
json.dumps({"error": "Internal server error"}),
|
|
status=500,
|
|
mimetype="application/json",
|
|
)
|
|
|
|
|
|
def setup_rate_limiting(app: Flask) -> None:
|
|
"""Configure rate limiting."""
|
|
from flask_limiter import Limiter
|
|
from flask_limiter.util import get_remote_address
|
|
|
|
limiter = Limiter(
|
|
key_func=get_remote_address,
|
|
app=app,
|
|
default_limits=["200 per day", "60 per hour"],
|
|
storage_uri="memory://",
|
|
strategy="fixed-window",
|
|
)
|
|
|
|
# Store limiter on app for use in routes
|
|
app.extensions["limiter"] = limiter
|
|
|
|
|
|
def setup_metrics(app: Flask) -> None:
|
|
"""Configure Prometheus metrics."""
|
|
# Only enable metrics in production
|
|
if app.config.get("TESTING"):
|
|
return
|
|
|
|
try:
|
|
from prometheus_flask_exporter import PrometheusMetrics
|
|
|
|
metrics = PrometheusMetrics(app)
|
|
|
|
# Add app info
|
|
metrics.info("flaskpaste_info", "FlaskPaste application info", version=VERSION)
|
|
|
|
app.extensions["metrics"] = metrics
|
|
|
|
# Setup custom metrics
|
|
from app.metrics import setup_custom_metrics
|
|
|
|
setup_custom_metrics(app)
|
|
except ImportError:
|
|
app.logger.warning("prometheus_flask_exporter not available, metrics disabled")
|
|
|
|
|
|
def create_app(config_name: str | None = None) -> Flask:
|
|
"""Create and configure the Flask application."""
|
|
if config_name is None:
|
|
config_name = os.environ.get("FLASK_ENV", "default")
|
|
|
|
app = Flask(__name__)
|
|
app.config.from_object(config[config_name])
|
|
|
|
# Setup logging first
|
|
setup_logging(app)
|
|
|
|
# Setup request ID tracking
|
|
setup_request_id(app)
|
|
|
|
# Setup security headers
|
|
setup_security_headers(app)
|
|
|
|
# Setup error handlers
|
|
setup_error_handlers(app)
|
|
|
|
# Setup rate limiting (skip in testing)
|
|
if not app.config.get("TESTING"):
|
|
setup_rate_limiting(app)
|
|
|
|
# Setup metrics (skip in testing)
|
|
setup_metrics(app)
|
|
|
|
# Initialize database
|
|
from app import database
|
|
|
|
database.init_app(app)
|
|
|
|
# Register blueprints
|
|
from app.api import bp as api_bp
|
|
|
|
app.register_blueprint(api_bp)
|
|
|
|
app.logger.info("FlaskPaste initialized successfully")
|
|
|
|
return app
|