#! /usr/bin/env python import datetime import functools import hashlib import json import logging import os import time import traceback import urllib.parse from collections.abc import Callable from logging.handlers import TimedRotatingFileHandler from typing import Any, ParamSpec from earwigbot.wiki.copyvios import globalize from flask import Flask, make_response, redirect, request, session from flask_mako import MakoTemplates, TemplateError, render_template from werkzeug import Response from copyvios.api import format_api_error, handle_api_request from copyvios.auth import clear_login_session, oauth_login_end, oauth_login_start from copyvios.cache import cache from copyvios.checker import CopyvioCheckError, ErrorCode, do_check from copyvios.cookies import get_new_cookies from copyvios.misc import get_notice from copyvios.query import CheckQuery from copyvios.settings import process_settings from copyvios.sites import update_sites app = Flask(__name__) MakoTemplates(app) app.config.from_pyfile("config.py", True) app.config["SESSION_COOKIE_SECURE"] = True app.config["SESSION_COOKIE_SAMESITE"] = "Lax" app.config["PERMANENT_SESSION_LIFETIME"] = datetime.timedelta(days=365) hand = TimedRotatingFileHandler("logs/app.log", when="midnight", backupCount=7) hand.setLevel(logging.DEBUG) app.logger.addHandler(hand) app.logger.info(f"Flask server started {time.asctime()}") globalize(num_workers=8) AnyResponse = Response | str | bytes P = ParamSpec("P") def catch_errors(func: Callable[P, AnyResponse]) -> Callable[P, AnyResponse]: @functools.wraps(func) def inner(*args: P.args, **kwargs: P.kwargs) -> AnyResponse: try: return func(*args, **kwargs) except TemplateError as exc: app.logger.error(f"Caught exception:\n{exc.text}") return render_template("error.mako", traceback=exc.text) except Exception: app.logger.exception("Caught exception:") return render_template("error.mako", traceback=traceback.format_exc()) return inner @app.after_request def add_new_cookies(response: Response) -> Response: for cookie in get_new_cookies(): response.headers.add("Set-Cookie", cookie) return response @app.after_request def write_access_log(response: Response) -> Response: app.logger.debug( f"{time.asctime()} {request.method} {request.path} " f"{request.values.to_dict()} -> {response.status_code}" ) return response @functools.lru_cache def _get_hash(path: str, mtime: float) -> str: # mtime is used as part of the cache key with open(path, "rb") as fp: return hashlib.sha1(fp.read()).hexdigest() def external_url_handler( error: Exception, endpoint: str, values: dict[str, Any] ) -> str: if endpoint == "static" and "file" in values: assert app.static_folder is not None path = os.path.join(app.static_folder, values["file"]) mtime = os.path.getmtime(path) hashstr = _get_hash(path, mtime) return f"/static/{values['file']}?v={hashstr}" raise error app.url_build_error_handlers.append(external_url_handler) @app.route("/") @catch_errors def index() -> AnyResponse: notice = get_notice() update_sites() query = CheckQuery.from_get_args() try: result = do_check(query) error = None except CopyvioCheckError as exc: if exc.code == ErrorCode.NOT_LOGGED_IN: target = "/login?next=" + urllib.parse.quote_plus( "/?" + request.query_string.decode(errors="ignore") ) return redirect(target, 302) result = None error = exc return render_template( "index.mako", notice=notice, query=query, result=result, error=error, ) @app.route("/login", methods=["GET", "POST"]) @catch_errors def login() -> AnyResponse: try: redirect_url = oauth_login_start() if request.method == "POST" else None if redirect_url: return redirect(redirect_url, 302) except Exception as e: app.logger.exception("OAuth login start failed") kwargs = {"error": str(e)} else: if session.get("username") is not None: return redirect("/", 302) kwargs = {"error": request.args.get("error")} return render_template("login.mako", **kwargs) @app.route("/logout", methods=["GET", "POST"]) @catch_errors def logout() -> AnyResponse: if request.method == "POST": clear_login_session() return redirect("/", 302) else: return render_template("logout.mako") @app.route("/oauth-callback") @catch_errors def oauth_callback() -> AnyResponse: try: next_url = oauth_login_end() except Exception as e: app.logger.exception("OAuth login end failed") return redirect("/login?error=" + urllib.parse.quote_plus(str(e)), 302) else: return redirect(next_url, 302) @app.route("/settings", methods=["GET", "POST"]) @catch_errors def settings() -> AnyResponse: status = process_settings() if request.method == "POST" else None update_sites() default = cache.bot.wiki.get_site() kwargs = { "status": status, "default_lang": default.lang, "default_project": default.project, } return render_template("settings.mako", **kwargs) @app.route("/api") @catch_errors def api() -> AnyResponse: return render_template("api.mako", help=True) @app.route("/api.json") @catch_errors def api_json() -> AnyResponse: if not request.args: return render_template("api.mako", help=True) format = request.args.get("format", "json") if format in ["json", "jsonfm"]: update_sites() try: result = handle_api_request() except Exception as exc: result = format_api_error("unhandled_exception", exc) else: errmsg = f"Unknown format: {format!r}" result = format_api_error("unknown_format", errmsg) if format == "jsonfm": return render_template("api.mako", help=False, result=result) resp = make_response(json.dumps(result)) resp.mimetype = "application/json" resp.headers["Access-Control-Allow-Origin"] = "*" return resp if __name__ == "__main__": app.run()