diff --git a/qt/aqt/mediasrv.py b/qt/aqt/mediasrv.py index a5bdb65db..cd8c0ab7b 100644 --- a/qt/aqt/mediasrv.py +++ b/qt/aqt/mediasrv.py @@ -1,20 +1,18 @@ # Copyright: Ankitects Pty Ltd and contributors # -*- coding: utf-8 -*- # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html - -from __future__ import annotations - -import http.server +import logging +import os import re -import socket -import socketserver +import sys import threading +import traceback from http import HTTPStatus -from typing import Optional -import aqt -from anki.collection import Collection -from anki.rsbackend import from_json_bytes +import flask +import flask_cors # type: ignore +from waitress.server import create_server # type: ignore + from anki.utils import devMode from aqt.qt import * from aqt.utils import aqt_data_folder @@ -33,181 +31,127 @@ def _getExportFolder(): _exportFolder = _getExportFolder() - -# webengine on windows sometimes opens a connection and fails to send a request, -# which will hang the server if unthreaded -class ThreadedHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer): - # allow for a flood of requests before we've started up properly - request_queue_size = 100 - - # work around python not being able to handle non-latin hostnames - def server_bind(self): - """Override server_bind to store the server name.""" - socketserver.TCPServer.server_bind(self) - host, port = self.server_address[:2] - try: - self.server_name = socket.getfqdn(host) - except: - self.server_name = "server" - self.server_port = port +app = flask.Flask(__name__) +flask_cors.CORS(app) class MediaServer(threading.Thread): - _port: Optional[int] = None _ready = threading.Event() daemon = True def __init__(self, mw, *args, **kwargs): super().__init__(*args, **kwargs) - self.mw = mw + self.is_shutdown = False + _redirectWebExports.mw = mw def run(self): - RequestHandler.mw = self.mw - desired_port = int(os.getenv("ANKI_API_PORT", "0")) - self.server = ThreadedHTTPServer(("127.0.0.1", desired_port), RequestHandler) - self._ready.set() - self.server.serve_forever() + try: + if devMode: + # idempotent if logging has already been set up + logging.basicConfig() + else: + logging.getLogger("waitress").setLevel(logging.ERROR) + + self.server = create_server(app, host="127.0.0.1", port=0) + if devMode: + print( + "Serving on http://%s:%s" + % (self.server.effective_host, self.server.effective_port) + ) + + self._ready.set() + self.server.run() + + except Exception: + if not self.is_shutdown: + raise + + def shutdown(self): + self.is_shutdown = True + sockets = list(self.server._map.values()) + for socket in sockets: + socket.handle_close() + # https://github.com/Pylons/webtest/blob/4b8a3ebf984185ff4fefb31b4d0cf82682e1fcf7/webtest/http.py#L93-L104 + self.server.task_dispatcher.shutdown() def getPort(self): self._ready.wait() - return self.server.server_port - - def shutdown(self): - self.server.shutdown() + return int(self.server.effective_port) -class RequestHandler(http.server.SimpleHTTPRequestHandler): - - timeout = 10 - mw: Optional[aqt.main.AnkiQt] = None - - def do_GET(self): - f = self.send_head() - if f: - try: - self.copyfile(f, self.wfile) - except Exception as e: - if devMode: - print("http server caught exception:", e) - else: - # swallow it - user likely surfed away from - # review screen before an image had finished - # downloading - pass - finally: - f.close() - - def send_head(self): - path = self.translate_path(self.path) - path = self._redirectWebExports(path) - try: - isdir = os.path.isdir(path) - except ValueError: - # path too long exception on Windows - self.send_error(HTTPStatus.NOT_FOUND, "File not found") - return None - - if isdir: - self.send_error(HTTPStatus.NOT_FOUND, "File not found") - return None - - ctype = self.guess_type(path) - try: - f = open(path, "rb") - except OSError: - self.send_error(HTTPStatus.NOT_FOUND, "File not found") - return None - try: - self.send_response(HTTPStatus.OK) - self.send_header("Content-type", ctype) - fs = os.fstat(f.fileno()) - self.send_header("Content-Length", str(fs[6])) - self.send_header("Last-Modified", self.date_time_string(fs.st_mtime)) - self.send_header("Access-Control-Allow-Origin", "*") - self.end_headers() - return f - except: - f.close() - raise - - def log_message(self, format, *args): - if not devMode: - return - print( - "%s - - [%s] %s" - % (self.address_string(), self.log_date_time_string(), format % args) +@app.route("/", defaults={"path": ""}) +@app.route("/") +def allroutes(path): + directory, path = _redirectWebExports(path) + try: + isdir = os.path.isdir(os.path.join(directory, path)) + except ValueError: + return flask.Response( + "Path for '%s - %s' is too long!" % (directory, path), + status=HTTPStatus.BAD_REQUEST, + mimetype="text/plain", ) - def _redirectWebExports(self, path): - # catch /_anki references and rewrite them to web export folder - targetPath = os.path.join(os.getcwd(), "_anki", "") - if path.startswith(targetPath): - newPath = os.path.join(_exportFolder, path[len(targetPath) :]) - return newPath + if isdir: + return flask.Response( + "Path for '%s - %s' is a directory (not supported)!" % (directory, path), + status=HTTPStatus.FORBIDDEN, + mimetype="text/plain", + ) - # catch /_addons references and rewrite them to addons folder - targetPath = os.path.join(os.getcwd(), "_addons", "") - if path.startswith(targetPath): - try: - addMgr = self.mw.addonManager - except AttributeError: - return path - - addonPath = path[len(targetPath) :] - - try: - addon, subPath = addonPath.split(os.path.sep, 1) - except ValueError: - return path - if not addon: - return path - - pattern = addMgr.getWebExports(addon) - if not pattern: - return path - - subPath2 = subPath.replace(os.sep, "/") - if re.fullmatch(pattern, subPath) or re.fullmatch(pattern, subPath2): - newPath = os.path.join(addMgr.addonsFolder(), addonPath) - return newPath - - return path - - def do_POST(self): - if not self.path.startswith("/_anki/"): - self.send_error(HTTPStatus.NOT_FOUND, "Method not found") - return - - cmd = self.path[len("/_anki/") :] - - if cmd == "graphData": - content_length = int(self.headers["Content-Length"]) - body = self.rfile.read(content_length) - data = graph_data(self.mw.col, **from_json_bytes(body)) - elif cmd == "i18nResources": - data = self.mw.col.backend.i18n_resources() - else: - self.send_error(HTTPStatus.NOT_FOUND, "Method not found") - return - - self.send_response(HTTPStatus.OK) - self.send_header("Content-Type", "application/binary") - self.send_header("Content-Length", str(len(data))) - self.send_header("Access-Control-Allow-Origin", "*") - self.end_headers() - - self.wfile.write(data) - - -def graph_data(col: Collection, search: str, days: int) -> bytes: try: - return col.backend.graphs(search=search, days=days) - except Exception as e: - # likely searching error - print(e) - return b"" + if devMode: + print("Sending file '%s - %s'" % (directory, path)) + return flask.send_from_directory(directory, path) + + except Exception as error: + if devMode: + print( + "Caught HTTP server exception,\n%s" + % "".join(traceback.format_exception(*sys.exc_info())), + ) + + # swallow it - user likely surfed away from + # review screen before an image had finished + # downloading + return flask.Response( + "For path '%s - %s' %s!" % (directory, path, error), + status=HTTPStatus.INTERNAL_SERVER_ERROR, + mimetype="text/plain", + ) -# work around Windows machines with incorrect mime type -RequestHandler.extensions_map[".css"] = "text/css" +def _redirectWebExports(path): + # catch /_anki references and rewrite them to web export folder + targetPath = "_anki/" + if path.startswith(targetPath): + return _exportFolder, path[len(targetPath) :] + + # catch /_addons references and rewrite them to addons folder + targetPath = "_addons/" + if path.startswith(targetPath): + addonPath = path[len(targetPath) :] + + try: + addMgr = _redirectWebExports.mw.addonManager + except AttributeError as error: + if devMode: + print("_redirectWebExports: %s" % error) + return _exportFolder, addonPath + + try: + addon, subPath = addonPath.split(os.path.sep, 1) + except ValueError: + return addMgr.addonsFolder(), path + if not addon: + return addMgr.addonsFolder(), path + + pattern = addMgr.getWebExports(addon) + if not pattern: + return addMgr.addonsFolder(), path + + if re.fullmatch(pattern, subPath): + return addMgr.addonsFolder(), addonPath + + return _redirectWebExports.mw.col.media.dir(), path diff --git a/qt/setup.py b/qt/setup.py index dbaa859ba..e370e2dd5 100644 --- a/qt/setup.py +++ b/qt/setup.py @@ -28,6 +28,9 @@ install_requires = [ "jsonschema", # "pyaudio", # https://anki.tenderapp.com/discussions/add-ons/44009-problems-with-code-completion # "pyqtwebengine", # https://github.com/ankitects/anki/pull/530 - Set to checks.yml install and import anki wheels + "flask", + "flask_cors", + "waitress", "pyqt5>=5.9", 'psutil; sys.platform == "win32"', 'pywin32; sys.platform == "win32"',