From 80e60eb2f0f417959c36f66be10ce8d9977cc984 2011-10-28 23:02:03
From: Fernando Perez <fperez.net@gmail.com>
Date: 2011-10-28 23:02:03
Subject: [PATCH] Merge pull request #931 from minrk/readonly

The notebook now supports a `--read-only` flag, which allows users to view all notebooks being served but not to edit them or execute any code.  These actions are not allowed and the buttons, shortcuts, etc. are removed, but the requests will raise authentication errors if they manage to send the events anyway.  Save/print functions remain available.

This flag can be used in two modes:

1. When running an unauthenticated server, one can run a *second* read-only server in the same directory on a public IP address.  This will let users connect to the read-only view without having to worry about configuring passwords and certificates for the execution server.

2. When running a server configured with authentication (and hopefully an SSL certificate), starting it with `--read-only` allows unauthenticated users read-only access to notebooks. This means that the same server on a single port can be both used by authenticated users for execution and by the public for viewing the available notebooks.


---

diff --git a/IPython/frontend/html/notebook/handlers.py b/IPython/frontend/html/notebook/handlers.py
index 1ce17b5..8e543e3 100644
--- a/IPython/frontend/html/notebook/handlers.py
+++ b/IPython/frontend/html/notebook/handlers.py
@@ -26,6 +26,7 @@ from tornado import websocket
 from zmq.eventloop import ioloop
 from zmq.utils import jsonapi
 
+from IPython.external.decorator import decorator
 from IPython.zmq.session import Session
 
 try:
@@ -34,6 +35,32 @@ except ImportError:
     publish_string = None
 
 
+#-----------------------------------------------------------------------------
+# Decorator for disabling read-only handlers
+#-----------------------------------------------------------------------------
+
+@decorator
+def not_if_readonly(f, self, *args, **kwargs):
+    if self.application.read_only:
+        raise web.HTTPError(403, "Notebook server is read-only")
+    else:
+        return f(self, *args, **kwargs)
+
+@decorator
+def authenticate_unless_readonly(f, self, *args, **kwargs):
+    """authenticate this page *unless* readonly view is active.
+    
+    In read-only mode, the notebook list and print view should
+    be accessible without authentication.
+    """
+    
+    @web.authenticated
+    def auth_f(self, *args, **kwargs):
+        return f(self, *args, **kwargs)
+    if self.application.read_only:
+        return f(self, *args, **kwargs)
+    else:
+        return auth_f(self, *args, **kwargs)
 
 #-----------------------------------------------------------------------------
 # Top-level handlers
@@ -50,34 +77,48 @@ class AuthenticatedHandler(web.RequestHandler):
         if user_id is None:
             # prevent extra Invalid cookie sig warnings:
             self.clear_cookie('username')
-            if not self.application.password:
+            if not self.application.password and not self.application.read_only:
                 user_id = 'anonymous'
         return user_id
+    
+    @property
+    def read_only(self):
+        if self.application.read_only:
+            if self.application.password:
+                return self.get_current_user() is None
+            else:
+                return True
+        else:
+            return False
+        
 
 
 class ProjectDashboardHandler(AuthenticatedHandler):
 
-    @web.authenticated
+    @authenticate_unless_readonly
     def get(self):
         nbm = self.application.notebook_manager
         project = nbm.notebook_dir
         self.render(
             'projectdashboard.html', project=project,
-            base_project_url=u'/', base_kernel_url=u'/'
+            base_project_url=u'/', base_kernel_url=u'/',
+            read_only=self.read_only,
         )
 
 
 class LoginHandler(AuthenticatedHandler):
 
     def get(self):
-        self.render('login.html', next='/')
+        self.render('login.html',
+                next=self.get_argument('next', default='/'),
+                read_only=self.read_only,
+        )
 
     def post(self):
         pwd = self.get_argument('password', default=u'')
         if self.application.password and pwd == self.application.password:
             self.set_secure_cookie('username', str(uuid.uuid4()))
-        url = self.get_argument('next', default='/')
-        self.redirect(url)
+        self.redirect(self.get_argument('next', default='/'))
 
 
 class NewHandler(AuthenticatedHandler):
