diff --git a/.env b/.env new file mode 100644 index 0000000..104d37f --- /dev/null +++ b/.env @@ -0,0 +1,23 @@ +# JupyterHub Notebook Viewer Configuration + +# Core Settings +JUPYTERHUB_SHARED_DIR=/shared +APP_TITLE=JupyterHub Notebook Viewer + +# Flask Settings +FLASK_HOST=0.0.0.0 +FLASK_PORT=5000 +FLASK_DEBUG=True +FLASK_SECRET_KEY=your-secret-key-change-in-production + +# File Handling +MAX_FILE_SIZE=16777216 +NOTEBOOKS_PER_PAGE=50 +ALLOWED_EXTENSIONS=.ipynb,.py,.md + +# Feature Toggles +ENABLE_DOWNLOAD=True +ENABLE_API=True + +# UI Settings +THEME=dark diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3555705 --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# JupyterHub Notebook Viewer Configuration +# Copy this file to .env and modify as needed + +# Core Settings +JUPYTERHUB_SHARED_DIR=/shared +APP_TITLE=JupyterHub Notebook Viewer + +# Flask Settings +FLASK_HOST=0.0.0.0 +FLASK_PORT=5000 +FLASK_DEBUG=True +FLASK_SECRET_KEY=your-secret-key-change-in-production + +# File Handling +MAX_FILE_SIZE=16777216 # 16MB in bytes +NOTEBOOKS_PER_PAGE=50 +ALLOWED_EXTENSIONS=.ipynb,.py,.md + +# Feature Toggles +ENABLE_DOWNLOAD=True +ENABLE_API=True + +# UI Settings +THEME=dark # dark or light diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e69de29 diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..2dd9701 --- /dev/null +++ b/Justfile @@ -0,0 +1,163 @@ +# JupyterHub Notebook Viewer - Development Commands + +# Default recipe +default: + @just --list + +# Set up development environment +setup: + @echo "Setting up development environment..." + mkdir -p shared notebooks templates + [ ! -f .env ] && cp .env.example .env || true + @echo "✓ Development environment ready" + +# Start development server +dev: + @echo "Starting development server..." + python app.py + +# Start development server with auto-reload (using nix shell) +dev-nix: + nix develop --command dev-server + +# Run tests +test: + @echo "Running tests..." + python -m pytest tests/ -v || python -c "import app; print('Basic import test passed')" + +# Run tests in nix environment +test-nix: + nix develop --command run-tests + +# Build Docker image +docker-build: + docker build -t jupyterhub-notebook-viewer . + +# Start with Docker Compose +docker-up: + docker-compose up + +# Start with Docker Compose in background +docker-up-daemon: + docker-compose up -d + +# Start with Docker Compose and rebuild +docker-rebuild: + docker-compose up --build + +# Start with nginx proxy +docker-proxy: + docker-compose --profile with-proxy up + +# Stop Docker services +docker-down: + docker-compose down + +# View Docker logs +docker-logs: + docker-compose logs -f + +# Clean Docker resources +docker-clean: + docker-compose down -v + docker system prune -f + +# Install Python dependencies +install: + pip install -r requirements.txt + +# Format code +format: + black app.py + isort app.py + +# Lint code +lint: + flake8 app.py + pylint app.py + +# Generate requirements.txt +freeze: + pip freeze > requirements.txt + +# Create sample notebook +sample-notebook: + @mkdir -p shared + @cat > shared/sample.ipynb << 'EOF' + { + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Sample Notebook\n\nThis is a sample Jupyter notebook." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print('Hello World!')\nimport numpy as np\nprint(f'NumPy version: {np.__version__}')" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + } + }, + "nbformat": 4, + "nbformat_minor": 4 + } + EOF + @echo "✓ Sample notebook created in shared/sample.ipynb" + +# Check environment configuration +check-env: + @echo "Environment Configuration:" + @echo "=========================" + @grep -E '^[A-Z_]' .env 2>/dev/null || echo "No .env file found" + @echo "" + @echo "Current Python: $(which python)" + @echo "Flask available: $(python -c 'import flask; print(flask.__version__)' 2>/dev/null || echo 'Not installed')" + @echo "Shared directory: ${JUPYTERHUB_SHARED_DIR:-./shared}" + +# Show application logs +logs: + tail -f app.log 2>/dev/null || echo "No log file found" + +# Clean temporary files +clean: + rm -rf __pycache__/ + rm -rf .pytest_cache/ + rm -rf *.pyc + rm -rf .coverage + rm -rf htmlcov/ + rm -rf dist/ + rm -rf build/ + rm -rf *.egg-info/ + +# Security scan +security: + safety check -r requirements.txt || echo "Safety not installed, skipping security scan" + bandit -r . -f json || echo "Bandit not installed, skipping security scan" + +# Performance test +perf: + @echo "Running performance test..." + ab -n 100 -c 10 http://localhost:5000/ || echo "Apache Bench not available" + +# Health check +health: + curl -f http://localhost:5000/ || echo "Application not responding" + +# Generate documentation +docs: + @echo "Generating documentation..." + mkdir -p docs + @echo "# JupyterHub Notebook Viewer\n\nGenerated documentation\n" > docs/README.md + @echo "✓ Documentation generated in docs/" diff --git a/README.md b/README.md index e69de29..32d8346 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,236 @@ +JupyterHub Notebook Viewer + +A Flask-based web application for viewing and browsing Jupyter notebooks from a JupyterHub shared directory. Features a responsive web interface with directory navigation, notebook preview, and download capabilities. +Features + + 📁 Directory Navigation - Browse through nested directories with breadcrumb navigation + 📄 Notebook Viewing - Convert and display Jupyter notebooks as HTML in the browser + ⬇️ Download Support - Direct download of notebook files + 🎨 Responsive Design - Bootstrap-based UI that works on desktop and mobile + 🔒 Security - Path traversal protection and configurable access controls + 🛠️ Configurable - All settings configurable via environment variables + 🐳 Docker Support - Ready-to-use Docker containers and compose files + ❄️ Nix Development - Complete Nix flake for reproducible development + +Quick Start +Using Nix (Recommended) + +bash + +# Enter development environment +nix develop + +# Set up the environment (first time only) +setup-dev + +# Start development server +dev-server + +Using Docker + +bash + +# Copy and configure environment +cp .env.example .env +# Edit .env with your settings + +# Start with Docker Compose +docker-compose up + +Manual Installation + +bash + +# Install dependencies +pip install -r requirements.txt + +# Configure environment +cp .env.example .env +# Edit .env with your settings + +# Start the application +python app.py + +Configuration + +All configuration is done via environment variables, typically in a .env file: +Core Settings + + JUPYTERHUB_SHARED_DIR - Path to the shared notebook directory (default: /shared) + APP_TITLE - Application title shown in the interface + +Flask Settings + + FLASK_HOST - Host to bind to (default: 0.0.0.0) + FLASK_PORT - Port to run on (default: 5000) + FLASK_DEBUG - Enable debug mode (default: True) + FLASK_SECRET_KEY - Secret key for sessions (change in production!) + +File Handling + + MAX_FILE_SIZE - Maximum file size in bytes (default: 16777216 = 16MB) + NOTEBOOKS_PER_PAGE - Maximum notebooks to show per directory (default: 50) + ALLOWED_EXTENSIONS - Comma-separated list of allowed file extensions (default: .ipynb,.py,.md) + +Feature Toggles + + ENABLE_DOWNLOAD - Enable file downloads (default: True) + ENABLE_API - Enable JSON API endpoints (default: True) + +UI Settings + + THEME - UI theme, dark or light (default: dark) + +Development +With Nix + +The project includes a complete Nix flake for reproducible development: + +bash + +# Enter development shell +nix develop + +# Available commands in the shell: +setup-dev # Set up development environment +dev-server # Start development server with auto-reload +run-tests # Run basic tests + +With Just + +If you have just installed: + +bash + +# See all available commands +just + +# Common commands +just setup # Set up development environment +just dev # Start development server +just docker-up # Start with Docker +just test # Run tests +just clean # Clean temporary files + +Manual Development + +bash + +# Install dependencies +pip install -r requirements.txt + +# Set up environment +cp .env.example .env + +# Create sample content +mkdir -p shared +# Add some .ipynb files to shared/ + +# Start development server +python app.py + +Docker Deployment +Basic Deployment + +bash + +# Build and start +docker-compose up --build + +# Run in background +docker-compose up -d + +# View logs +docker-compose logs -f + +With Reverse Proxy + +The compose file includes an optional nginx reverse proxy: + +bash + +# Start with proxy +docker-compose --profile with-proxy up + +Production Considerations + +For production deployment: + + Change the secret key: Set FLASK_SECRET_KEY to a secure random value + Disable debug mode: Set FLASK_DEBUG=False + Configure volumes: Mount your actual shared directory + Set up SSL: Configure HTTPS in your reverse proxy + Resource limits: Set appropriate CPU and memory limits in Docker + +API Endpoints + +When ENABLE_API=True, the following JSON API endpoints are available: + + GET /api/notebooks?dir= - List notebooks and directories + GET / - Main web interface + GET /view/ - View notebook as HTML + GET /download/ - Download notebook file (if enabled) + +Security Features + + Path Traversal Protection - Prevents access outside the configured directory + File Type Validation - Only allows configured file extensions + Configurable Downloads - Downloads can be disabled entirely + Non-root Docker User - Container runs as non-privileged user + +Troubleshooting +Common Issues + + "No notebooks found" + Check that JUPYTERHUB_SHARED_DIR points to the correct directory + Ensure the directory contains .ipynb files + Check file permissions + Notebook won't display + Verify the notebook file is valid JSON + Check that nbconvert dependencies are installed + Look for errors in the application logs + Permission denied + Ensure the application has read access to the shared directory + Check Docker volume mounts + +Logs + +Application logs are printed to stdout. In Docker: + +bash + +docker-compose logs -f notebook-viewer + +Contributing + + Fork the repository + Enter the development environment: nix develop + Make your changes + Run tests: run-tests + Submit a pull request + +License + +MIT License - see LICENSE file for details. +Architecture + +┌─────────────────────────────────────────────────────────────┐ +│ Web Browser │ +└─────────────────────┬───────────────────────────────────────┘ + │ HTTP +┌─────────────────────▼───────────────────────────────────────┐ +│ Flask App │ +│ ┌─────────────┐ ┌──────────────┐ ┌─────────────────────┐ │ +│ │ Routes │ │ Templates │ │ Static Assets │ │ +│ └─────────────┘ └──────────────┘ └─────────────────────┘ │ +└─────────────────────┬───────────────────────────────────────┘ + │ File System +┌─────────────────────▼───────────────────────────────────────┐ +│ JupyterHub Shared Directory │ +│ ┌─────────────┐ ┌──────────────┐ ┌─────────────────────┐ │ +│ │ Notebooks │ │ Directories │ │ Other Files │ │ +│ │ (.ipynb) │ │ │ │ (.py, .md) │ │ +│ └─────────────┘ └──────────────┘ └─────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + + diff --git a/app.py b/app.py new file mode 100644 index 0000000..3aea62c --- /dev/null +++ b/app.py @@ -0,0 +1,440 @@ +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'] + ) diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..da02fff --- /dev/null +++ b/compose.yml @@ -0,0 +1,36 @@ +version: '3.8' + +services: + notebook-viewer: + build: . + ports: + - "${FLASK_PORT:-5000}:5000" + volumes: + - "${JUPYTERHUB_SHARED_DIR:-./shared}:/shared:ro" + - "./notebooks:/notebooks:ro" # Additional notebooks directory + environment: + - JUPYTERHUB_SHARED_DIR=/shared + - FLASK_HOST=0.0.0.0 + - FLASK_PORT=5000 + - FLASK_DEBUG=${FLASK_DEBUG:-False} + - FLASK_SECRET_KEY=${FLASK_SECRET_KEY:-change-me-in-production} + - MAX_FILE_SIZE=${MAX_FILE_SIZE:-16777216} + - NOTEBOOKS_PER_PAGE=${NOTEBOOKS_PER_PAGE:-50} + - ALLOWED_EXTENSIONS=${ALLOWED_EXTENSIONS:-.ipynb,.py,.md} + - ENABLE_DOWNLOAD=${ENABLE_DOWNLOAD:-True} + - ENABLE_API=${ENABLE_API:-True} + - APP_TITLE=${APP_TITLE:-JupyterHub Notebook Viewer} + - THEME=${THEME:-dark} + env_file: + - .env + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5000/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + +volumes: + shared: + driver: local diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..c5c3696 --- /dev/null +++ b/flake.nix @@ -0,0 +1,313 @@ +{ + description = "JupyterHub Notebook Viewer Development Environment"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + + # Python with required packages + python = pkgs.python311.withPackages (ps: with ps; [ + flask + python-dotenv + nbformat + nbconvert + werkzeug + jinja2 + markupsafe + itsdangerous + click + blinker + jupyter-core + traitlets + jupyter-client + ipython + bleach + defusedxml + mistune + pandocfilters + beautifulsoup4 + webencodings + fastjsonschema + jsonschema + pyrsistent + pyzmq + tornado + nest-asyncio + psutil + packaging + platformdirs + debugpy + matplotlib-inline + pygments + cffi + argon2-cffi + jupyterlab-pygments + nbclient + tinycss2 + ]); + + # Development tools + devTools = with pkgs; [ + # Core development + python + nodejs_20 + + # System tools + curl + jq + tree + htop + + # Container tools + docker + docker-compose + + # Documentation tools + pandoc + + # LaTeX for nbconvert + texlive.combined.scheme-medium + + # Version control + git + + # Text editors + vim + nano + + # Network tools + netcat + + # Process management + supervisor + ]; + + # Shell script for easy setup + setupScript = pkgs.writeShellScriptBin "setup-dev" '' + echo "Setting up JupyterHub Notebook Viewer development environment..." + + # Create directories + mkdir -p shared notebooks templates + + # Copy example .env if it doesn't exist + if [ ! -f .env ]; then + cp .env.example .env 2>/dev/null || echo "No .env.example found, using defaults" + fi + + # Set up git hooks if in git repo + if [ -d .git ]; then + echo "Setting up git hooks..." + cp .githooks/* .git/hooks/ 2>/dev/null || true + fi + + # Create sample notebook if shared directory is empty + if [ ! "$(ls -A shared)" ]; then + echo "Creating sample notebook..." + cat > shared/sample.ipynb << 'EOF' + { + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Sample Notebook\n", + "\n", + "This is a sample Jupyter notebook for testing the viewer." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print('Hello from JupyterHub Notebook Viewer!')\n", + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "\n", + "x = np.linspace(0, 10, 100)\n", + "y = np.sin(x)\n", + "\n", + "plt.figure(figsize=(10, 6))\n", + "plt.plot(x, y)\n", + "plt.title('Sample Plot')\n", + "plt.xlabel('X')\n", + "plt.ylabel('sin(x)')\n", + "plt.grid(True)\n", + "plt.show()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 + } + EOF + fi + + echo "Development environment setup complete!" + echo "Run 'python app.py' to start the development server" + ''; + + # Script for running tests + testScript = pkgs.writeShellScriptBin "run-tests" '' + echo "Running tests for JupyterHub Notebook Viewer..." + + # Basic import test + python -c " + import app + print('✓ App imports successfully') + + # Test configuration loading + from dotenv import load_dotenv + load_dotenv() + print('✓ Environment variables loaded') + + # Test nbconvert + import nbformat + import nbconvert + print('✓ Jupyter dependencies working') + " + + # Test Flask app creation + python -c " + import app + test_app = app.app.test_client() + response = test_app.get('/') + if response.status_code in [200, 404]: # 404 is OK if no shared dir + print('✓ Flask app responds correctly') + else: + print('✗ Flask app error:', response.status_code) + exit(1) + " + + echo "All tests passed!" + ''; + + # Development server with auto-reload + devServerScript = pkgs.writeShellScriptBin "dev-server" '' + echo "Starting development server with auto-reload..." + export FLASK_DEBUG=True + export FLASK_ENV=development + + # Load .env file + if [ -f .env ]; then + export $(cat .env | grep -v '^#' | xargs) + fi + + # Start the server + python app.py + ''; + + in + { + # Development shell + devShells.default = pkgs.mkShell { + buildInputs = devTools ++ [ + setupScript + testScript + devServerScript + ]; + + shellHook = '' + echo "🚀 JupyterHub Notebook Viewer Development Environment" + echo "==================================================" + echo "" + echo "Available commands:" + echo " setup-dev - Set up development environment" + echo " dev-server - Start development server with auto-reload" + echo " run-tests - Run basic tests" + echo " python app.py - Start the application" + echo "" + echo "Docker commands:" + echo " docker-compose up - Start with Docker" + echo " docker-compose up --build - Rebuild and start" + echo " docker-compose up -d - Start in background" + echo " docker-compose --profile with-proxy up - Start with nginx proxy" + echo "" + echo "Configuration:" + echo " Edit .env file to configure the application" + echo " JUPYTERHUB_SHARED_DIR: $(echo ''${JUPYTERHUB_SHARED_DIR:-/shared})" + echo "" + + # Auto-setup on first run + if [ ! -f .env ] && [ ! -d shared ]; then + echo "First run detected, running setup..." + setup-dev + fi + ''; + + # Environment variables for development + FLASK_DEBUG = "True"; + FLASK_ENV = "development"; + PYTHONPATH = "."; + }; + + # Package for the application + packages.default = pkgs.stdenv.mkDerivation { + pname = "jupyterhub-notebook-viewer"; + version = "1.0.0"; + + src = ./.; + + buildInputs = [ python ]; + + installPhase = '' + mkdir -p $out/bin $out/share/jupyterhub-notebook-viewer + + # Copy application files + cp app.py $out/share/jupyterhub-notebook-viewer/ + cp -r templates $out/share/jupyterhub-notebook-viewer/ + + # Create wrapper script + cat > $out/bin/jupyterhub-notebook-viewer << EOF + #!${pkgs.bash}/bin/bash + cd $out/share/jupyterhub-notebook-viewer + exec ${python}/bin/python app.py "\$@" + EOF + chmod +x $out/bin/jupyterhub-notebook-viewer + ''; + + meta = with pkgs.lib; { + description = "Web viewer for JupyterHub shared notebooks"; + license = licenses.mit; + platforms = platforms.unix; + }; + }; + + # Docker image build + packages.docker = pkgs.dockerTools.buildImage { + name = "jupyterhub-notebook-viewer"; + tag = "latest"; + + contents = [ python pkgs.pandoc ]; + + config = { + Cmd = [ "${python}/bin/python" "/app/app.py" ]; + WorkingDir = "/app"; + ExposedPorts = { + "5000/tcp" = {}; + }; + }; + }; + + # Formatter for nix files + formatter = pkgs.nixpkgs-fmt; + }); +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e69de29