From c9a4e5b00d818abde242f445d06caa9cadd83166 Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 11 Nov 2014 17:18:55 -0800 Subject: [PATCH 1/2] handle various permission failures - turn various EPERM, EACCES errors into 403 - add `writable` bool field to contents models - if a notebook is not `writable`, save is disabled --- IPython/html/services/contents/filemanager.py | 89 ++++++++++++++----- IPython/html/static/notebook/js/notebook.js | 28 ++++-- .../static/notebook/js/notificationarea.js | 7 +- IPython/html/static/notebook/js/savewidget.js | 5 ++ IPython/html/utils.py | 31 +++---- 5 files changed, 118 insertions(+), 42 deletions(-) diff --git a/IPython/html/services/contents/filemanager.py b/IPython/html/services/contents/filemanager.py index da9167239..e500f0db5 100644 --- a/IPython/html/services/contents/filemanager.py +++ b/IPython/html/services/contents/filemanager.py @@ -4,10 +4,11 @@ # Distributed under the terms of the Modified BSD License. import base64 +import errno import io import os -import glob import shutil +from contextlib import contextmanager from tornado import web @@ -16,9 +17,9 @@ from IPython import nbformat from IPython.utils.io import atomic_writing from IPython.utils.path import ensure_dir_exists from IPython.utils.traitlets import Unicode, Bool, TraitError -from IPython.utils.py3compat import getcwd +from IPython.utils.py3compat import getcwd, str_to_unicode from IPython.utils import tz -from IPython.html.utils import is_hidden, to_os_path, url_path_join +from IPython.html.utils import is_hidden, to_os_path, to_api_path class FileContentsManager(ContentsManager): @@ -30,7 +31,38 @@ class FileContentsManager(ContentsManager): return self.parent.notebook_dir except AttributeError: return getcwd() - + + @contextmanager + def perm_to_403(self, os_path=''): + """context manager for turning permission errors into 403""" + try: + yield + except OSError as e: + if e.errno in {errno.EPERM, errno.EACCES}: + # make 403 error message without root prefix + # this may not work perfectly on unicode paths on Python 2, + # but nobody should be doing that anyway. + if not os_path: + os_path = str_to_unicode(e.filename or 'unknown file') + path = to_api_path(os_path, self.root_dir) + raise web.HTTPError(403, u'Permission denied: %s' % path) + else: + raise + + @contextmanager + def open(self, os_path, *args, **kwargs): + """wrapper around io.open that turns permission errors into 403""" + with self.perm_to_403(os_path): + with io.open(os_path, *args, **kwargs) as f: + yield f + + @contextmanager + def open_w(self, os_path, *args, **kwargs): + """wrapper around atomic_writing that turns permission errors into 403""" + with self.perm_to_403(os_path): + with atomic_writing(os_path, *args, **kwargs) as f: + yield f + save_script = Bool(False, config=True, help='DEPRECATED, IGNORED') def _save_script_changed(self): self.log.warn(""" @@ -172,6 +204,11 @@ class FileContentsManager(ContentsManager): model['created'] = created model['content'] = None model['format'] = None + try: + model['writable'] = os.access(os_path, os.W_OK) + except OSError: + self.log.error("Failed to check write permissions on %s", os_path) + model['writable'] = False return model def _dir_model(self, path, content=True): @@ -181,7 +218,7 @@ class FileContentsManager(ContentsManager): """ os_path = self._get_os_path(path) - four_o_four = u'directory does not exist: %r' % os_path + four_o_four = u'directory does not exist: %r' % path if not os.path.isdir(os_path): raise web.HTTPError(404, four_o_four) @@ -232,7 +269,7 @@ class FileContentsManager(ContentsManager): if not os.path.isfile(os_path): # could be FIFO raise web.HTTPError(400, "Cannot get content of non-file %s" % os_path) - with io.open(os_path, 'rb') as f: + with self.open(os_path, 'rb') as f: bcontent = f.read() if format != 'base64': @@ -261,7 +298,7 @@ class FileContentsManager(ContentsManager): model['type'] = 'notebook' if content: os_path = self._get_os_path(path) - with io.open(os_path, 'r', encoding='utf-8') as f: + with self.open(os_path, 'r', encoding='utf-8') as f: try: nb = nbformat.read(f, as_version=4) except Exception as e: @@ -321,7 +358,7 @@ class FileContentsManager(ContentsManager): self.check_and_sign(nb, path) - with atomic_writing(os_path, encoding='utf-8') as f: + with self.open_w(os_path, encoding='utf-8') as f: nbformat.write(nb, f, version=nbformat.NO_CONVERT) def _save_file(self, os_path, model, path=''): @@ -338,7 +375,7 @@ class FileContentsManager(ContentsManager): bcontent = base64.decodestring(b64_bytes) except Exception as e: raise web.HTTPError(400, u'Encoding error saving %s: %s' % (os_path, e)) - with atomic_writing(os_path, text=False) as f: + with self.open_w(os_path, text=False) as f: f.write(bcontent) def _save_directory(self, os_path, model, path=''): @@ -346,7 +383,8 @@ class FileContentsManager(ContentsManager): if is_hidden(os_path, self.root_dir): raise web.HTTPError(400, u'Cannot create hidden directory %r' % os_path) if not os.path.exists(os_path): - os.mkdir(os_path) + with self.perm_to_403(): + os.mkdir(os_path) elif not os.path.isdir(os_path): raise web.HTTPError(400, u'Not a directory: %s' % (os_path)) else: @@ -379,7 +417,8 @@ class FileContentsManager(ContentsManager): except web.HTTPError: raise except Exception as e: - raise web.HTTPError(400, u'Unexpected error while saving file: %s %s' % (os_path, e)) + self.log.error(u'Error while saving file: %s %s', path, e, exc_info=True) + raise web.HTTPError(500, u'Unexpected error while saving file: %s %s' % (path, e)) validation_message = None if model['type'] == 'notebook': @@ -423,14 +462,17 @@ class FileContentsManager(ContentsManager): cp_path = self.get_checkpoint_path(checkpoint_id, path) if os.path.isfile(cp_path): self.log.debug("Unlinking checkpoint %s", cp_path) - os.unlink(cp_path) + with self.perm_to_403(): + rm(cp_path) if os.path.isdir(os_path): self.log.debug("Removing directory %s", os_path) - shutil.rmtree(os_path) + with self.perm_to_403(): + shutil.rmtree(os_path) else: self.log.debug("Unlinking file %s", os_path) - rm(os_path) + with self.perm_to_403(): + rm(os_path) def rename(self, old_path, new_path): """Rename a file.""" @@ -448,7 +490,10 @@ class FileContentsManager(ContentsManager): # Move the file try: - shutil.move(old_os_path, new_os_path) + with self.perm_to_403(): + shutil.move(old_os_path, new_os_path) + except web.HTTPError: + raise except Exception as e: raise web.HTTPError(500, u'Unknown error renaming file: %s %s' % (old_path, e)) @@ -460,7 +505,8 @@ class FileContentsManager(ContentsManager): new_cp_path = self.get_checkpoint_path(checkpoint_id, new_path) if os.path.isfile(old_cp_path): self.log.debug("Renaming checkpoint %s -> %s", old_cp_path, new_cp_path) - shutil.move(old_cp_path, new_cp_path) + with self.perm_to_403(): + shutil.move(old_cp_path, new_cp_path) # Checkpoint-related utilities @@ -477,7 +523,8 @@ class FileContentsManager(ContentsManager): ) os_path = self._get_os_path(path=parent) cp_dir = os.path.join(os_path, self.checkpoint_dir) - ensure_dir_exists(cp_dir) + with self.perm_to_403(): + ensure_dir_exists(cp_dir) cp_path = os.path.join(cp_dir, filename) return cp_path @@ -505,7 +552,8 @@ class FileContentsManager(ContentsManager): checkpoint_id = u"checkpoint" cp_path = self.get_checkpoint_path(checkpoint_id, path) self.log.debug("creating checkpoint for %s", path) - self._copy(src_path, cp_path) + with self.perm_to_403(): + self._copy(src_path, cp_path) # return the checkpoint info return self.get_checkpoint_model(checkpoint_id, path) @@ -537,10 +585,11 @@ class FileContentsManager(ContentsManager): ) # ensure notebook is readable (never restore from an unreadable notebook) if cp_path.endswith('.ipynb'): - with io.open(cp_path, 'r', encoding='utf-8') as f: + with self.open(cp_path, 'r', encoding='utf-8') as f: nbformat.read(f, as_version=4) - self._copy(cp_path, nb_path) self.log.debug("copying %s -> %s", cp_path, nb_path) + with self.perm_to_403(): + self._copy(cp_path, nb_path) def delete_checkpoint(self, checkpoint_id, path): """delete a file's checkpoint""" diff --git a/IPython/html/static/notebook/js/notebook.js b/IPython/html/static/notebook/js/notebook.js index 910a70d4e..b2f56b7f2 100644 --- a/IPython/html/static/notebook/js/notebook.js +++ b/IPython/html/static/notebook/js/notebook.js @@ -110,7 +110,7 @@ define([ CodeMirror.runMode(code, mode, el); callback(null, el.innerHTML); } catch (err) { - console.log("Failed to highlight " + lang + " code", error); + console.log("Failed to highlight " + lang + " code", err); callback(err, code); } }, function (err) { @@ -132,6 +132,7 @@ define([ this.undelete_index = null; this.undelete_below = false; this.paste_enabled = false; + this.writable = false; // It is important to start out in command mode to match the intial mode // of the KeyboardManager. this.mode = 'command'; @@ -1896,6 +1897,10 @@ define([ if (this.autosave_timer) { clearInterval(this.autosave_timer); } + if (!this.writable) { + // disable autosave if not writable + interval = 0; + } this.autosave_interval = this.minimum_autosave_interval = interval; if (interval) { @@ -1918,12 +1923,18 @@ define([ * @method save_notebook */ Notebook.prototype.save_notebook = function () { - if(!this._fully_loaded){ + if (!this._fully_loaded) { this.events.trigger('notebook_save_failed.Notebook', new Error("Load failed, save is disabled") ); return; + } else if (!this.writable) { + this.events.trigger('notebook_save_failed.Notebook', + new Error("Notebook is read-only") + ); + return; } + // Create a JSON model to be sent to the server. var model = { type : "notebook", @@ -2052,7 +2063,8 @@ define([ }); }; - Notebook.prototype.copy_notebook = function(){ + Notebook.prototype.copy_notebook = function () { + var that = this; var base_url = this.base_url; var w = window.open(); var parent = utils.url_path_split(this.notebook_path)[0]; @@ -2064,7 +2076,7 @@ define([ }, function(error) { w.close(); - console.log(error); + that.events.trigger('notebook_copy_failed', error); } ); }; @@ -2175,6 +2187,7 @@ define([ } this.set_dirty(false); this.scroll_to_top(); + this.writable = data.writable || false; var nbmodel = data.content; var orig_nbformat = nbmodel.metadata.orig_nbformat; var orig_nbformat_minor = nbmodel.metadata.orig_nbformat_minor; @@ -2249,7 +2262,12 @@ define([ } else { celltoolbar.CellToolbar.global_hide(); } - + + if (!this.writable) { + this.set_autosave_interval(0); + this.events.trigger('notebook_read_only.Notebook'); + } + // now that we're fully loaded, it is safe to restore save functionality this._fully_loaded = true; this.events.trigger('notebook_loaded.Notebook'); diff --git a/IPython/html/static/notebook/js/notificationarea.js b/IPython/html/static/notebook/js/notificationarea.js index ca71b139d..93ad8a054 100644 --- a/IPython/html/static/notebook/js/notificationarea.js +++ b/IPython/html/static/notebook/js/notificationarea.js @@ -340,8 +340,11 @@ define([ this.events.on('notebook_saved.Notebook', function () { nnw.set_message("Notebook saved",2000); }); - this.events.on('notebook_save_failed.Notebook', function (evt, xhr, status, data) { - nnw.warning(data || "Notebook save failed"); + this.events.on('notebook_save_failed.Notebook', function (evt, error) { + nnw.warning(error.message || "Notebook save failed"); + }); + this.events.on('notebook_copy_failed.Notebook', function (evt, error) { + nnw.warning(error.message || "Notebook copy failed"); }); // Checkpoint events diff --git a/IPython/html/static/notebook/js/savewidget.js b/IPython/html/static/notebook/js/savewidget.js index 2bcd2c568..669b1dc54 100644 --- a/IPython/html/static/notebook/js/savewidget.js +++ b/IPython/html/static/notebook/js/savewidget.js @@ -46,6 +46,11 @@ define([ this.events.on('notebook_save_failed.Notebook', function () { that.set_save_status('Autosave Failed!'); }); + this.events.on('notebook_read_only.Notebook', function () { + that.set_save_status('(read only)'); + // disable future set_save_status + that.set_save_status = function () {}; + }); this.events.on('checkpoints_listed.Notebook', function (event, data) { that._set_last_checkpoint(data[0]); }); diff --git a/IPython/html/utils.py b/IPython/html/utils.py index 68892c38c..d91d10b36 100644 --- a/IPython/html/utils.py +++ b/IPython/html/utils.py @@ -1,16 +1,7 @@ -"""Notebook related utilities +"""Notebook related utilities""" -Authors: - -* Brian Granger -""" - -#----------------------------------------------------------------------------- -# Copyright (C) 2011 The IPython Development Team -# -# Distributed under the terms of the BSD License. The full license is in -# the file COPYING, distributed as part of this software. -#----------------------------------------------------------------------------- +# Copyright (c) IPython Development Team. +# Distributed under the terms of the Modified BSD License. from __future__ import print_function @@ -28,9 +19,6 @@ from IPython.utils import py3compat # It is used by BSD to indicate hidden files. UF_HIDDEN = getattr(stat, 'UF_HIDDEN', 32768) -#----------------------------------------------------------------------------- -# Imports -#----------------------------------------------------------------------------- def url_path_join(*pieces): """Join components of url into a relative url @@ -137,4 +125,17 @@ def to_os_path(path, root=''): parts = [p for p in parts if p != ''] # remove duplicate splits path = os.path.join(root, *parts) return path + +def to_api_path(os_path, root=''): + """Convert a filesystem path to an API path + If given, root will be removed from the path. + root must be a filesystem path already. + """ + if os_path.startswith(root): + os_path = os_path[len(root):] + parts = os_path.strip(os.path.sep).split(os.path.sep) + parts = [p for p in parts if p != ''] # remove duplicate splits + path = '/'.join(parts) + return path + From 837ef7f122795f557157334d6665585bbd1cc140 Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 18 Nov 2014 13:02:00 -0800 Subject: [PATCH 2/2] s/open_w/atomic_writing/ --- IPython/html/services/contents/filemanager.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/IPython/html/services/contents/filemanager.py b/IPython/html/services/contents/filemanager.py index e500f0db5..92465e14a 100644 --- a/IPython/html/services/contents/filemanager.py +++ b/IPython/html/services/contents/filemanager.py @@ -57,7 +57,7 @@ class FileContentsManager(ContentsManager): yield f @contextmanager - def open_w(self, os_path, *args, **kwargs): + def atomic_writing(self, os_path, *args, **kwargs): """wrapper around atomic_writing that turns permission errors into 403""" with self.perm_to_403(os_path): with atomic_writing(os_path, *args, **kwargs) as f: @@ -358,7 +358,7 @@ class FileContentsManager(ContentsManager): self.check_and_sign(nb, path) - with self.open_w(os_path, encoding='utf-8') as f: + with self.atomic_writing(os_path, encoding='utf-8') as f: nbformat.write(nb, f, version=nbformat.NO_CONVERT) def _save_file(self, os_path, model, path=''): @@ -375,7 +375,7 @@ class FileContentsManager(ContentsManager): bcontent = base64.decodestring(b64_bytes) except Exception as e: raise web.HTTPError(400, u'Encoding error saving %s: %s' % (os_path, e)) - with self.open_w(os_path, text=False) as f: + with self.atomic_writing(os_path, text=False) as f: f.write(bcontent) def _save_directory(self, os_path, model, path=''):