@@ -91,23 +132,26 @@ class NewHandler(AuthenticatedHandler):
             'notebook.html', project=project,
             notebook_id=notebook_id,
             base_project_url=u'/', base_kernel_url=u'/',
-            kill_kernel=False
+            kill_kernel=False,
+            read_only=False,
         )
 
 
 class NamedNotebookHandler(AuthenticatedHandler):
 
-    @web.authenticated
+    @authenticate_unless_readonly
     def get(self, notebook_id):
         nbm = self.application.notebook_manager
         project = nbm.notebook_dir
         if not nbm.notebook_exists(notebook_id):
             raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_id)
+        
         self.render(
             'notebook.html', project=project,
             notebook_id=notebook_id,
             base_project_url=u'/', base_kernel_url=u'/',
-            kill_kernel=False
+            kill_kernel=False,
+            read_only=self.read_only,
         )
 
 
@@ -363,8 +407,9 @@ class ShellHandler(AuthenticatedZMQStreamHandler):
 
 class NotebookRootHandler(AuthenticatedHandler):
 
-    @web.authenticated
+    @authenticate_unless_readonly
     def get(self):
+        
         nbm = self.application.notebook_manager
         files = nbm.list_notebooks()
         self.finish(jsonapi.dumps(files))
@@ -387,11 +432,12 @@ class NotebookHandler(AuthenticatedHandler):
 
     SUPPORTED_METHODS = ('GET', 'PUT', 'DELETE')
 
-    @web.authenticated
+    @authenticate_unless_readonly
     def get(self, notebook_id):
         nbm = self.application.notebook_manager
         format = self.get_argument('format', default='json')
         last_mod, name, data = nbm.get_notebook(notebook_id, format)
+        
         if format == u'json':
             self.set_header('Content-Type', 'application/json')
             self.set_header('Content-Disposition','attachment; filename="%s.ipynb"' % name)
diff --git a/IPython/frontend/html/notebook/notebookapp.py b/IPython/frontend/html/notebook/notebookapp.py
index ae9fb18..2f0f50f 100644
--- a/IPython/frontend/html/notebook/notebookapp.py
+++ b/IPython/frontend/html/notebook/notebookapp.py
@@ -110,6 +110,7 @@ class NotebookWebApplication(web.Application):
         self.log = log
         self.notebook_manager = notebook_manager
         self.ipython_app = ipython_app
+        self.read_only = self.ipython_app.read_only
 
 
 #-----------------------------------------------------------------------------
@@ -121,11 +122,23 @@ flags['no-browser']=(
     {'NotebookApp' : {'open_browser' : False}},
     "Don't open the notebook in a browser after startup."
 )
+flags['read-only'] = (
+    {'NotebookApp' : {'read_only' : True}},
+    """Allow read-only access to notebooks.
+    
+    When using a password to protect the notebook server, this flag
+    allows unauthenticated clients to view the notebook list, and
+    individual notebooks, but not edit them, start kernels, or run
+    code.
+    
+    If no password is set, the server will be entirely read-only.
+    """
+)
 
 # the flags that are specific to the frontend
 # these must be scrubbed before being passed to the kernel,
 # or it will raise an error on unrecognized flags
-notebook_flags = ['no-browser']
+notebook_flags = ['no-browser', 'read-only']
 
 aliases = dict(ipkernel_aliases)
 
@@ -208,6 +221,10 @@ class NotebookApp(BaseIPythonApplication):
     
     open_browser = Bool(True, config=True,
                         help="Whether to open in a browser after starting.")
+    
+    read_only = Bool(False, config=True,
+        help="Whether to prevent editing/execution of notebooks."
+    )
 
     def get_ws_url(self):
         """Return the WebSocket URL for this server."""
@@ -288,7 +305,7 @@ class NotebookApp(BaseIPythonApplication):
         # Try random ports centered around the default.
         from random import randint
         n = 50  # Max number of attempts, keep reasonably large.
