web.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. from dataclasses import dataclass
  2. import flask
  3. import pystache
  4. from typing import TypeVar, Type
  5. import lc.config as c
  6. import lc.error as e
  7. import lc.model as m
  8. import lc.request as r
  9. T = TypeVar("T", bound=r.Request)
  10. @dataclass
  11. class ApiOK:
  12. response: dict
  13. class Endpoint:
  14. def __init__(self):
  15. self.user = None
  16. # try finding the token
  17. token = None
  18. # first check the HTTP headers
  19. if (auth := flask.request.headers.get("Authorization", None)) :
  20. token = auth.split()[1]
  21. # if that fails, check the session
  22. elif flask.session.get("auth", None):
  23. token = flask.session["auth"]
  24. # if that exists and we can deserialize it, then make sure
  25. # it contains a valid user password, too
  26. if token and (payload := c.serializer.loads(token)):
  27. if "name" not in payload or "password" not in payload:
  28. return
  29. try:
  30. u = m.User.by_slug(payload["name"])
  31. except e.LCException:
  32. return
  33. if u.authenticate(payload["password"]):
  34. self.user = u
  35. def api_ok(self, redirect: str, data: dict = {"status": "ok"}) -> ApiOK:
  36. if flask.request.content_type == "application/json":
  37. return ApiOK(response=data)
  38. elif flask.request.content_type == "application/x-www-form-urlencoded":
  39. raise e.LCRedirect(redirect)
  40. else:
  41. raise e.BadContentType(flask.request.content_type or "unknown")
  42. def request_data(self, cls: Type[T]) -> T:
  43. """Construct a Request model from either a JSON payload or a urlencoded payload"""
  44. if flask.request.content_type == "application/json":
  45. try:
  46. return cls.from_json(flask.request.data)
  47. except KeyError as exn:
  48. raise e.BadPayload(key=exn.args[0])
  49. elif flask.request.content_type == "application/x-www-form-urlencoded":
  50. return cls.from_form(flask.request.form)
  51. else:
  52. raise e.BadContentType(flask.request.content_type or "unknown")
  53. def require_authentication(self, name: str) -> m.User:
  54. """
  55. Check that the currently logged-in user exists and is the
  56. same as the user whose username is given. Raises an exception
  57. otherwise.
  58. """
  59. if not self.user or name != self.user.name:
  60. raise e.BadPermissions()
  61. return self.user
  62. def route(self, *args, **kwargs):
  63. """Forward to the appropriate routing method"""
  64. try:
  65. if flask.request.method == "POST":
  66. # all POST methods are "API methods": if we want to
  67. # display information in response to a post, then we
  68. # should redirect to the page where that information
  69. # can be viewed instead of returning that
  70. # information. (I think.)
  71. api_ok = self.api_post(*args, **kwargs)
  72. assert isinstance(api_ok, ApiOK)
  73. return flask.jsonify(api_ok.response)
  74. elif (
  75. flask.request.method in ["GET", "HEAD"]
  76. and flask.request.content_type == "application/json"
  77. ):
  78. # Here we're distinguishing between an API GET (i.e. a
  79. # client trying to get JSON data about an endpoint)
  80. # versus a user-level GET (i.e. a user in a browser.)
  81. # I like using the HTTP headers to distinguish these
  82. # cases, while other APIs tend to have a separate /api
  83. # endpoint to do this.
  84. return flask.jsonify(self.api_get(*args, **kwargs))
  85. # if an exception arose from an "API method", then we should
  86. # report it as JSON
  87. except e.LCException as exn:
  88. if flask.request.content_type == "application/json":
  89. return ({"status": exn.http_code(), "error": str(exn)}, exn.http_code())
  90. else:
  91. page = render(
  92. "main",
  93. title="error",
  94. content=f"shit's fucked yo: {exn}",
  95. user=None,
  96. )
  97. return (page, exn.http_code())
  98. # also maybe we tried to redirect, so just do that
  99. except e.LCRedirect as exn:
  100. return flask.redirect(exn.to_path())
  101. # if we're here, it means we're just trying to get a typical
  102. # HTML request.
  103. try:
  104. return self.html(*args, **kwargs)
  105. except e.LCException as exn:
  106. page = render(
  107. "main", title="error", content=f"shit's fucked yo: {exn}", user=None,
  108. )
  109. return (page, exn.http_code())
  110. except e.LCRedirect as exn:
  111. return flask.redirect(exn.to_path())
  112. # Decorators result in some weird code in Python, especially 'cause it
  113. # doesn't make higher-order functions terse. Let's break this down a
  114. # bit. This out method, `endpoint`, takes the route...
  115. def endpoint(route: str):
  116. """Route an endpoint using our semi-smart routing machinery"""
  117. # but `endpoint` returns another function which is going to be
  118. # called with the result of the definition after it. The argument
  119. # to what we're calling `do_endpoint` here is going to be the
  120. # class object defined afterwards.
  121. def do_endpoint(endpoint_class: Type[Endpoint]):
  122. # we'll just make that explicit here
  123. assert Endpoint in endpoint_class.__bases__
  124. # finally, we need a function that we'll give to Flask in
  125. # order to actually dispatch to. This is the actual routing
  126. # function, which is why it just creates an instance of the
  127. # endpoint provided above and calls the `route` method on it
  128. def func(*args, **kwargs):
  129. return endpoint_class().route(*args, **kwargs)
  130. # use reflection over the methods defined by the endpoint
  131. # class to decide if it needs to accept POST requests or not.
  132. methods = ["GET"]
  133. if "api_post" in dir(endpoint_class):
  134. methods.append("POST")
  135. # this is just for making error messages nicer
  136. func.__name__ = endpoint_class.__name__
  137. # finally, use the Flask routing machinery to register our callback
  138. return c.app.route(route, methods=methods)(func)
  139. return do_endpoint
  140. LOADER = pystache.loader.Loader(extension="mustache", search_dirs=["templates"])
  141. def render(name: str, **kwargs) -> str:
  142. """Load and use a Mustache template from the project root"""
  143. template = LOADER.load_name(name)
  144. renderer = pystache.Renderer(missing_tags="strict", search_dirs=["templates"])
  145. return renderer.render(template, kwargs)