Files
flaskpaste/app/__init__.py
Username 7063f8718e feat: add observability and CLI enhancements
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
2025-12-23 22:39:50 +01:00

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