import os import json from pathlib import Path from flask import Flask, render_template, request, jsonify, send_file, abort import nbformat from nbconvert import HTMLExporter import mimetypes from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() app = Flask(__name__) # Configuration from environment variables app.config.update({ 'SHARED_DIRECTORY': os.environ.get('JUPYTERHUB_SHARED_DIR', '/shared'), 'HOST': os.environ.get('FLASK_HOST', '0.0.0.0'), 'PORT': int(os.environ.get('FLASK_PORT', 5000)), 'DEBUG': os.environ.get('FLASK_DEBUG', 'True').lower() == 'true', 'SECRET_KEY': os.environ.get('FLASK_SECRET_KEY', 'dev-key-change-in-production'), 'MAX_CONTENT_LENGTH': int(os.environ.get('MAX_FILE_SIZE', 16 * 1024 * 1024)), # 16MB default 'NOTEBOOKS_PER_PAGE': int(os.environ.get('NOTEBOOKS_PER_PAGE', 50)), 'ALLOWED_EXTENSIONS': os.environ.get('ALLOWED_EXTENSIONS', '.ipynb').split(','), 'ENABLE_DOWNLOAD': os.environ.get('ENABLE_DOWNLOAD', 'True').lower() == 'true', 'ENABLE_API': os.environ.get('ENABLE_API', 'True').lower() == 'true', 'APP_TITLE': os.environ.get('APP_TITLE', 'JupyterHub Notebook Viewer'), 'THEME': os.environ.get('THEME', 'dark'), # dark or light }) def get_notebook_files(directory): """Recursively get all notebook files from directory""" notebooks = [] allowed_extensions = app.config['ALLOWED_EXTENSIONS'] try: for root, dirs, files in os.walk(directory): for file in files: if any(file.endswith(ext) for ext in allowed_extensions): full_path = os.path.join(root, file) relative_path = os.path.relpath(full_path, directory) notebooks.append({ 'name': file, 'path': relative_path, 'full_path': full_path, 'size': os.path.getsize(full_path), 'modified': os.path.getmtime(full_path) }) except (OSError, PermissionError) as e: app.logger.error(f"Error accessing directory {directory}: {e}") # Limit results based on configuration notebooks = sorted(notebooks, key=lambda x: x['modified'], reverse=True) return notebooks[:app.config['NOTEBOOKS_PER_PAGE']] def get_directory_structure(directory): """Get directory structure for navigation""" structure = [] try: for item in os.listdir(directory): item_path = os.path.join(directory, item) if os.path.isdir(item_path): structure.append({ 'name': item, 'type': 'directory', 'path': os.path.relpath(item_path, app.config['SHARED_DIRECTORY']) }) except (OSError, PermissionError): pass return sorted(structure, key=lambda x: x['name']) def convert_notebook_to_html(notebook_path): """Convert Jupyter notebook to HTML""" try: with open(notebook_path, 'r', encoding='utf-8') as f: notebook = nbformat.read(f, as_version=4) html_exporter = HTMLExporter() html_exporter.template_name = 'classic' (body, resources) = html_exporter.from_notebook_node(notebook) return body except Exception as e: return f"
Error converting notebook: {str(e)}
" @app.route('/') def index(): """Main page showing all notebooks""" current_dir = request.args.get('dir', '') full_current_dir = os.path.join(app.config['SHARED_DIRECTORY'], current_dir) if not os.path.exists(full_current_dir): abort(404) notebooks = get_notebook_files(full_current_dir) directories = get_directory_structure(full_current_dir) # Breadcrumb navigation breadcrumbs = [] if current_dir: parts = current_dir.split(os.sep) for i, part in enumerate(parts): breadcrumbs.append({ 'name': part, 'path': os.sep.join(parts[:i+1]) }) return render_template('index.html', notebooks=notebooks, directories=directories, current_dir=current_dir, breadcrumbs=breadcrumbs, config=app.config) @app.route('/view/') def view_notebook(notebook_path): """View a specific notebook""" full_path = os.path.join(app.config['SHARED_DIRECTORY'], notebook_path) if not os.path.exists(full_path): abort(404) # Check if file has allowed extension if not any(full_path.endswith(ext) for ext in app.config['ALLOWED_EXTENSIONS']): abort(403) # Security check - ensure path is within shared directory if not os.path.commonpath([full_path, app.config['SHARED_DIRECTORY']]) == app.config['SHARED_DIRECTORY']: abort(403) html_content = convert_notebook_to_html(full_path) return render_template('notebook.html', html_content=html_content, notebook_name=os.path.basename(notebook_path), notebook_path=notebook_path, config=app.config) @app.route('/download/') def download_notebook(notebook_path): """Download a notebook file""" if not app.config['ENABLE_DOWNLOAD']: abort(403, "Downloads are disabled") full_path = os.path.join(app.config['SHARED_DIRECTORY'], notebook_path) if not os.path.exists(full_path): abort(404) # Check if file has allowed extension if not any(full_path.endswith(ext) for ext in app.config['ALLOWED_EXTENSIONS']): abort(403) # Security check if not os.path.commonpath([full_path, app.config['SHARED_DIRECTORY']]) == app.config['SHARED_DIRECTORY']: abort(403) return send_file(full_path, as_attachment=True) @app.route('/api/notebooks') def api_notebooks(): """API endpoint to get notebooks as JSON""" if not app.config['ENABLE_API']: abort(403, "API is disabled") current_dir = request.args.get('dir', '') full_current_dir = os.path.join(app.config['SHARED_DIRECTORY'], current_dir) notebooks = get_notebook_files(full_current_dir) directories = get_directory_structure(full_current_dir) return jsonify({ 'notebooks': notebooks, 'directories': directories, 'current_dir': current_dir, 'config': { 'app_title': app.config['APP_TITLE'], 'enable_download': app.config['ENABLE_DOWNLOAD'], 'notebooks_per_page': app.config['NOTEBOOKS_PER_PAGE'] } }) @app.errorhandler(404) def not_found(error): return render_template('error.html', error_code=404, error_message="Notebook or directory not found"), 404 @app.errorhandler(403) def forbidden(error): return render_template('error.html', error_code=403, error_message="Access forbidden"), 403 @app.errorhandler(500) def server_error(error): return render_template('error.html', error_code=500, error_message="Internal server error"), 500 if __name__ == '__main__': # Create templates directory if it doesn't exist os.makedirs('templates', exist_ok=True) # Determine theme classes navbar_class = "navbar-dark bg-dark" if app.config['THEME'] == 'dark' else "navbar-light bg-light" # Basic templates index_template = f''' {{{{ config.APP_TITLE }}}}
{{% if breadcrumbs %}} {{% endif %}}

{{% if current_dir %}} Notebooks in {{{{ current_dir }}}} {{% else %}} All Notebooks {{% endif %}}

{{% if directories %}}

Directories

{{% for dir in directories %}} {{% endfor %}}
{{% endif %}}

Notebooks

{{% if notebooks %}} {{% for notebook in notebooks %}}
{{{{ notebook.name }}}}

Size: {{{{ "%.1f"|format(notebook.size/1024) }}}} KB | Modified: {{{{ notebook.modified|int|datetime }}}}

View {{% if config.ENABLE_DOWNLOAD %}} Download {{% endif %}}
{{% endfor %}} {{% if notebooks|length == config.NOTEBOOKS_PER_PAGE %}}
Showing first {{{{ config.NOTEBOOKS_PER_PAGE }}}} notebooks. Configure NOTEBOOKS_PER_PAGE to show more.
{{% endif %}} {{% else %}}
No notebooks found in this directory.
{{% endif %}}
''' notebook_template = f''' {{{{ notebook_name }}}} - {{{{ config.APP_TITLE }}}}

{{{{ notebook_name }}}}

{{{{ html_content|safe }}}}
''' error_template = f''' Error {{{{ error_code }}}} - {{{{ config.APP_TITLE if config else 'JupyterHub Notebook Viewer' }}}}

Error {{{{ error_code }}}}

{{{{ error_message }}}}

Go Home
''' # Write templates with open('templates/index.html', 'w') as f: f.write(index_template) with open('templates/notebook.html', 'w') as f: f.write(notebook_template) with open('templates/error.html', 'w') as f: f.write(error_template) # Add datetime filter @app.template_filter('datetime') def datetime_filter(timestamp): from datetime import datetime return datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M:%S') print(f"Starting {app.config['APP_TITLE']}") print(f"Shared directory: {app.config['SHARED_DIRECTORY']}") print(f"Server running on http://{app.config['HOST']}:{app.config['PORT']}") print(f"Debug mode: {app.config['DEBUG']}") print(f"Downloads enabled: {app.config['ENABLE_DOWNLOAD']}") print(f"API enabled: {app.config['ENABLE_API']}") app.run( debug=app.config['DEBUG'], host=app.config['HOST'], port=app.config['PORT'] )