JupyShare/app.py
2025-06-17 13:06:48 +02:00

441 lines
17 KiB
Python

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']
)