server.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. # aiohttp.py -- aiohttp smart client/server
  2. # Copyright (C) 2022 Jelmer Vernooij <jelmer@jelmer.uk>
  3. #
  4. # SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
  5. # Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
  6. # General Public License as published by the Free Software Foundation; version 2.0
  7. # or (at your option) any later version. You can redistribute it and/or
  8. # modify it under the terms of either of these two licenses.
  9. #
  10. # Unless required by applicable law or agreed to in writing, software
  11. # distributed under the License is distributed on an "AS IS" BASIS,
  12. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. # See the License for the specific language governing permissions and
  14. # limitations under the License.
  15. #
  16. # You should have received a copy of the licenses; if not, see
  17. # <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
  18. # and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
  19. # License, Version 2.0.
  20. #
  21. """aiohttp client/server support."""
  22. import asyncio
  23. import sys
  24. from io import BytesIO
  25. from typing import BinaryIO, cast
  26. from aiohttp import web
  27. from .. import log_utils
  28. from ..errors import HangupException
  29. from ..protocol import ReceivableProtocol
  30. from ..repo import Repo
  31. from ..server import (
  32. DEFAULT_HANDLERS,
  33. BackendRepo,
  34. DictBackend,
  35. generate_info_refs,
  36. generate_objects_info_packs,
  37. )
  38. from ..web import NO_CACHE_HEADERS, cache_forever_headers
  39. logger = log_utils.getLogger(__name__)
  40. # Application keys for type-safe access to app state
  41. REPO_KEY = web.AppKey("repo", Repo)
  42. HANDLERS_KEY = web.AppKey("handlers", dict)
  43. DUMB_KEY = web.AppKey("dumb", bool)
  44. async def send_file(
  45. req: web.Request, f: BinaryIO | None, headers: dict[str, str]
  46. ) -> web.StreamResponse:
  47. """Send a file-like object to the request output.
  48. Args:
  49. req: The HTTPGitRequest object to send output to.
  50. f: An open file-like object to send; will be closed.
  51. headers: Headers to send
  52. Returns: Iterator over the contents of the file, as chunks.
  53. """
  54. if f is None:
  55. raise web.HTTPNotFound(text="File not found")
  56. response = web.StreamResponse(status=200, reason="OK", headers=headers)
  57. await response.prepare(req)
  58. try:
  59. while True:
  60. data = f.read(10240)
  61. if not data:
  62. break
  63. await response.write(data)
  64. except OSError:
  65. raise web.HTTPInternalServerError(text="Error reading file")
  66. finally:
  67. f.close()
  68. await response.write_eof()
  69. return response
  70. async def get_loose_object(request: web.Request) -> web.Response:
  71. """Handle request for a loose object.
  72. Args:
  73. request: aiohttp request object
  74. Returns: Response with the loose object data
  75. """
  76. sha = (request.match_info["dir"] + request.match_info["file"]).encode("ascii")
  77. logger.info("Sending loose object %s", sha)
  78. object_store = request.app[REPO_KEY].object_store
  79. if not object_store.contains_loose(sha):
  80. raise web.HTTPNotFound(text="Object not found")
  81. try:
  82. data = object_store[sha].as_legacy_object()
  83. except OSError:
  84. raise web.HTTPInternalServerError(text="Error reading object")
  85. headers = {"Content-Type": "application/x-git-loose-object"}
  86. headers.update(cache_forever_headers())
  87. return web.Response(status=200, headers=headers, body=data)
  88. async def get_text_file(request: web.Request) -> web.StreamResponse:
  89. """Handle request for a text file.
  90. Args:
  91. request: aiohttp request object
  92. Returns: Response with the text file contents
  93. """
  94. headers = {"Content-Type": "text/plain"}
  95. headers.update(NO_CACHE_HEADERS)
  96. path = request.match_info["file"]
  97. logger.info("Sending plain text file %s", path)
  98. repo = request.app[REPO_KEY]
  99. return await send_file(request, repo.get_named_file(path), headers)
  100. async def refs_request(
  101. repo: Repo, request: web.Request, handlers: dict[bytes, type] | None = None
  102. ) -> web.StreamResponse | web.Response:
  103. """Handle a refs request.
  104. Args:
  105. repo: Repository object
  106. request: aiohttp request object
  107. handlers: Optional dict of service handlers
  108. Returns: Response with refs information
  109. """
  110. service = request.query.get("service")
  111. if service:
  112. if handlers is None:
  113. handlers = dict(DEFAULT_HANDLERS)
  114. handler_cls = handlers.get(service.encode("ascii"), None)
  115. if handler_cls is None:
  116. raise web.HTTPForbidden(text="Unsupported service")
  117. headers = {"Content-Type": f"application/x-{service}-advertisement"}
  118. headers.update(NO_CACHE_HEADERS)
  119. response = web.StreamResponse(status=200, headers=headers)
  120. await response.prepare(request)
  121. out = BytesIO()
  122. proto = ReceivableProtocol(BytesIO().read, out.write)
  123. handler = handler_cls(
  124. DictBackend({b".": cast(BackendRepo, repo)}),
  125. [b"."],
  126. proto,
  127. stateless_rpc=True,
  128. advertise_refs=True,
  129. )
  130. handler.proto.write_pkt_line(b"# service=" + service.encode("ascii") + b"\n")
  131. handler.proto.write_pkt_line(None)
  132. # TODO(jelmer): Implement this with proper async code
  133. await asyncio.to_thread(handler.handle)
  134. await response.write(out.getvalue())
  135. await response.write_eof()
  136. return response
  137. else:
  138. # non-smart fallback
  139. headers = {"Content-Type": "text/plain"}
  140. headers.update(NO_CACHE_HEADERS)
  141. logger.info("Emulating dumb info/refs")
  142. return web.Response(body=b"".join(generate_info_refs(repo)), headers=headers)
  143. async def get_info_refs(request: web.Request) -> web.StreamResponse | web.Response:
  144. """Handle request for /info/refs.
  145. Args:
  146. request: aiohttp request object
  147. Returns: Response with refs information
  148. """
  149. repo = request.app[REPO_KEY]
  150. return await refs_request(repo, request, request.app[HANDLERS_KEY])
  151. async def get_info_packs(request: web.Request) -> web.Response:
  152. """Handle request for /info/packs.
  153. Args:
  154. request: aiohttp request object
  155. Returns: Response with pack information
  156. """
  157. headers = {"Content-Type": "text/plain"}
  158. headers.update(NO_CACHE_HEADERS)
  159. logger.info("Emulating dumb info/packs")
  160. return web.Response(
  161. body=b"".join(generate_objects_info_packs(request.app[REPO_KEY])),
  162. headers=headers,
  163. )
  164. async def get_pack_file(request: web.Request) -> web.StreamResponse:
  165. """Handle request for a pack file.
  166. Args:
  167. request: aiohttp request object
  168. Returns: Response with the pack file data
  169. """
  170. headers = {"Content-Type": "application/x-git-packed-objects"}
  171. headers.update(cache_forever_headers())
  172. sha = request.match_info["sha"]
  173. path = f"objects/pack/pack-{sha}.pack"
  174. logger.info("Sending pack file %s", path)
  175. return await send_file(
  176. request,
  177. request.app[REPO_KEY].get_named_file(path),
  178. headers=headers,
  179. )
  180. async def get_index_file(request: web.Request) -> web.StreamResponse:
  181. """Handle request for a pack index file.
  182. Args:
  183. request: aiohttp request object
  184. Returns: Response with the pack index file data
  185. """
  186. headers = {"Content-Type": "application/x-git-packed-objects-toc"}
  187. headers.update(cache_forever_headers())
  188. sha = request.match_info["sha"]
  189. path = f"objects/pack/pack-{sha}.idx"
  190. logger.info("Sending pack file %s", path)
  191. return await send_file(
  192. request, request.app["repo"].get_named_file(path), headers=headers
  193. )
  194. async def service_request(
  195. repo: Repo, request: web.Request, handlers: dict[bytes, type] | None = None
  196. ) -> web.StreamResponse:
  197. """Handle a git service request (upload-pack or receive-pack).
  198. Args:
  199. repo: Repository object
  200. request: aiohttp request object
  201. handlers: Optional dict of service handlers
  202. Returns: Response with service result
  203. """
  204. service = request.match_info["service"]
  205. if handlers is None:
  206. handlers = dict(DEFAULT_HANDLERS)
  207. logger.info("Handling service request for %s", service)
  208. handler_cls = handlers.get(service.encode("ascii"), None)
  209. if handler_cls is None:
  210. raise web.HTTPForbidden(text="Unsupported service")
  211. headers = {"Content-Type": f"application/x-{service}-result"}
  212. headers.update(NO_CACHE_HEADERS)
  213. response = web.StreamResponse(status=200, headers=headers)
  214. await response.prepare(request)
  215. inf = BytesIO(await request.read())
  216. outf = BytesIO()
  217. def handle() -> None:
  218. proto = ReceivableProtocol(inf.read, outf.write)
  219. handler = handler_cls(
  220. DictBackend({b".": cast(BackendRepo, repo)}),
  221. [b"."],
  222. proto,
  223. stateless_rpc=True,
  224. )
  225. try:
  226. handler.handle()
  227. except HangupException:
  228. response.force_close()
  229. # TODO(jelmer): Implement this with proper async code
  230. await asyncio.to_thread(handle)
  231. await response.write(outf.getvalue())
  232. await response.write_eof()
  233. return response
  234. async def handle_service_request(request: web.Request) -> web.StreamResponse:
  235. """Handle a service request endpoint.
  236. Args:
  237. request: aiohttp request object
  238. Returns: Response with service result
  239. """
  240. repo = request.app[REPO_KEY]
  241. return await service_request(repo, request, request.app[HANDLERS_KEY])
  242. def create_repo_app(
  243. repo: Repo, handlers: dict[bytes, type] | None = None, dumb: bool = False
  244. ) -> web.Application:
  245. """Create an aiohttp application for serving a git repository.
  246. Args:
  247. repo: Repository object to serve
  248. handlers: Optional dict of service handlers
  249. dumb: Whether to enable dumb HTTP protocol support
  250. Returns: Configured aiohttp Application
  251. """
  252. app = web.Application()
  253. app[REPO_KEY] = repo
  254. if handlers is None:
  255. handlers = dict(DEFAULT_HANDLERS)
  256. app[HANDLERS_KEY] = handlers
  257. app[DUMB_KEY] = dumb
  258. app.router.add_get("/info/refs", get_info_refs)
  259. app.router.add_post(
  260. "/{service:git-upload-pack|git-receive-pack}", handle_service_request
  261. )
  262. if dumb:
  263. app.router.add_get("/{file:HEAD}", get_text_file)
  264. app.router.add_get("/{file:objects/info/alternates}", get_text_file)
  265. app.router.add_get("/{file:objects/info/http-alternates}", get_text_file)
  266. app.router.add_get("/objects/info/packs", get_info_packs)
  267. app.router.add_get(
  268. "/objects/{dir:[0-9a-f]{2}}/{file:[0-9a-f]{38}}", get_loose_object
  269. )
  270. app.router.add_get(
  271. "/objects/pack/pack-{sha:[0-9a-f]{40}}\\.pack", get_pack_file
  272. )
  273. app.router.add_get(
  274. "/objects/pack/pack-{sha:[0-9a-f]{40}}\\.idx", get_index_file
  275. )
  276. return app
  277. def main(argv: list[str] | None = None) -> None:
  278. """Entry point for starting an HTTP git server."""
  279. import argparse
  280. parser = argparse.ArgumentParser()
  281. parser.add_argument(
  282. "-l",
  283. "--listen_address",
  284. dest="listen_address",
  285. default="localhost",
  286. help="Binding IP address.",
  287. )
  288. parser.add_argument(
  289. "-p",
  290. "--port",
  291. dest="port",
  292. type=int,
  293. default=8000,
  294. help="Port to listen on.",
  295. )
  296. parser.add_argument("gitdir", type=str, default=".", nargs="?")
  297. args = parser.parse_args(argv)
  298. log_utils.default_logging_config()
  299. app = create_repo_app(Repo(args.gitdir))
  300. logger.info(
  301. "Listening for HTTP connections on %s:%d",
  302. args.listen_address,
  303. args.port,
  304. )
  305. web.run_app(app, port=args.port, host=args.listen_address)
  306. if __name__ == "__main__":
  307. main(sys.argv[1:])