#! /usr/bin/env python import functools import hashlib import json import os import time import traceback import urllib.parse from typing import Any from earwigbot.wiki.copyvios import globalize from flask import make_response, redirect, render_template, request, session from werkzeug import Response from werkzeug.exceptions import HTTPException from copyvios import app from copyvios.api import format_api_error, handle_api_request from copyvios.attribution import get_attribution_info from copyvios.auth import clear_login_session, oauth_login_end, oauth_login_start from copyvios.background import get_background from copyvios.cache import cache from copyvios.checker import ( T_POSSIBLE, T_SUSPECT, CopyvioCheckError, ErrorCode, do_check, ) from copyvios.cookies import get_cookies, get_new_cookies from copyvios.highlighter import highlight_delta from copyvios.misc import get_notice, get_permalink from copyvios.query import CheckQuery from copyvios.settings import process_settings from copyvios.sites import update_sites AnyResponse = Response | str | bytes app.logger.info(f"Flask server started {time.asctime()}") globalize(num_workers=8) @app.errorhandler(Exception) def handle_errors(exc: Exception) -> AnyResponse | HTTPException: if isinstance(exc, HTTPException): return exc if app.debug: raise # Use built-in debugger app.logger.exception("Caught exception:") error_page = render_template("error.html.jinja", traceback=traceback.format_exc()) return make_response(error_page, 500) @app.context_processor def setup_context() -> dict[str, Any]: return { "T_POSSIBLE": T_POSSIBLE, "T_SUSPECT": T_SUSPECT, "ErrorCode": ErrorCode, "cache": cache, "dump_json": json.dumps, "get_attribution_info": get_attribution_info, "get_background": get_background, "get_cookies": get_cookies, "get_notice": get_notice, "get_permalink": get_permalink, "highlight_delta": highlight_delta, } @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("/") def index() -> AnyResponse: 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) app.logger.exception(f"Copyvio check failed on {query}") result = None error = exc return render_template( "index.html.jinja", query=query, result=result, error=error, splash=not result, ) @app.route("/login", methods=["GET", "POST"]) 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") error_str = str(e) else: if session.get("username") is not None: return redirect("/", 302) error_str = request.args.get("error") is_search = False if request.args.get("next", "").startswith("/?"): next_query = dict(urllib.parse.parse_qsl(request.args["next"])) if next_query.get("action") == "search": is_search = True return render_template("login.html.jinja", error=error_str, is_search=is_search) @app.route("/logout", methods=["GET", "POST"]) def logout() -> AnyResponse: if request.method == "POST": clear_login_session() return redirect("/", 302) else: return render_template("logout.html.jinja") @app.route("/oauth-callback") 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"]) def settings() -> AnyResponse: status = process_settings() if request.method == "POST" else None update_sites() return render_template( "settings.html.jinja", status=status, default_site=cache.bot.wiki.get_site(), splash=True, ) @app.route("/api") def api() -> AnyResponse: return render_template("api_help.html.jinja") @app.route("/api.json") def api_json() -> AnyResponse: if not request.args: return render_template("api_help.html.jinja") format = request.args.get("format", "json") if format in ["json", "jsonfm"]: update_sites() try: result = handle_api_request() except Exception as exc: app.logger.exception("API request failed") 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_result.html.jinja", result=result) resp = make_response(json.dumps(result)) resp.mimetype = "application/json" resp.headers["Access-Control-Allow-Origin"] = "*" return resp if app.debug: # Silence browser 404s when testing @app.route("/favicon.ico") def favicon() -> AnyResponse: return app.send_static_file("favicon.ico") if __name__ == "__main__": app.run()