-        for port in [self.port] + [self.port + randint(-2*n, 2*n) for i in range(n)]:
+        for port in range(self.port, self.port+5) + [self.port + randint(-2*n, 2*n) for i in range(n-5)]:
             try:
                 self.http_server.listen(port, self.ip)
             except socket.error, e:
diff --git a/IPython/frontend/html/notebook/static/css/base.css b/IPython/frontend/html/notebook/static/css/base.css
index 8d2631c..ba113b3 100644
--- a/IPython/frontend/html/notebook/static/css/base.css
+++ b/IPython/frontend/html/notebook/static/css/base.css
@@ -51,3 +51,12 @@ div#main_app {
     padding: 0.2em 0.8em;
     font-size: 77%;
 }
+
+span#login_widget {
+    float: right;
+}
+
+/* generic class for hidden objects */
+.hidden {
+    display: none;
+}
\ No newline at end of file
diff --git a/IPython/frontend/html/notebook/static/js/cell.js b/IPython/frontend/html/notebook/static/js/cell.js
index a25d2e2..2d86c62 100644
--- a/IPython/frontend/html/notebook/static/js/cell.js
+++ b/IPython/frontend/html/notebook/static/js/cell.js
@@ -15,6 +15,10 @@ var IPython = (function (IPython) {
 
     var Cell = function (notebook) {
         this.notebook = notebook;
+        this.read_only = false;
+        if (notebook){
+            this.read_only = notebook.read_only;
+        }
         this.selected = false;
         this.element = null;
         this.create_element();
diff --git a/IPython/frontend/html/notebook/static/js/codecell.js b/IPython/frontend/html/notebook/static/js/codecell.js
index b557e29..044016e 100644
--- a/IPython/frontend/html/notebook/static/js/codecell.js
+++ b/IPython/frontend/html/notebook/static/js/codecell.js
@@ -37,6 +37,7 @@ var IPython = (function (IPython) {
             indentUnit : 4,
             mode: 'python',
             theme: 'ipython',
+            readOnly: this.read_only,
             onKeyEvent: $.proxy(this.handle_codemirror_keyevent,this)
         });
         input.append(input_area);
diff --git a/IPython/frontend/html/notebook/static/js/leftpanel.js b/IPython/frontend/html/notebook/static/js/leftpanel.js
index 4a41294..380510a 100644
--- a/IPython/frontend/html/notebook/static/js/leftpanel.js
+++ b/IPython/frontend/html/notebook/static/js/leftpanel.js
@@ -65,8 +65,10 @@ var IPython = (function (IPython) {
 
     LeftPanel.prototype.create_children = function () {
         this.notebook_section = new IPython.NotebookSection('div#notebook_section');
-        this.cell_section = new IPython.CellSection('div#cell_section');
-        this.kernel_section = new IPython.KernelSection('div#kernel_section');
+        if (! IPython.read_only){
+            this.cell_section = new IPython.CellSection('div#cell_section');
+            this.kernel_section = new IPython.KernelSection('div#kernel_section');
+        }
         this.help_section = new IPython.HelpSection('div#help_section');
     }
 
diff --git a/IPython/frontend/html/notebook/static/js/loginwidget.js b/IPython/frontend/html/notebook/static/js/loginwidget.js
new file mode 100644
index 0000000..17fcc58
--- /dev/null
+++ b/IPython/frontend/html/notebook/static/js/loginwidget.js
@@ -0,0 +1,38 @@
+//----------------------------------------------------------------------------
+//  Copyright (C) 2008-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.
+//----------------------------------------------------------------------------
+
+//============================================================================
+// Login button
+//============================================================================
+
+var IPython = (function (IPython) {
+
+    var LoginWidget = function (selector) {
+        this.selector = selector;
+        if (this.selector !== undefined) {
+            this.element = $(selector);
+            this.style();
+            this.bind_events();
+        }
+    };
+
+    LoginWidget.prototype.style = function () {
+        this.element.find('button#login').button();
+    };
+    LoginWidget.prototype.bind_events = function () {
+        var that = this;
+        this.element.find("button#login").click(function () {
+            window.location = "/login?next="+location.pathname;
+        });
+    };
+
+    // Set module variables
+    IPython.LoginWidget = LoginWidget;
+
+    return IPython;
+
+}(IPython));
diff --git a/IPython/frontend/html/notebook/static/js/notebook.js b/IPython/frontend/html/notebook/static/js/notebook.js
index e8c0f84..c27f701 100644
--- a/IPython/frontend/html/notebook/static/js/notebook.js
+++ b/IPython/frontend/html/notebook/static/js/notebook.js
@@ -14,6 +14,7 @@ var IPython = (function (IPython) {
     var utils = IPython.utils;
 
     var Notebook = function (selector) {
+        this.read_only = IPython.read_only;
         this.element = $(selector);
         this.element.scroll();
         this.element.data("notebook", this);
@@ -42,6 +43,7 @@ var IPython = (function (IPython) {
         var that = this;
         var end_space = $('<div class="end_space"></div>').height(150);
         end_space.dblclick(function (e) {
+            if (that.read_only) return;
             var ncells = that.ncells();
             that.insert_code_cell_below(ncells-1);
         });
@@ -54,6 +56,7 @@ var IPython = (function (IPython) {
         var that = this;
         $(document).keydown(function (event) {
             // console.log(event);
+            if (that.read_only) return;
             if (event.which === 38) {
                 var cell = that.selected_cell();
                 if (cell.at_top()) {
@@ -185,11 +188,11 @@ var IPython = (function (IPython) {
         });
 
         $(window).bind('beforeunload', function () {
-            var kill_kernel = $('#kill_kernel').prop('checked');                
+            var kill_kernel = $('#kill_kernel').prop('checked');
             if (kill_kernel) {
                 that.kernel.kill();
             }
-            if (that.dirty) {
+            if (that.dirty && ! that.read_only) {
                 return "You have unsaved changes that will be lost if you leave this page.";
             };
         });
@@ -975,14 +978,17 @@ var IPython = (function (IPython) {
 
 
     Notebook.prototype.notebook_loaded = function (data, status, xhr) {
+        var allowed = xhr.getResponseHeader('Allow');
         this.fromJSON(data);
         if (this.ncells() === 0) {
             this.insert_code_cell_below();
         };
         IPython.save_widget.status_save();
         IPython.save_widget.set_notebook_name(data.metadata.name);
-        this.start_kernel();
         this.dirty = false;
+        if (! this.read_only) {
+            this.start_kernel();
+        }
         // fromJSON always selects the last cell inserted. We need to wait
         // until that is done before scrolling to the top.
         setTimeout(function () {
@@ -991,7 +997,6 @@ var IPython = (function (IPython) {
         }, 50);
     };
 
-
     IPython.Notebook = Notebook;
 
 
diff --git a/IPython/frontend/html/notebook/static/js/notebooklist.js b/IPython/frontend/html/notebook/static/js/notebooklist.js
index 5a3bf86..316ef0e 100644
--- a/IPython/frontend/html/notebook/static/js/notebooklist.js
+++ b/IPython/frontend/html/notebook/static/js/notebooklist.js
@@ -80,7 +80,10 @@ var IPython = (function (IPython) {
             var nbname = data[i].name;
             var item = this.new_notebook_item(i);
             this.add_link(notebook_id, nbname, item);
-            this.add_delete_button(item);
+            if (!IPython.read_only){
+                // hide delete buttons when readonly
+                this.add_delete_button(item);
+            }
         };
     };
 
diff --git a/IPython/frontend/html/notebook/static/js/notebookmain.js b/IPython/frontend/html/notebook/static/js/notebookmain.js
index 095d098..11c0db6 100644
--- a/IPython/frontend/html/notebook/static/js/notebookmain.js
+++ b/IPython/frontend/html/notebook/static/js/notebookmain.js
@@ -23,6 +23,7 @@ $(document).ready(function () {
         }
     });
     IPython.markdown_converter = new Markdown.Converter();
+    IPython.read_only = $('meta[name=read_only]').attr("content") == 'True';
 
     $('div#header').addClass('border-box-sizing');
     $('div#main_app').addClass('border-box-sizing ui-widget ui-widget-content');
@@ -33,6 +34,7 @@ $(document).ready(function () {
     IPython.left_panel = new IPython.LeftPanel('div#left_panel', 'div#left_panel_splitter');
     IPython.save_widget = new IPython.SaveWidget('span#save_widget');
     IPython.quick_help = new IPython.QuickHelp('span#quick_help_area');
+    IPython.login_widget = new IPython.LoginWidget('span#login_widget');
     IPython.print_widget = new IPython.PrintWidget('span#print_widget');
     IPython.notebook = new IPython.Notebook('div#notebook');
     IPython.kernel_status_widget = new IPython.KernelStatusWidget('#kernel_status');
@@ -42,6 +44,21 @@ $(document).ready(function () {
 
     // These have display: none in the css file and are made visible here to prevent FLOUC.
     $('div#header').css('display','block');
+
+    if(IPython.read_only){
+        // hide various elements from read-only view
+        IPython.save_widget.element.find('button#save_notebook').addClass('hidden');
+        IPython.quick_help.element.addClass('hidden'); // shortcuts are disabled in read_only
+        $('button#new_notebook').addClass('hidden');
+        $('div#cell_section').addClass('hidden');
+        $('div#kernel_section').addClass('hidden');
+        $('span#login_widget').removeClass('hidden');
+        // left panel starts collapsed, but the collapse must happen after
+        // elements start drawing.  Don't draw contents of the panel until
+        // after they are collapsed
+        IPython.left_panel.left_panel_element.css('visibility', 'hidden');
+    }
+
     $('div#main_app').css('display','block');
 
     // Perform these actions after the notebook has been loaded.
@@ -52,6 +69,14 @@ $(document).ready(function () {
             IPython.save_widget.update_url();
             IPython.layout_manager.do_resize();
             IPython.pager.collapse();
+            if(IPython.read_only){
+                // collapse the left panel on read-only
+                IPython.left_panel.collapse();
+                // and finally unhide the panel contents after collapse
+                setTimeout(function(){
+                    IPython.left_panel.left_panel_element.css('visibility', 'visible');
+                }, 200)
+            }
         },100);
     });
 
diff --git a/IPython/frontend/html/notebook/static/js/projectdashboardmain.js b/IPython/frontend/html/notebook/static/js/projectdashboardmain.js
index 9ae6256..4d6f9d3 100644
--- a/IPython/frontend/html/notebook/static/js/projectdashboardmain.js
+++ b/IPython/frontend/html/notebook/static/js/projectdashboardmain.js
@@ -27,7 +27,16 @@ $(document).ready(function () {
     $('div#left_panel').addClass('box-flex');
     $('div#right_panel').addClass('box-flex');
 
+    IPython.read_only = $('meta[name=read_only]').attr("content") == 'True';
+    
     IPython.notebook_list = new IPython.NotebookList('div#notebook_list');
+    IPython.login_widget = new IPython.LoginWidget('span#login_widget');
+    
+    if (IPython.read_only){
+        $('#new_notebook').addClass('hidden');
+        // unhide login button if it's relevant
+        $('span#login_widget').removeClass('hidden');
+    }
     IPython.notebook_list.load_list();
 
     // These have display: none in the css file and are made visible here to prevent FLOUC.
diff --git a/IPython/frontend/html/notebook/static/js/textcell.js b/IPython/frontend/html/notebook/static/js/textcell.js
index 4a24e53..817c0dc 100644
--- a/IPython/frontend/html/notebook/static/js/textcell.js
+++ b/IPython/frontend/html/notebook/static/js/textcell.js
@@ -33,7 +33,8 @@ var IPython = (function (IPython) {
             indentUnit : 4,
             mode: this.code_mirror_mode,
             theme: 'default',
-            value: this.placeholder
+            value: this.placeholder,
+            readOnly: this.read_only,
         });
         // The tabindex=-1 makes this div focusable.
         var render_area = $('<div/>').addClass('text_cell_render').
@@ -65,6 +66,7 @@ var IPython = (function (IPython) {
 
 
     TextCell.prototype.edit = function () {
+        if ( this.read_only ) return;
         if (this.rendered === true) {
             var text_cell = this.element;
             var output = text_cell.find("div.text_cell_render");  
diff --git a/IPython/frontend/html/notebook/templates/login.html b/IPython/frontend/html/notebook/templates/login.html
index 86f160a..03b7b1c 100644
--- a/IPython/frontend/html/notebook/templates/login.html
+++ b/IPython/frontend/html/notebook/templates/login.html
@@ -11,6 +11,8 @@
     <link rel="stylesheet" href="static/css/layout.css" type="text/css" />
     <link rel="stylesheet" href="static/css/base.css" type="text/css" />
 
+    <meta name="read_only" content="{{read_only}}"/>
+
 </head>
 
 <body>
diff --git a/IPython/frontend/html/notebook/templates/notebook.html b/IPython/frontend/html/notebook/templates/notebook.html
index 3039ad5..0d924fc 100644
--- a/IPython/frontend/html/notebook/templates/notebook.html
+++ b/IPython/frontend/html/notebook/templates/notebook.html
@@ -40,7 +40,8 @@
     <link rel="stylesheet" href="static/css/base.css" type="text/css" />
     <link rel="stylesheet" href="static/css/notebook.css" type="text/css" />
     <link rel="stylesheet" href="static/css/renderedhtml.css" type="text/css" />
-
+    
+    <meta name="read_only" content="{{read_only}}"/>
 
 </head>
 
@@ -57,7 +58,10 @@
     </span>
     <span id="quick_help_area">
       <button id="quick_help">Quick<u>H</u>elp</button>
-      </span>
+    </span>
+    <span id="login_widget" class="hidden">
+      <button id="login">Login</button>
+    </span>
     <span id="kernel_status">Idle</span>
 </div>
 
@@ -278,6 +282,7 @@
 <script src="static/js/layout.js" type="text/javascript" charset="utf-8"></script>
 <script src="static/js/savewidget.js" type="text/javascript" charset="utf-8"></script>
 <script src="static/js/quickhelp.js" type="text/javascript" charset="utf-8"></script>
+<script src="static/js/loginwidget.js" type="text/javascript" charset="utf-8"></script>
 <script src="static/js/pager.js" type="text/javascript" charset="utf-8"></script>
 <script src="static/js/panelsection.js" type="text/javascript" charset="utf-8"></script>
 <script src="static/js/printwidget.js" type="text/javascript" charset="utf-8"></script>
diff --git a/IPython/frontend/html/notebook/templates/projectdashboard.html b/IPython/frontend/html/notebook/templates/projectdashboard.html
index 0b9075f..386e261 100644
--- a/IPython/frontend/html/notebook/templates/projectdashboard.html
+++ b/IPython/frontend/html/notebook/templates/projectdashboard.html
@@ -12,6 +12,8 @@
     <link rel="stylesheet" href="static/css/base.css" type="text/css" />
     <link rel="stylesheet" href="static/css/projectdashboard.css" type="text/css" />
 
+    <meta name="read_only" content="{{read_only}}"/>
+
 </head>
 
 <body data-project={{project}} data-base-project-url={{base_project_url}}
@@ -19,6 +21,9 @@
 
 <div id="header">
     <span id="ipython_notebook"><h1>IPython Notebook</h1></span>
+    <span id="login_widget" class="hidden">
+      <button id="login">Login</button>
+    </span>
 </div>
 
 <div id="header_border"></div>
@@ -54,6 +59,7 @@
 <script src="static/jquery/js/jquery-ui-1.8.14.custom.min.js" type="text/javascript" charset="utf-8"></script>
 <script src="static/js/namespace.js" type="text/javascript" charset="utf-8"></script>
 <script src="static/js/notebooklist.js" type="text/javascript" charset="utf-8"></script>
+<script src="static/js/loginwidget.js" type="text/javascript" charset="utf-8"></script>
 <script src="static/js/projectdashboardmain.js" type="text/javascript" charset="utf-8"></script>
 
 </body>