glo/app.py

249 lines
6.5 KiB
Python

import html
import mimetypes
import os
import secrets
from pathlib import Path
from flask import Flask, g, jsonify, render_template, request, session
from werkzeug.exceptions import RequestEntityTooLarge
from werkzeug.middleware.proxy_fix import ProxyFix
from werkzeug.utils import secure_filename
from config import (
APP_ROOT,
CALLS_BASE,
CALL_HELPER_SHARED_SECRET,
CALL_TOKEN_TTL_SECONDS,
CLIENT_HASH_KEY,
CONSTANTS,
DB_PATH,
PRIVATE_ACCESS_KEY,
RUNTIME_STATE,
SECRET_KEY,
TRUSTED_PROXY_HOPS,
UPLOAD_DIR,
ATTACHMENT_CLEANUP_INTERVAL_SECONDS,
ATTACHMENT_ORPHAN_TTL_HOURS,
flask_config,
get_asset_version,
now_iso,
parse_iso,
utc_now,
)
from db import get_db, db_write, init_db, bootstrap_admin
from auth import csrf_token, request_blacklisted
from attachments import maybe_cleanup_orphaned_attachments, cleanup_orphaned_attachments
from routes import register_routes
app = Flask(__name__)
app.secret_key = SECRET_KEY
if TRUSTED_PROXY_HOPS > 0:
app.wsgi_app = ProxyFix(
app.wsgi_app,
x_for=TRUSTED_PROXY_HOPS,
x_proto=TRUSTED_PROXY_HOPS,
x_host=TRUSTED_PROXY_HOPS,
)
app.config.update(flask_config())
app.os = os
app.secrets = secrets
app.mimetypes = mimetypes
app.secure_filename = secure_filename
app.timedelta = __import__("datetime").timedelta
def get_asset_template_values():
return {
"asset_version": get_asset_version(),
"csp_nonce": getattr(g, "csp_nonce", ""),
}
@app.before_request
def setup_request_security():
g.csp_nonce = __import__("base64").b64encode(secrets.token_bytes(18)).decode("ascii")
if request_blacklisted():
if request.path.startswith("/api/"):
return jsonify({"error": "Access to this service has been blocked.", "code": "blacklisted"}), 403
try:
return render_template("blocked.html", asset_version=get_asset_version()), 403
except Exception:
return (
"<!doctype html><html><head><meta charset='utf-8'><title>access blocked</title></head>"
"<body><h1>access blocked</h1><p>access is unavailable.</p></body></html>",
403,
)
@app.context_processor
def inject_template_values():
return get_asset_template_values()
@app.after_request
def add_security_headers(response):
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "same-origin"
response.headers["Permissions-Policy"] = "camera=(self), microphone=(self), geolocation=()"
response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
response.headers["Cross-Origin-Resource-Policy"] = "same-origin"
calls_connect_origin = CALLS_BASE
calls_ws_origin = CALLS_BASE.replace("https://", "wss://").replace("http://", "ws://")
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"base-uri 'self'; "
"form-action 'self'; "
"frame-ancestors 'none'; "
"object-src 'none'; "
"img-src 'self' data: blob:; "
"media-src 'self' blob:; "
"style-src 'self' 'unsafe-inline'; "
"script-src 'self'; "
f"connect-src 'self' {calls_connect_origin} {calls_ws_origin};"
)
if request.path.startswith("/static/"):
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
else:
response.headers["Cache-Control"] = "no-store"
return response
@app.errorhandler(RequestEntityTooLarge)
def request_too_large(_error):
return jsonify({"error": "Request is too large."}), 413
@app.errorhandler(__import__("sqlite3").OperationalError)
def sqlite_error(error):
app.logger.exception("Database error: %s", error)
return jsonify({"error": "A database error occurred."}), 500
@app.route("/")
def index():
return render_template("index.html")
@app.route("/api/csrf")
def api_csrf():
return jsonify({"csrf_token": csrf_token()})
@app.route("/api/me")
def api_me():
from auth import current_user
user = current_user()
return jsonify(
{
"authenticated": bool(user),
"user": user,
"csrf_token": csrf_token(),
}
)
@app.route("/api/register", methods=["POST"])
def register():
from auth import rate_limited, csrf_valid, safe_json, authenticate_account, audit, login_user, register_account
limited = rate_limited("register")
if limited:
return limited
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
try:
data = safe_json()
except ValueError as error:
return jsonify({"error": str(error)}), 400
authenticated, error = register_account(data)
if error:
return error
return jsonify(
{
"status": "registered",
"user": authenticated,
"csrf_token": csrf_token(),
}
), 201
@app.route("/api/login", methods=["POST"])
def login():
from auth import rate_limited, csrf_valid, safe_json, authenticate_account
limited = rate_limited("login")
if limited:
return limited
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
try:
data = safe_json()
except ValueError as error:
return jsonify({"error": str(error)}), 400
authenticated, error = authenticate_account(data)
if error:
return error
return jsonify(
{
"status": "logged_in",
"user": authenticated,
"csrf_token": csrf_token(),
}
)
@app.route("/api/logout", methods=["POST"])
def logout():
from auth import csrf_valid
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
session.clear()
return jsonify({"status": "logged_out", "csrf_token": csrf_token()})
def _maybe_cleanup_orphaned_attachments():
maybe_cleanup_orphaned_attachments(
RUNTIME_STATE,
ATTACHMENT_CLEANUP_INTERVAL_SECONDS,
lambda: cleanup_orphaned_attachments(
UPLOAD_DIR,
utc_now,
ATTACHMENT_ORPHAN_TTL_HOURS,
app.logger,
),
app.logger,
)
app.maybe_cleanup_orphaned_attachments = _maybe_cleanup_orphaned_attachments
register_routes(app)
init_db()
bootstrap_admin()
if __name__ == "__main__":
app.run(
host="0.0.0.0",
port=int(os.environ.get("PORT", 5000)),
debug=os.environ.get("FLASK_DEBUG", "0") == "1",
)