diff --git a/eaapi/server b/eaapi/server deleted file mode 160000 index dec7ca3..0000000 --- a/eaapi/server +++ /dev/null @@ -1 +0,0 @@ -Subproject commit dec7ca3536cf459be21b7284358bf2bea4eb3d14 diff --git a/eaapi/server/README.md b/eaapi/server/README.md new file mode 100644 index 0000000..efdd9cc --- /dev/null +++ b/eaapi/server/README.md @@ -0,0 +1,43 @@ +# eaapi.server + +## Quickstart + +```py +server = EAMServer("http://127.0.0.1:5000") + +@server.handler("message", "get") +def message(ctx): + ctx.resp.append("message", expire="300", status="0") + +server.run("0.0.0.0", 5000) +``` + +```py +EAMServer( + # The URL this server can be access at. Used for services + public_url, + # Add `//` as a prefix when generating service urls (useful when debugging games) + prefix_services: bool = False, + # If both the URL and the query params match, which one gets the final say? + prioritise_params: bool = False, + # Include e-Amusement specific details of why requests failed in the responses + verbose_errors: bool = False, + # The operation mode in services.get's response + services_mode: eaapi.const.ServicesMode = eaapi.const.ServicesMode.Operation, + # The NTP server to use in services.get + ntp_server: str = "ntp://pool.ntp.org/", + # Keepalive server to use in serices.get. We'll use our own if one is not specified + keepalive_server: str = None +) + +@handler( + # Module name to handle. Will curry if method is not provided + module, + # Method name to handle + method=None, + # The datecode prefix to match during routing + dc_prefix=None, + # The service to use. Likely `local` or `local2` when handling game functions + service=None +) +``` diff --git a/eaapi/server/__init__.py b/eaapi/server/__init__.py new file mode 100644 index 0000000..3daf76f --- /dev/null +++ b/eaapi/server/__init__.py @@ -0,0 +1,13 @@ +from .server import EAMServer +from .context import CallContext +from .model import Model, ModelMatcher, DatecodeMatcher +from .exceptions import EAMHTTPException +from .controller import Controller + +__all__ = ( + "EAMServer", + "CallContext", + "Model", "ModelMatcher", "DatecodeMatcher", + "EAMHTTPException", + "Controller", +) diff --git a/eaapi/server/__main__.py b/eaapi/server/__main__.py new file mode 100644 index 0000000..8884709 --- /dev/null +++ b/eaapi/server/__main__.py @@ -0,0 +1,4 @@ +from .server import EAMServer + +app = EAMServer("http://127.0.0.1:5000", verbose_errors=True) +app.run("0.0.0.0", 5000, debug=True) diff --git a/eaapi/server/const.py b/eaapi/server/const.py new file mode 100644 index 0000000..e02fd82 --- /dev/null +++ b/eaapi/server/const.py @@ -0,0 +1,45 @@ +# Services where the module and service name match +TRIVIAL_SERVICES = [ + "pcbtracker", + "message", + "facility", + "pcbevent", + "cardmng", + "package", + "userdata", + "userid", + "dlstatus", + "eacoin", + # "traceroute", + "apsmanager", + "sidmgr", +] + +# Just chilling here until I figure out where these route +UNMAPPED_SERVICES = { + "???0": "numbering", + "???1": "pkglist", + "???2": "posevent", + "???3": "lobby", + "???4": "lobby2", + "???5": "netlog", # ins.netlog (?) + "???6": "globby", + "???7": "matching", + "???8": "netsci", +} + +MODULE_SERVICES = "services" +RESERVED_MODULES = [ + MODULE_SERVICES, +] + +METHOD_SERVICES_GET = "get" + +SERVICE_SERVICES = "services" +SERVICE_KEEPALIVE = "keepalive" +SERVICE_NTP = "ntp" +RESERVED_SERVICES = [ + SERVICE_SERVICES, + SERVICE_KEEPALIVE, + SERVICE_NTP, +] diff --git a/eaapi/server/context.py b/eaapi/server/context.py new file mode 100644 index 0000000..6af7b21 --- /dev/null +++ b/eaapi/server/context.py @@ -0,0 +1,105 @@ +import eaapi + +from . import exceptions as exc +from .model import Model + + +NODE_CALL = "call" +NODE_RESP = "response" + + +class CallContext: + def __init__(self, request, decoder, call, eainfo, compressed): + if call.name != NODE_CALL: + raise exc.CallNodeMissing + self._request = request + self._decoder = decoder + self._call: eaapi.XMLNode = call + self._eainfo: str | None = eainfo + self._compressed: bool = compressed + self._resp: eaapi.XMLNode = eaapi.XMLNode.void(NODE_RESP) + + self._module: str | None = None + self._method: str | None = None + self._url_slash: bool | None = None + + self._model: Model = Model.from_model_str(call.get("model")) + + @property + def module(self): + return self._module + + @property + def method(self): + return self._method + + @property + def url_slash(self): + return self._url_slash + + @property + def request(self): + return self._request + + @property + def was_xml_string(self): + return self._decoder.is_xml_string + + @property + def was_compressed(self): + return self._compressed + + @property + def call(self): + return self._call + + @property + def resp(self): + return self._resp + + @property + def model(self): + return self._model + + @property + def srcid(self): + return self._call.get("srcid") + + @property + def tag(self): + return self._call.get("tag") + + def get_root(self): + return self.call.xpath(self.module) + + def abort(self, status="1"): + return self.resp.append(self.module, status=status) + + def ok(self): + return self.abort("0") + + +class ResponseContext: + def __init__(self, resp, decoder, response, compressed): + if response.name != NODE_RESP: + raise exc.CallNodeMissing + self._resp = resp + self._decoder = decoder + self._response = response + self._compressed = compressed + + @property + def resp(self): + return self._resp + + @property + def decoder(self): + return self._decoder + + @property + def response(self): + return self._response + + @property + def compressed(self): + return self._compressed diff --git a/eaapi/server/controller.py b/eaapi/server/controller.py new file mode 100644 index 0000000..f52ddb4 --- /dev/null +++ b/eaapi/server/controller.py @@ -0,0 +1,210 @@ +from typing import Callable +from collections import defaultdict +from abc import ABC + +from .context import CallContext +from .model import ModelMatcher +from .const import RESERVED_MODULES, RESERVED_SERVICES, TRIVIAL_SERVICES, MODULE_SERVICES, METHOD_SERVICES_GET + +import eaapi + + +Handler = Callable[[CallContext], None] + + +class IController(ABC): + _name: str + + def get_handler(self, ctx: CallContext) -> Handler | None: + raise NotImplementedError + + def get_service_routes(self, ctx: CallContext | None) -> dict[str, str]: + raise NotImplementedError + + def serviced_prefixes(self) -> list[str]: + raise NotImplementedError + + +class Controller(IController): + def __init__(self, server, endpoint="", matcher: None | ModelMatcher = None): + from .server import EAMServer + self._server: EAMServer = server + server.controllers.append(self) + + import inspect + caller = inspect.getmodule(inspect.stack()[1][0]) + assert caller is not None + self._name = caller.__name__ + + self._handlers: dict[ + tuple[str | None, str | None], + list[tuple[ModelMatcher, Handler]] + ] = defaultdict(lambda: []) + self._endpoint = endpoint + self._matcher = matcher + + self._services: set[tuple[ModelMatcher, str]] = set() + + self._pre_handler: list[Callable[[CallContext, Handler], Handler]] = [] + + @property + def server(self): + return self._server + + def on_pre(self, callback): + if callback not in self._pre_handler: + self._pre_handler.append(callback) + + def add_dummy_service( + self, + service: str, + matcher: ModelMatcher | None = None, + unsafe_force_bypass_reserved: bool = False + ): + if not unsafe_force_bypass_reserved: + if service in RESERVED_SERVICES: + raise KeyError( + f"{service} is a reserved service provided by default.\n" + "Pass unsafe_force_bypass_reserved=True to override this implementation" + ) + if matcher is None: + matcher = ModelMatcher() + self._services.add((matcher, service)) + + def register_handler( + self, + handler: Handler, + module: str, + method: str, + matcher: ModelMatcher | None = None, + service: str | None = None, + unsafe_force_bypass_reserved: bool = False + ): + if not unsafe_force_bypass_reserved: + if module in RESERVED_MODULES: + raise KeyError( + f"{module} is a reserved module provided by default.\n" + "Pass unsafe_force_bypass_reserved=True to override this implementation" + ) + + if service is not None and service in RESERVED_SERVICES: + raise KeyError( + f"{service} is a reserved service provided by default.\n" + "Pass unsafe_force_bypass_reserved=True to override this implementation" + ) + + if service is None: + if module not in TRIVIAL_SERVICES: + raise ValueError(f"Unable to identify service for {module}") + service = module + + handlers = self._handlers[(module, method)] + for i in handlers: + if matcher is None and i[0] is None: + raise ValueError(f"Duplicate default handler for {module}.{method}") + if matcher == i[0]: + raise ValueError(f"Duplicate handler for {module}.{method} ({matcher})") + matcher_ = matcher or ModelMatcher() + handlers.append((matcher_, handler)) + handlers.sort(key=lambda x: x[0]) + + self._services.add((matcher_, service)) + + def handler( + self, + module: str, + method: str | None = None, + matcher: ModelMatcher | None = None, + service: str | None = None, + unsafe_force_bypass_reserved: bool = False + ): + if method is None: + def h2(method): + return self.handler(module, method, matcher, service) + return h2 + + # Commented out for MachineCallContext bodge + # def decorator(handler: Handler): + def decorator(handler): + self.register_handler(handler, module, method, matcher, service, unsafe_force_bypass_reserved) + return handler + return decorator + + def get_handler(self, ctx): + if self._matcher is not None: + if not self._matcher.matches(ctx.model): + return None + + handlers = self._handlers[(ctx.module, ctx.method)] + if not handlers: + return None + + for matcher, handler in handlers: + if matcher.matches(ctx.model): + for i in self._pre_handler: + handler = i(ctx, handler) + return handler + + return None + + def get_service_routes(self, ctx: CallContext | None): + endpoint = self._server.expand_url(self._endpoint) + + if ctx is None: + return { + service: endpoint + for _, service in self._services + } + + if self._matcher is not None and not self._matcher.matches(ctx.model): + return {} + + return { + service: endpoint + for matcher, service in self._services + if matcher.matches(ctx.model) + } + + def serviced_prefixes(self) -> list[str]: + return [self._endpoint] + + +class ServicesController(IController): + def __init__(self, server, services_mode: eaapi.const.ServicesMode): + from .server import EAMServer + self._server: EAMServer = server + + self.services_mode = services_mode + + self._name = __name__ + "." + self.__class__.__name__ + + def service_routes_for(self, ctx: CallContext): + services = defaultdict(lambda: self._server.public_url) + for service, route in self._server.get_service_routes(ctx): + services[service] = self._server.expand_url(route) + + return services + + def get_handler(self, ctx: CallContext) -> Handler | None: + if ctx.module == MODULE_SERVICES and ctx.method == METHOD_SERVICES_GET: + return self.services_get + return None + + def service_route(self, for_service: str, ctx: CallContext): + routes = self.service_routes_for(ctx) + return routes[for_service] + + def services_get(self, ctx: CallContext): + services = ctx.resp.append( + MODULE_SERVICES, expire="600", mode=self.services_mode.value, status="0" + ) + + routes = self._server.get_service_routes(ctx) + for service in routes: + services.append("item", name=service, url=routes[service]) + + def get_service_routes(self, ctx: CallContext | None) -> dict[str, str]: + return {} + + def serviced_prefixes(self) -> list[str]: + return [""] diff --git a/eaapi/server/exceptions.py b/eaapi/server/exceptions.py new file mode 100644 index 0000000..d2a956b --- /dev/null +++ b/eaapi/server/exceptions.py @@ -0,0 +1,51 @@ +from werkzeug.exceptions import HTTPException + + +class EAMHTTPException(HTTPException): + code = None + eam_description = None + + +class InvalidUpstream(EAMHTTPException): + code = 400 + eam_description = "Upstream URL invalid" + + +class UpstreamFailed(EAMHTTPException): + code = 400 + eam_description = "Upstream request failed" + + +class UnknownCompression(EAMHTTPException): + code = 400 + eam_description = "Unknown compression type" + + +class InvalidPacket(EAMHTTPException): + code = 400 + eam_description = "Invalid XML packet" + + +class InvalidModel(EAMHTTPException): + code = 400 + eam_description = "Invalid model" + + +class ModelMissmatch(EAMHTTPException): + code = 400 + eam_description = "Model missmatched" + + +class ModuleMethodMissing(EAMHTTPException): + code = 400 + eam_description = "Module or method missing" + + +class CallNodeMissing(EAMHTTPException): + code = 400 + eam_description = " node missing" + + +class NoMethodHandler(EAMHTTPException): + code = 404 + eam_description = "No handler found for module/method" diff --git a/eaapi/server/model.py b/eaapi/server/model.py new file mode 100644 index 0000000..21eecac --- /dev/null +++ b/eaapi/server/model.py @@ -0,0 +1,201 @@ +from dataclasses import dataclass + +import eaapi + + +class Model: + def __init__(self, gamecode: str, dest: str, spec: str, rev: str, datecode: str): + self._gamecode = gamecode + self._dest = dest + self._spec = spec + self._rev = rev + self._datecode = datecode + + @classmethod + def from_model_str(cls, model: str) -> "Model": + return cls(*eaapi.parse_model(model)) + + @property + def gamecode(self): + return self._gamecode + + @property + def dest(self): + return self._dest + + @property + def spec(self): + return self._spec + + @property + def rev(self): + return self._rev + + @property + def datecode(self): + return int(self._datecode) + + @property + def year(self): + return int(self._datecode[:4]) + + @property + def month(self): + return int(self._datecode[4:6]) + + @property + def day(self): + return int(self._datecode[6:8]) + + @property + def minor(self): + return int(self._datecode[8:10]) + + def __hash__(self): + return hash(str(self)) + + def __eq__(self, other): + if not isinstance(other, Model): + return False + return str(other) == str(self) + + def __str__(self): + return f"{self.gamecode}:{self.dest}:{self.spec}:{self.rev}:{self.datecode}" + + def __repr__(self): + return f"" + + +@dataclass +class DatecodeMatcher: + year: int | None = None + month: int | None = None + day: int | None = None + minor: int | None = None + + @classmethod + def from_str(cls, datecode: str): + if len(datecode) != 10 or not datecode.isdigit(): + raise ValueError("Not a valid datecode") + return cls( + int(datecode[0:4]), + int(datecode[4:6]), + int(datecode[6:8]), + int(datecode[8:10]) + ) + + def _num_filters(self): + num = 0 + if self.year is not None: + num += 1 + if self.month is not None: + num += 1 + if self.day is not None: + num += 1 + if self.minor is not None: + num += 1 + return num + + def __lt__(self, other): + if self._num_filters() < other._num_filters(): + return False + if self.minor is None and other.minor is not None: + return False + if self.day is None and other.day is not None: + return False + if self.month is None and other.month is not None: + return False + if self.year is None and other.year is not None: + return False + return True + + def __hash__(self): + return hash(str(self)) + + def __str__(self): + year = self.year if self.year is not None else "----" + month = self.month if self.month is not None else "--" + day = self.day if self.day is not None else "--" + minor = self.minor if self.minor is not None else "--" + return f"{year:04}{month:02}{day:02}{minor:02}" + + def matches(self, model): + if self.year is not None and model.year != self.year: + return False + if self.month is not None and model.month != self.month: + return False + if self.day is not None and model.day != self.day: + return False + if self.minor is not None and model.minor != self.minor: + return False + return True + + +@dataclass +class ModelMatcher: + gamecode: str | None = None + dest: str | None = None + spec: str | None = None + rev: str | None = None + datecode: list[DatecodeMatcher] | DatecodeMatcher | None = None + + def _num_filters(self): + num = 0 + if self.gamecode is not None: + num += 1 + if self.dest is not None: + num += 1 + if self.spec is not None: + num += 1 + if self.rev is not None: + num += 1 + if isinstance(self.datecode, list): + num += sum(i._num_filters() for i in self.datecode) + elif self.datecode is not None: + num += self.datecode._num_filters() + return num + + def __lt__(self, other): + if self._num_filters() < other._num_filters(): + return False + if self.datecode is None and other.datecode is not None: + return False + if self.rev is None and other.rev is not None: + return False + if self.spec is None and other.spec is not None: + return False + if self.dest is None and other.dest is not None: + return False + if self.gamecode is None and other.gamecode is not None: + return False + return True + + def __hash__(self): + return hash(str(self)) + + def __str__(self): + gamecode = self.gamecode if self.gamecode is not None else "---" + dest = self.dest if self.dest is not None else "-" + spec = self.spec if self.spec is not None else "-" + rev = self.rev if self.rev is not None else "-" + datecode = self.datecode if self.datecode is not None else "-" * 10 + if isinstance(self.datecode, list): + datecode = "/".join(str(i) for i in self.datecode) + if not datecode: + datecode = "-" * 10 + return f"{gamecode:3}:{dest}:{spec}:{rev}:{datecode}" + + def matches(self, model): + if self.gamecode is not None and model.gamecode != self.gamecode: + return False + if self.dest is not None and model.dest != self.dest: + return False + if self.spec is not None and model.spec != self.spec: + return False + if self.rev is not None and model.rev != self.rev: + return False + if isinstance(self.datecode, list): + return any(i.matches(model) for i in self.datecode) + if self.datecode is not None: + return self.datecode.matches(model) + return True diff --git a/eaapi/server/server.py b/eaapi/server/server.py new file mode 100644 index 0000000..4ac0a6c --- /dev/null +++ b/eaapi/server/server.py @@ -0,0 +1,397 @@ +import traceback +import urllib.parse +import urllib +import sys +import os + +from typing import Callable +from collections import defaultdict + +from werkzeug.exceptions import HTTPException, MethodNotAllowed +from werkzeug.wrappers import Request, Response +from werkzeug.routing import Map, Rule + +import eaapi + +from . import exceptions as exc +from .context import CallContext +from .model import Model +from .controller import IController, ServicesController +from .const import SERVICE_NTP, SERVICE_KEEPALIVE + + +Handler = Callable[[CallContext], None] + + +HEADER_ENCRYPTION = "X-Eamuse-Info" +HEADER_COMPRESSION = "X-Compress" + +PINGABLE_IP = "127.0.0.1" + + +class NetworkState: + def __init__(self): + self._pa = PINGABLE_IP # TODO: what does this one mean? + self.router_ip = PINGABLE_IP + self.gateway_ip = PINGABLE_IP + self.center_ip = PINGABLE_IP + + def format_ka(self, base): + return base + "?" + urllib.parse.urlencode({ + "pa": self.pa, + "ia": self.ia, + "ga": self.ga, + "ma": self.ma, + "t1": self.t1, + "t2": self.t2, + }) + + @property + def pa(self) -> str: + return self._pa + + @property + def ia(self) -> str: + return self.router_ip + + @property + def ga(self) -> str: + return self.gateway_ip + + @property + def ma(self) -> str: + return self.center_ip + + # TODO: Identify what these values are. Ping intervals? + @property + def t1(self): + return 2 + + @property + def t2(self): + return 10 + + +class EAMServer: + def __init__( + self, + public_url: str, + prioritise_params: bool = False, + verbose_errors: bool = False, + services_mode: eaapi.const.ServicesMode = eaapi.const.ServicesMode.Operation, + ntp_server: str = "ntp://pool.ntp.org/", + keepalive_server: str | None = None, + no_keepalive_route: bool = False, + disable_routes: bool = False, + no_services_handler: bool = False, + ): + self.network = NetworkState() + + self.verbose_errors = verbose_errors + + self._prioritise_params = prioritise_params + self._public_url = public_url + + self.disable_routes = disable_routes + self._no_keepalive_route = no_keepalive_route + + self.ntp = ntp_server + self.keepalive = keepalive_server or f"{public_url}/keepalive" + + self._prng = eaapi.crypt.new_prng() + + self._setup = [] + self._pre_handlers_check = [] + self._teardown = [] + + self._einfo_ctx: CallContext | None = None + self._einfo_controller: str | None = None + + self.controllers: list[IController] = [] + if not no_services_handler: + self.controllers.append(ServicesController(self, services_mode)) + + def on_setup(self, callback): + if callback not in self._setup: + self._setup.append(callback) + + def on_pre_handlers_check(self, callback): + if callback not in self._pre_handlers_check: + self._pre_handlers_check.append(callback) + + def on_teardown(self, callback): + if callback not in self._teardown: + self._teardown.append(callback) + + def build_rules_map(self) -> Map: + if self.disable_routes: + return Map([]) + + rules = Map([], strict_slashes=False, merge_slashes=False) + + prefixes = {"/"} + for i in self.controllers: + for prefix in i.serviced_prefixes(): + prefix = self.expand_url(prefix) + if not prefix.startswith(self._public_url): + continue + prefix = prefix[len(self._public_url):] + if prefix == "": + prefix = "/" + + prefixes.add(prefix) + + for i in prefixes: + rules.add(Rule(f"{i}///", endpoint="xrpc_request")) + # WSGI flattens the // at the start + if i == "/": + rules.add(Rule("///", endpoint="xrpc_request")) + rules.add(Rule(f"{i}", endpoint="xrpc_request")) + + if not self._no_keepalive_route: + rules.add(Rule("/keepalive", endpoint="keepalive_request")) + + return rules + + def expand_url(self, url: str) -> str: + return urllib.parse.urljoin(self._public_url, url) + + @property + def public_url(self) -> str: + return self._public_url + + def get_service_routes(self, ctx: CallContext | None) -> dict[str, str]: + services: dict[str, str] = defaultdict(lambda: self.public_url) + services[SERVICE_NTP] = self.ntp + services[SERVICE_KEEPALIVE] = self.network.format_ka(self.keepalive) + + for i in self.controllers: + services.update(i.get_service_routes(ctx)) + return services + + def _decode_request(self, request: Request) -> CallContext: + ea_info = request.headers.get(HEADER_ENCRYPTION) + compression = request.headers.get(HEADER_COMPRESSION) + + compressed = False + if compression == eaapi.Compression.Lz77.value: + compressed = True + elif compression != eaapi.Compression.None_.value: + raise exc.UnknownCompression + + payload = eaapi.unwrap(request.data, ea_info, compressed) + decoder = eaapi.Decoder(payload) + try: + call = decoder.unpack() + except eaapi.EAAPIException: + raise exc.InvalidPacket + + return CallContext(request, decoder, call, ea_info, compressed) + + def _encode_response(self, ctx: CallContext) -> Response: + if ctx._eainfo is None: + ea_info = None + else: + ea_info = eaapi.crypt.get_key(self._prng) + + encoded = eaapi.Encoder.encode(ctx.resp, ctx.was_xml_string) + wrapped = eaapi.wrap(encoded, ea_info, ctx.was_compressed) + response = Response(wrapped, 200) + if ea_info: + response.headers[HEADER_ENCRYPTION] = ea_info + response.headers[HEADER_COMPRESSION] = ( + eaapi.Compression.Lz77 if ctx.was_compressed + else eaapi.Compression.None_ + ).value + + return response + + def _create_ctx( + self, + url_slash: bool, + request: Request, + model: Model | None, + module: str, + method: str + ) -> CallContext: + ctx = self._decode_request(request) + ctx._module = module + ctx._method = method + ctx._url_slash = url_slash + self._einfo_ctx = ctx + + if ctx.model != model: + raise exc.ModelMissmatch + return ctx + + def _handle_request(self, ctx: CallContext) -> Response: + for controller in self.controllers: + if (handler := controller.get_handler(ctx)) is not None: + self._einfo_controller = ( + f"{controller._name}" + ) + + handler(ctx) + break + else: + raise exc.NoMethodHandler + + return self._encode_response(ctx) + + def on_xrpc_other( + self, + request: Request, + service: str | None = None, + model: str | None = None, + module: str | None = None, + method: str | None = None + ): + if request.method != "GET" or not self.verbose_errors: + raise MethodNotAllowed + + return Response( + f"XRPC running. model {model}, call {module}.{method} ({service})" + ) + + def keepalive_request(self) -> Response: + return Response(None) + + def parse_request( + self, + request: Request, + service: str | None = None, + model: str | None = None, + module: str | None = None, + method: str | None = None + ): + url_slash = bool(module and module and method) + model_param = request.args.get("model", None) + module_param = request.args.get("module", None) + method_param = request.args.get("method", None) + if "f" in request.args: + module_param, _, method_param = request.args.get("f", "").partition(".") + + if self._prioritise_params: + model = model_param or model + module = module_param or module + method = method_param or method + else: + model = model or model_param + module = module or module_param + method = method or method_param + + if module is None or method is None: + raise exc.ModuleMethodMissing + + if model is None: + model_obj = None + else: + try: + model_obj = Model.from_model_str(model) + except eaapi.exception.InvalidModel: + raise exc.InvalidModel + + return url_slash, service, model_obj, module, method + + def on_xrpc_request( + self, + request: Request, + service: str | None = None, + model: str | None = None, + module: str | None = None, + method: str | None = None + ): + url_slash, service, model_obj, module, method = self.parse_request(request, service, model, module, method) + + if request.method != "POST": + return self.on_xrpc_other(request, service, model, module, method) + + ctx = self._create_ctx(url_slash, request, model_obj, module, method) + for i in self._pre_handlers_check: + i(ctx) + return self._handle_request(ctx) + + def _make_error(self, status: int | None = None, message: str | None = None) -> Response: + response = eaapi.XMLNode.void("response") + if status is not None: + response["status"] = str(status) + + if self.verbose_errors: + if message: + response.append("details", eaapi.Type.Str, message) + + context = response.append("context") + if self._einfo_ctx is not None: + context.append("module", eaapi.Type.Str, self._einfo_ctx.module) + context.append("method", eaapi.Type.Str, self._einfo_ctx.method) + context.append("game", eaapi.Type.Str, str(self._einfo_ctx.model)) + if self._einfo_controller is not None: + context.append("controller", eaapi.Type.Str, self._einfo_controller) + + encoded = eaapi.Encoder.encode(response, False) + wrapped = eaapi.wrap(encoded, None, False) + response = Response(wrapped, status or 500) + response.headers[HEADER_COMPRESSION] = eaapi.Compression.None_.value + + return response + + def _eamhttp_error(self, exc: exc.EAMHTTPException) -> Response: + return self._make_error(exc.code, exc.eam_description) + + def _structure_error(self, e: eaapi.exception.XMLStrutureError) -> Response: + summary = traceback.extract_tb(e.__traceback__) + for frame_summary in summary: + filename = frame_summary.filename + frame_summary.filename = os.path.relpath(filename) + + # The first three entries are within the controller, and the last one is us + summary = summary[3:-1] + tb = "".join(traceback.format_list(traceback.StackSummary.from_list(summary))) + tb += f"{e.__module__}.{e.__class__.__name__}" + + return self._make_error(400, tb) + + def _generic_error(self, exc: Exception) -> Response: + return self._make_error(500, str(exc)) + + def dispatch_request(self, request): + self._einfo_ctx = None + self._einfo_controller = None + + adapter = self.build_rules_map().bind_to_environ(request.environ) + try: + endpoint, values = adapter.match() + return getattr(self, f"on_{endpoint}")(request, **values) + except exc.EAMHTTPException as e: + return self._eamhttp_error(e) + except HTTPException as e: + return e + except eaapi.exception.XMLStrutureError as e: + traceback.print_exc(file=sys.stderr) + return self._structure_error(e) + except Exception as e: + traceback.print_exc(file=sys.stderr) + return self._generic_error(e) + + def wsgi_app(self, environ, start_response): + request = Request(environ) + response = self.dispatch_request(request) + return response(environ, start_response) + + def __call__(self, environ, start_response): + for i in self._setup: + i() + + try: + response = self.wsgi_app(environ, start_response) + for i in self._teardown: + i(None) + return response + except Exception as e: + for i in self._teardown: + i(e) + raise e + + def run(self, host="127.0.0.1", port=5000, debug=False): + from werkzeug.serving import run_simple + run_simple(host, port, self, use_debugger=debug, use_reloader=debug)