441 lines
17 KiB
Python
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']
|
||
|
)
|