server.py 12 KB

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