handlers.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444
  1. """Tornado handlers for the contents web service.
  2. Preliminary documentation at https://github.com/ipython/ipython/wiki/IPEP-27%3A-Contents-Service
  3. """
  4. # Copyright (c) Jupyter Development Team.
  5. # Distributed under the terms of the Modified BSD License.
  6. import json
  7. from http import HTTPStatus
  8. from typing import Any
  9. try:
  10. from jupyter_client.jsonutil import json_default
  11. except ImportError:
  12. from jupyter_client.jsonutil import date_default as json_default
  13. from jupyter_core.utils import ensure_async
  14. from tornado import web
  15. from jupyter_server.auth.decorator import allow_unauthenticated, authorized
  16. from jupyter_server.base.handlers import APIHandler, JupyterHandler, path_regex
  17. from jupyter_server.utils import url_escape, url_path_join
  18. AUTH_RESOURCE = "contents"
  19. def _validate_keys(expect_defined: bool, model: dict[str, Any], keys: list[str]):
  20. """
  21. Validate that the keys are defined (i.e. not None) or not (i.e. None)
  22. """
  23. if expect_defined:
  24. errors = [key for key in keys if model[key] is None]
  25. if errors:
  26. raise web.HTTPError(
  27. 500,
  28. f"Keys unexpectedly None: {errors}",
  29. )
  30. else:
  31. errors = {key: model[key] for key in keys if model[key] is not None} # type: ignore[assignment]
  32. if errors:
  33. raise web.HTTPError(
  34. 500,
  35. f"Keys unexpectedly not None: {errors}",
  36. )
  37. def validate_model(model, expect_content=False, expect_hash=False):
  38. """
  39. Validate a model returned by a ContentsManager method.
  40. If expect_content is True, then we expect non-null entries for 'content'
  41. and 'format'.
  42. If expect_hash is True, then we expect non-null entries for 'hash' and 'hash_algorithm'.
  43. """
  44. required_keys = {
  45. "name",
  46. "path",
  47. "type",
  48. "writable",
  49. "created",
  50. "last_modified",
  51. "mimetype",
  52. "content",
  53. "format",
  54. }
  55. if expect_hash:
  56. required_keys.update(["hash", "hash_algorithm"])
  57. missing = required_keys - set(model.keys())
  58. if missing:
  59. raise web.HTTPError(
  60. 500,
  61. f"Missing Model Keys: {missing}",
  62. )
  63. content_keys = ["content", "format"]
  64. _validate_keys(expect_content, model, content_keys)
  65. if expect_hash:
  66. _validate_keys(expect_hash, model, ["hash", "hash_algorithm"])
  67. class ContentsAPIHandler(APIHandler):
  68. """A contents API handler."""
  69. auth_resource = AUTH_RESOURCE
  70. class ContentsHandler(ContentsAPIHandler):
  71. """A contents handler."""
  72. def location_url(self, path):
  73. """Return the full URL location of a file.
  74. Parameters
  75. ----------
  76. path : unicode
  77. The API path of the file, such as "foo/bar.txt".
  78. """
  79. return url_path_join(self.base_url, "api", "contents", url_escape(path))
  80. def _finish_model(self, model, location=True):
  81. """Finish a JSON request with a model, setting relevant headers, etc."""
  82. if location:
  83. location = self.location_url(model["path"])
  84. self.set_header("Location", location)
  85. self.set_header("Last-Modified", model["last_modified"])
  86. self.set_header("Content-Type", "application/json")
  87. self.finish(json.dumps(model, default=json_default))
  88. async def _finish_error(self, code, message):
  89. """Finish a JSON request with an error code and descriptive message"""
  90. self.set_status(code)
  91. self.write(message)
  92. await self.finish()
  93. @web.authenticated
  94. @authorized
  95. async def get(self, path=""):
  96. """Return a model for a file or directory.
  97. A directory model contains a list of models (without content)
  98. of the files and directories it contains.
  99. """
  100. path = path or ""
  101. cm = self.contents_manager
  102. type = self.get_query_argument("type", default=None)
  103. if type not in {None, "directory", "file", "notebook"}:
  104. # fall back to file if unknown type
  105. type = "file"
  106. format = self.get_query_argument("format", default=None)
  107. if format not in {None, "text", "base64"}:
  108. raise web.HTTPError(400, "Format %r is invalid" % format)
  109. content_str = self.get_query_argument("content", default="1")
  110. if content_str not in {"0", "1"}:
  111. raise web.HTTPError(400, "Content %r is invalid" % content_str)
  112. content = int(content_str or "")
  113. hash_str = self.get_query_argument("hash", default="0")
  114. if hash_str not in {"0", "1"}:
  115. raise web.HTTPError(
  116. 400, f"Hash argument {hash_str!r} is invalid. It must be '0' or '1'."
  117. )
  118. require_hash = int(hash_str)
  119. if not cm.allow_hidden and await ensure_async(cm.is_hidden(path)):
  120. await self._finish_error(
  121. HTTPStatus.NOT_FOUND, f"file or directory {path!r} does not exist"
  122. )
  123. try:
  124. expect_hash = require_hash
  125. try:
  126. model = await ensure_async(
  127. self.contents_manager.get(
  128. path=path,
  129. type=type,
  130. format=format,
  131. content=content,
  132. require_hash=require_hash,
  133. )
  134. )
  135. except TypeError:
  136. # Fallback for ContentsManager not handling the require_hash argument
  137. # introduced in 2.11
  138. expect_hash = False
  139. model = await ensure_async(
  140. self.contents_manager.get(
  141. path=path,
  142. type=type,
  143. format=format,
  144. content=content,
  145. )
  146. )
  147. validate_model(model, expect_content=content, expect_hash=expect_hash)
  148. self._finish_model(model, location=False)
  149. except web.HTTPError as exc:
  150. # 404 is okay in this context, catch exception and return 404 code to prevent stack trace on client
  151. if exc.status_code == HTTPStatus.NOT_FOUND:
  152. await self._finish_error(
  153. HTTPStatus.NOT_FOUND, f"file or directory {path!r} does not exist"
  154. )
  155. raise
  156. @web.authenticated
  157. @authorized
  158. async def patch(self, path=""):
  159. """PATCH renames a file or directory without re-uploading content."""
  160. cm = self.contents_manager
  161. model = self.get_json_body()
  162. if model is None:
  163. raise web.HTTPError(400, "JSON body missing")
  164. old_path = model.get("path")
  165. if (
  166. old_path
  167. and not cm.allow_hidden
  168. and (
  169. await ensure_async(cm.is_hidden(path)) or await ensure_async(cm.is_hidden(old_path))
  170. )
  171. ):
  172. raise web.HTTPError(400, f"Cannot rename file or directory {path!r}")
  173. model = await ensure_async(cm.update(model, path))
  174. validate_model(model)
  175. self._finish_model(model)
  176. async def _copy(self, copy_from, copy_to=None):
  177. """Copy a file, optionally specifying a target directory."""
  178. self.log.info(
  179. "Copying %r to %r",
  180. copy_from,
  181. copy_to or "",
  182. )
  183. model = await ensure_async(self.contents_manager.copy(copy_from, copy_to))
  184. self.set_status(201)
  185. validate_model(model)
  186. self._finish_model(model)
  187. async def _upload(self, model, path):
  188. """Handle upload of a new file to path"""
  189. self.log.info("Uploading file to %s", path)
  190. model = await ensure_async(self.contents_manager.new(model, path))
  191. self.set_status(201)
  192. validate_model(model)
  193. self._finish_model(model)
  194. async def _new_untitled(self, path, type="", ext=""):
  195. """Create a new, empty untitled entity"""
  196. self.log.info("Creating new %s in %s", type or "file", path)
  197. model = await ensure_async(
  198. self.contents_manager.new_untitled(path=path, type=type, ext=ext)
  199. )
  200. self.set_status(201)
  201. validate_model(model)
  202. self._finish_model(model)
  203. async def _save(self, model, path):
  204. """Save an existing file."""
  205. chunk = model.get("chunk", None)
  206. if not chunk or chunk == -1: # Avoid tedious log information
  207. self.log.info("Saving file at %s", path)
  208. model = await ensure_async(self.contents_manager.save(model, path))
  209. validate_model(model)
  210. self._finish_model(model)
  211. @web.authenticated
  212. @authorized
  213. async def post(self, path=""):
  214. """Create a new file in the specified path.
  215. POST creates new files. The server always decides on the name.
  216. POST /api/contents/path
  217. New untitled, empty file or directory.
  218. POST /api/contents/path
  219. with body {"copy_from" : "/path/to/OtherNotebook.ipynb"}
  220. New copy of OtherNotebook in path
  221. """
  222. cm = self.contents_manager
  223. file_exists = await ensure_async(cm.file_exists(path))
  224. if file_exists:
  225. raise web.HTTPError(400, "Cannot POST to files, use PUT instead.")
  226. model = self.get_json_body()
  227. if model:
  228. copy_from = model.get("copy_from")
  229. if copy_from:
  230. if not cm.allow_hidden and (
  231. await ensure_async(cm.is_hidden(path))
  232. or await ensure_async(cm.is_hidden(copy_from))
  233. ):
  234. raise web.HTTPError(400, f"Cannot copy file or directory {path!r}")
  235. else:
  236. await self._copy(copy_from, path)
  237. else:
  238. ext = model.get("ext", "")
  239. type = model.get("type", "")
  240. if type not in {None, "", "directory", "file", "notebook"}:
  241. # fall back to file if unknown type
  242. type = "file"
  243. await self._new_untitled(path, type=type, ext=ext)
  244. else:
  245. await self._new_untitled(path)
  246. @web.authenticated
  247. @authorized
  248. async def put(self, path=""):
  249. """Saves the file in the location specified by name and path.
  250. PUT is very similar to POST, but the requester specifies the name,
  251. whereas with POST, the server picks the name.
  252. PUT /api/contents/path/Name.ipynb
  253. Save notebook at ``path/Name.ipynb``. Notebook structure is specified
  254. in `content` key of JSON request body. If content is not specified,
  255. create a new empty notebook.
  256. """
  257. model = self.get_json_body()
  258. cm = self.contents_manager
  259. if model:
  260. if model.get("copy_from"):
  261. raise web.HTTPError(400, "Cannot copy with PUT, only POST")
  262. if not cm.allow_hidden and (
  263. (model.get("path") and await ensure_async(cm.is_hidden(model.get("path"))))
  264. or await ensure_async(cm.is_hidden(path))
  265. ):
  266. raise web.HTTPError(400, f"Cannot create file or directory {path!r}")
  267. exists = await ensure_async(self.contents_manager.file_exists(path))
  268. if model.get("type", "") not in {None, "", "directory", "file", "notebook"}:
  269. # fall back to file if unknown type
  270. model["type"] = "file"
  271. if exists:
  272. await self._save(model, path)
  273. else:
  274. await self._upload(model, path)
  275. else:
  276. await self._new_untitled(path)
  277. @web.authenticated
  278. @authorized
  279. async def delete(self, path=""):
  280. """delete a file in the given path"""
  281. cm = self.contents_manager
  282. if not cm.allow_hidden and await ensure_async(cm.is_hidden(path)):
  283. raise web.HTTPError(400, f"Cannot delete file or directory {path!r}")
  284. self.log.warning("delete %s", path)
  285. await ensure_async(cm.delete(path))
  286. self.set_status(204)
  287. self.finish()
  288. class CheckpointsHandler(ContentsAPIHandler):
  289. """A checkpoints API handler."""
  290. @web.authenticated
  291. @authorized
  292. async def get(self, path=""):
  293. """get lists checkpoints for a file"""
  294. cm = self.contents_manager
  295. checkpoints = await ensure_async(cm.list_checkpoints(path))
  296. data = json.dumps(checkpoints, default=json_default)
  297. self.finish(data)
  298. @web.authenticated
  299. @authorized
  300. async def post(self, path=""):
  301. """post creates a new checkpoint"""
  302. cm = self.contents_manager
  303. checkpoint = await ensure_async(cm.create_checkpoint(path))
  304. data = json.dumps(checkpoint, default=json_default)
  305. location = url_path_join(
  306. self.base_url,
  307. "api/contents",
  308. url_escape(path),
  309. "checkpoints",
  310. url_escape(checkpoint["id"]),
  311. )
  312. self.set_header("Location", location)
  313. self.set_status(201)
  314. self.finish(data)
  315. class ModifyCheckpointsHandler(ContentsAPIHandler):
  316. """A checkpoints modification handler."""
  317. @web.authenticated
  318. @authorized
  319. async def post(self, path, checkpoint_id):
  320. """post restores a file from a checkpoint"""
  321. cm = self.contents_manager
  322. await ensure_async(cm.restore_checkpoint(checkpoint_id, path))
  323. self.set_status(204)
  324. self.finish()
  325. @web.authenticated
  326. @authorized
  327. async def delete(self, path, checkpoint_id):
  328. """delete clears a checkpoint for a given file"""
  329. cm = self.contents_manager
  330. await ensure_async(cm.delete_checkpoint(checkpoint_id, path))
  331. self.set_status(204)
  332. self.finish()
  333. class NotebooksRedirectHandler(JupyterHandler):
  334. """Redirect /api/notebooks to /api/contents"""
  335. SUPPORTED_METHODS = (
  336. "GET",
  337. "PUT",
  338. "PATCH",
  339. "POST",
  340. "DELETE",
  341. )
  342. @allow_unauthenticated
  343. def get(self, path):
  344. """Handle a notebooks redirect."""
  345. self.log.warning("/api/notebooks is deprecated, use /api/contents")
  346. self.redirect(url_path_join(self.base_url, "api/contents", url_escape(path)))
  347. put = patch = post = delete = get
  348. class TrustNotebooksHandler(JupyterHandler):
  349. """Handles trust/signing of notebooks"""
  350. @web.authenticated # type:ignore[misc]
  351. @authorized(resource=AUTH_RESOURCE)
  352. async def post(self, path=""):
  353. """Trust a notebook by path."""
  354. cm = self.contents_manager
  355. await ensure_async(cm.trust_notebook(path))
  356. self.set_status(201)
  357. self.finish()
  358. # -----------------------------------------------------------------------------
  359. # URL to handler mappings
  360. # -----------------------------------------------------------------------------
  361. _checkpoint_id_regex = r"(?P<checkpoint_id>[\w-]+)"
  362. default_handlers = [
  363. (r"/api/contents%s/checkpoints" % path_regex, CheckpointsHandler),
  364. (
  365. rf"/api/contents{path_regex}/checkpoints/{_checkpoint_id_regex}",
  366. ModifyCheckpointsHandler,
  367. ),
  368. (r"/api/contents%s/trust" % path_regex, TrustNotebooksHandler),
  369. (r"/api/contents%s" % path_regex, ContentsHandler),
  370. (r"/api/notebooks/?(.*)", NotebooksRedirectHandler),
  371. ]