This commit is contained in:
DerGrumpf 2025-06-17 13:06:48 +02:00
parent 9146896837
commit 8c6ed3d642
9 changed files with 1235 additions and 0 deletions

23
.env Normal file
View File

@ -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

24
.env.example Normal file
View File

@ -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

0
Dockerfile Normal file
View File

163
Justfile Normal file
View File

@ -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/"

236
README.md
View File

@ -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=<path> - List notebooks and directories
GET / - Main web interface
GET /view/<path> - View notebook as HTML
GET /download/<path> - 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) │ │
│ └─────────────┘ └──────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────┘

440
app.py Normal file
View File

@ -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"<div class='alert alert-danger'>Error converting notebook: {str(e)}</div>"
@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/<path:notebook_path>')
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/<path:notebook_path>')
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'''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{{{ config.APP_TITLE }}}}</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.1.3/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
</head>
<body>
<nav class="navbar {navbar_class}">
<div class="container">
<a class="navbar-brand" href="{{{{ url_for('index') }}}}">
<i class="fas fa-book"></i> {{{{ config.APP_TITLE }}}}
</a>
</div>
</nav>
<div class="container mt-4">
<!-- Breadcrumb Navigation -->
{{% if breadcrumbs %}}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{{{ url_for('index') }}}}">Home</a></li>
{{% for crumb in breadcrumbs %}}
<li class="breadcrumb-item">
<a href="{{{{ url_for('index', dir=crumb.path) }}}}">{{{{ crumb.name }}}}</a>
</li>
{{% endfor %}}
</ol>
</nav>
{{% endif %}}
<h1 class="mb-4">
{{% if current_dir %}}
Notebooks in {{{{ current_dir }}}}
{{% else %}}
All Notebooks
{{% endif %}}
</h1>
<!-- Directories -->
{{% if directories %}}
<div class="row mb-4">
<div class="col-12">
<h3><i class="fas fa-folder"></i> Directories</h3>
{{% for dir in directories %}}
<div class="card mb-2">
<div class="card-body">
<h5 class="card-title">
<a href="{{{{ url_for('index', dir=dir.path) }}}}" class="text-decoration-none">
<i class="fas fa-folder text-warning"></i> {{{{ dir.name }}}}
</a>
</h5>
</div>
</div>
{{% endfor %}}
</div>
</div>
{{% endif %}}
<!-- Notebooks -->
<div class="row">
<div class="col-12">
<h3><i class="fas fa-file-code"></i> Notebooks</h3>
{{% if notebooks %}}
{{% for notebook in notebooks %}}
<div class="card mb-3">
<div class="card-body">
<div class="row align-items-center">
<div class="col-md-8">
<h5 class="card-title">
<i class="fas fa-file-code text-primary"></i> {{{{ notebook.name }}}}
</h5>
<p class="card-text text-muted">
<small>
Size: {{{{ "%.1f"|format(notebook.size/1024) }}}} KB |
Modified: {{{{ notebook.modified|int|datetime }}}}
</small>
</p>
</div>
<div class="col-md-4 text-end">
<a href="{{{{ url_for('view_notebook', notebook_path=notebook.path) }}}}"
class="btn btn-primary btn-sm me-2">
<i class="fas fa-eye"></i> View
</a>
{{% if config.ENABLE_DOWNLOAD %}}
<a href="{{{{ url_for('download_notebook', notebook_path=notebook.path) }}}}"
class="btn btn-secondary btn-sm">
<i class="fas fa-download"></i> Download
</a>
{{% endif %}}
</div>
</div>
</div>
</div>
{{% endfor %}}
{{% if notebooks|length == config.NOTEBOOKS_PER_PAGE %}}
<div class="alert alert-info">
<i class="fas fa-info-circle"></i> Showing first {{{{ config.NOTEBOOKS_PER_PAGE }}}} notebooks. Configure NOTEBOOKS_PER_PAGE to show more.
</div>
{{% endif %}}
{{% else %}}
<div class="alert alert-info">
<i class="fas fa-info-circle"></i> No notebooks found in this directory.
</div>
{{% endif %}}
</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.1.3/js/bootstrap.bundle.min.js"></script>
</body>
</html>'''
notebook_template = f'''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{{{ notebook_name }}}} - {{{{ config.APP_TITLE }}}}</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.1.3/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
.notebook-content {{
max-width: 100%;
overflow-x: auto;
}}
.notebook-content img {{
max-width: 100%;
height: auto;
}}
</style>
</head>
<body>
<nav class="navbar {navbar_class}">
<div class="container">
<a class="navbar-brand" href="{{{{ url_for('index') }}}}">
<i class="fas fa-book"></i> {{{{ config.APP_TITLE }}}}
</a>
<div>
{{% if config.ENABLE_DOWNLOAD %}}
<a href="{{{{ url_for('download_notebook', notebook_path=notebook_path) }}}}"
class="btn btn-outline-light btn-sm">
<i class="fas fa-download"></i> Download
</a>
{{% endif %}}
</div>
</div>
</nav>
<div class="container-fluid mt-4">
<div class="row">
<div class="col-12">
<h2 class="mb-3">
<i class="fas fa-file-code text-primary"></i> {{{{ notebook_name }}}}
</h2>
<div class="notebook-content">
{{{{ html_content|safe }}}}
</div>
</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.1.3/js/bootstrap.bundle.min.js"></script>
</body>
</html>'''
error_template = f'''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Error {{{{ error_code }}}} - {{{{ config.APP_TITLE if config else 'JupyterHub Notebook Viewer' }}}}</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.1.3/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
</head>
<body>
<nav class="navbar {navbar_class}">
<div class="container">
<a class="navbar-brand" href="{{{{ url_for('index') }}}}">
<i class="fas fa-book"></i> {{{{ config.APP_TITLE if config else 'JupyterHub Notebook Viewer' }}}}
</a>
</div>
</nav>
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-6 text-center">
<div class="alert alert-danger">
<h1><i class="fas fa-exclamation-triangle"></i></h1>
<h2>Error {{{{ error_code }}}}</h2>
<p>{{{{ error_message }}}}</p>
<a href="{{{{ url_for('index') }}}}" class="btn btn-primary">
<i class="fas fa-home"></i> Go Home
</a>
</div>
</div>
</div>
</div>
</body>
</html>'''
# 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']
)

36
compose.yml Normal file
View File

@ -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

313
flake.nix Normal file
View File

@ -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;
});
}

0
requirements.txt Normal file
View File