This commit is contained in:
503
webserver.py
Normal file
503
webserver.py
Normal file
@@ -0,0 +1,503 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ParentZone Snapshots Web Server
|
||||
|
||||
A simple web server that serves HTML snapshot files and their assets.
|
||||
Provides a directory listing and serves static files from the snapshots folder.
|
||||
"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
import argparse
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote
|
||||
from datetime import datetime
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import web, hdrs
|
||||
from aiohttp.web_response import Response
|
||||
|
||||
|
||||
class SnapshotsWebServer:
|
||||
def __init__(
|
||||
self,
|
||||
snapshots_dir: str = "./snapshots",
|
||||
port: int = 8080,
|
||||
host: str = "0.0.0.0",
|
||||
):
|
||||
self.snapshots_dir = Path(snapshots_dir).resolve()
|
||||
self.port = port
|
||||
self.host = host
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
# Ensure snapshots directory exists
|
||||
self.snapshots_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.logger.info(f"Serving snapshots from: {self.snapshots_dir}")
|
||||
|
||||
async def index_handler(self, request):
|
||||
"""Serve the main directory listing page."""
|
||||
try:
|
||||
html_files = []
|
||||
|
||||
# Find all HTML files in the snapshots directory
|
||||
for file_path in self.snapshots_dir.glob("*.html"):
|
||||
stat = file_path.stat()
|
||||
html_files.append(
|
||||
{
|
||||
"name": file_path.name,
|
||||
"size": stat.st_size,
|
||||
"modified": datetime.fromtimestamp(stat.st_mtime),
|
||||
"path": file_path.name,
|
||||
}
|
||||
)
|
||||
|
||||
# Sort by modification time (newest first)
|
||||
html_files.sort(key=lambda x: x["modified"], reverse=True)
|
||||
|
||||
# Generate HTML page
|
||||
html_content = self._generate_index_html(html_files)
|
||||
|
||||
return web.Response(text=html_content, content_type="text/html")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error generating index: {e}")
|
||||
return web.Response(
|
||||
text=f"<h1>Error</h1><p>Could not generate directory listing: {e}</p>",
|
||||
status=500,
|
||||
content_type="text/html",
|
||||
)
|
||||
|
||||
def _generate_index_html(self, html_files):
|
||||
"""Generate the HTML directory listing page."""
|
||||
files_list = ""
|
||||
|
||||
if not html_files:
|
||||
files_list = "<p class='no-files'>No snapshot files found.</p>"
|
||||
else:
|
||||
for file_info in html_files:
|
||||
size_mb = file_info["size"] / (1024 * 1024)
|
||||
files_list += f"""
|
||||
<div class="file-item">
|
||||
<div class="file-info">
|
||||
<h3><a href="/{file_info["path"]}" class="file-link">{file_info["name"]}</a></h3>
|
||||
<div class="file-meta">
|
||||
<span class="file-size">{size_mb:.2f} MB</span>
|
||||
<span class="file-date">{file_info["modified"].strftime("%Y-%m-%d %H:%M:%S")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
return f"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ParentZone Snapshots</title>
|
||||
<style>
|
||||
* {{
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}}
|
||||
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background-color: #f5f5f5;
|
||||
padding: 20px;
|
||||
}}
|
||||
|
||||
.container {{
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}}
|
||||
|
||||
.header {{
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
}}
|
||||
|
||||
.header h1 {{
|
||||
color: #2c3e50;
|
||||
margin-bottom: 10px;
|
||||
font-size: 2.5em;
|
||||
}}
|
||||
|
||||
.header p {{
|
||||
color: #7f8c8d;
|
||||
font-size: 1.1em;
|
||||
}}
|
||||
|
||||
.files-container {{
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
}}
|
||||
|
||||
.files-header {{
|
||||
background: #3498db;
|
||||
color: white;
|
||||
padding: 20px;
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
}}
|
||||
|
||||
.file-item {{
|
||||
border-bottom: 1px solid #ecf0f1;
|
||||
padding: 20px;
|
||||
transition: background-color 0.2s;
|
||||
}}
|
||||
|
||||
.file-item:last-child {{
|
||||
border-bottom: none;
|
||||
}}
|
||||
|
||||
.file-item:hover {{
|
||||
background-color: #f8f9fa;
|
||||
}}
|
||||
|
||||
.file-link {{
|
||||
color: #2c3e50;
|
||||
text-decoration: none;
|
||||
font-size: 1.1em;
|
||||
font-weight: 500;
|
||||
}}
|
||||
|
||||
.file-link:hover {{
|
||||
color: #3498db;
|
||||
text-decoration: underline;
|
||||
}}
|
||||
|
||||
.file-meta {{
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
color: #7f8c8d;
|
||||
font-size: 0.9em;
|
||||
}}
|
||||
|
||||
.no-files {{
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: #7f8c8d;
|
||||
font-size: 1.1em;
|
||||
}}
|
||||
|
||||
.footer {{
|
||||
margin-top: 30px;
|
||||
text-align: center;
|
||||
color: #7f8c8d;
|
||||
font-size: 0.9em;
|
||||
}}
|
||||
|
||||
@media (max-width: 600px) {{
|
||||
.file-meta {{
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}}
|
||||
|
||||
.header h1 {{
|
||||
font-size: 2em;
|
||||
}}
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>📸 ParentZone Snapshots</h1>
|
||||
<p>Browse and view your downloaded snapshot files</p>
|
||||
</div>
|
||||
|
||||
<div class="files-container">
|
||||
<div class="files-header">
|
||||
📁 Available Snapshot Files ({len(html_files)} files)
|
||||
</div>
|
||||
{files_list}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>Served from: {self.snapshots_dir}</p>
|
||||
<p>Server running on {self.host}:{self.port}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
async def file_handler(self, request):
|
||||
"""Serve individual HTML files and their assets."""
|
||||
try:
|
||||
# Get the requested file path
|
||||
file_path = unquote(request.match_info["filename"])
|
||||
requested_file = self.snapshots_dir / file_path
|
||||
|
||||
# Security check: ensure the file is within the snapshots directory
|
||||
try:
|
||||
requested_file.resolve().relative_to(self.snapshots_dir.resolve())
|
||||
except ValueError:
|
||||
self.logger.warning(f"Attempted path traversal: {file_path}")
|
||||
return web.Response(
|
||||
text="<h1>403 Forbidden</h1><p>Access denied.</p>",
|
||||
status=403,
|
||||
content_type="text/html",
|
||||
)
|
||||
|
||||
# Check if file exists
|
||||
if not requested_file.exists():
|
||||
return web.Response(
|
||||
text="<h1>404 Not Found</h1><p>The requested file was not found.</p>",
|
||||
status=404,
|
||||
content_type="text/html",
|
||||
)
|
||||
|
||||
# Determine content type
|
||||
content_type = self._get_content_type(requested_file)
|
||||
|
||||
# Read and serve the file
|
||||
with open(requested_file, "rb") as f:
|
||||
content = f.read()
|
||||
|
||||
return web.Response(
|
||||
body=content,
|
||||
content_type=content_type,
|
||||
headers={
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
"Last-Modified": datetime.fromtimestamp(
|
||||
requested_file.stat().st_mtime
|
||||
).strftime("%a, %d %b %Y %H:%M:%S GMT"),
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Error serving file {request.match_info.get('filename', 'unknown')}: {e}"
|
||||
)
|
||||
return web.Response(
|
||||
text=f"<h1>500 Internal Server Error</h1><p>Could not serve file: {e}</p>",
|
||||
status=500,
|
||||
content_type="text/html",
|
||||
)
|
||||
|
||||
async def assets_handler(self, request):
|
||||
"""Serve asset files (images, CSS, JS, etc.) from assets subdirectories."""
|
||||
try:
|
||||
# Get the requested asset path
|
||||
asset_path = unquote(request.match_info["path"])
|
||||
requested_file = self.snapshots_dir / "assets" / asset_path
|
||||
|
||||
# Security check
|
||||
try:
|
||||
requested_file.resolve().relative_to(self.snapshots_dir.resolve())
|
||||
except ValueError:
|
||||
self.logger.warning(f"Attempted path traversal in assets: {asset_path}")
|
||||
return web.Response(text="403 Forbidden", status=403)
|
||||
|
||||
# Check if file exists
|
||||
if not requested_file.exists():
|
||||
return web.Response(text="404 Not Found", status=404)
|
||||
|
||||
# Determine content type
|
||||
content_type = self._get_content_type(requested_file)
|
||||
|
||||
# Read and serve the file
|
||||
with open(requested_file, "rb") as f:
|
||||
content = f.read()
|
||||
|
||||
return web.Response(
|
||||
body=content,
|
||||
content_type=content_type,
|
||||
headers={
|
||||
"Cache-Control": "public, max-age=86400", # Cache assets for 24 hours
|
||||
"Last-Modified": datetime.fromtimestamp(
|
||||
requested_file.stat().st_mtime
|
||||
).strftime("%a, %d %b %Y %H:%M:%S GMT"),
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Error serving asset {request.match_info.get('path', 'unknown')}: {e}"
|
||||
)
|
||||
return web.Response(text="500 Internal Server Error", status=500)
|
||||
|
||||
def _get_content_type(self, file_path: Path) -> str:
|
||||
"""Determine the content type based on file extension."""
|
||||
suffix = file_path.suffix.lower()
|
||||
|
||||
content_types = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
".pdf": "application/pdf",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
".log": "text/plain; charset=utf-8",
|
||||
}
|
||||
|
||||
return content_types.get(suffix, "application/octet-stream")
|
||||
|
||||
def setup_routes(self, app):
|
||||
"""Configure the web application routes."""
|
||||
# Main index page
|
||||
app.router.add_get("/", self.index_handler)
|
||||
|
||||
# Serve HTML files directly
|
||||
app.router.add_get("/{filename:.+\.html}", self.file_handler)
|
||||
|
||||
# Serve assets (images, CSS, JS, etc.)
|
||||
app.router.add_get("/assets/{path:.+}", self.assets_handler)
|
||||
|
||||
# Serve other static files (logs, etc.)
|
||||
app.router.add_get(
|
||||
"/{filename:.+\.(css|js|json|txt|log|ico)}", self.file_handler
|
||||
)
|
||||
|
||||
async def create_app(self):
|
||||
"""Create and configure the web application."""
|
||||
app = web.Application()
|
||||
|
||||
# Setup routes
|
||||
self.setup_routes(app)
|
||||
|
||||
# Add middleware for logging
|
||||
async def logging_middleware(request, handler):
|
||||
start_time = datetime.now()
|
||||
|
||||
try:
|
||||
response = await handler(request)
|
||||
|
||||
# Log the request
|
||||
duration = (datetime.now() - start_time).total_seconds()
|
||||
self.logger.info(
|
||||
f"{request.remote} - {request.method} {request.path} - "
|
||||
f"{response.status} - {duration:.3f}s"
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
duration = (datetime.now() - start_time).total_seconds()
|
||||
self.logger.error(
|
||||
f"{request.remote} - {request.method} {request.path} - "
|
||||
f"ERROR: {e} - {duration:.3f}s"
|
||||
)
|
||||
raise
|
||||
|
||||
app.middlewares.append(logging_middleware)
|
||||
|
||||
return app
|
||||
|
||||
async def start_server(self):
|
||||
"""Start the web server."""
|
||||
app = await self.create_app()
|
||||
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
|
||||
site = web.TCPSite(runner, self.host, self.port)
|
||||
await site.start()
|
||||
|
||||
self.logger.info(f"🚀 ParentZone Snapshots Web Server started!")
|
||||
self.logger.info(f"📂 Serving files from: {self.snapshots_dir}")
|
||||
self.logger.info(f"🌐 Server running at: http://{self.host}:{self.port}")
|
||||
self.logger.info(f"🔗 Open in browser: http://localhost:{self.port}")
|
||||
self.logger.info("Press Ctrl+C to stop the server")
|
||||
|
||||
return runner
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="ParentZone Snapshots Web Server",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Start server with default settings
|
||||
python webserver.py
|
||||
|
||||
# Start server on custom port
|
||||
python webserver.py --port 3000
|
||||
|
||||
# Serve from custom directory
|
||||
python webserver.py --snapshots-dir /path/to/snapshots
|
||||
|
||||
# Start server on all interfaces
|
||||
python webserver.py --host 0.0.0.0 --port 8080
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--snapshots-dir",
|
||||
default="./snapshots",
|
||||
help="Directory containing snapshot files (default: ./snapshots)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
type=int,
|
||||
default=8080,
|
||||
help="Port to run the server on (default: 8080)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--host",
|
||||
default="0.0.0.0",
|
||||
help="Host to bind the server to (default: 0.0.0.0)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Create and start the server
|
||||
server = SnapshotsWebServer(
|
||||
snapshots_dir=args.snapshots_dir, port=args.port, host=args.host
|
||||
)
|
||||
|
||||
async def run_server():
|
||||
runner = None
|
||||
try:
|
||||
runner = await server.start_server()
|
||||
|
||||
# Keep the server running
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n👋 Shutting down server...")
|
||||
except Exception as e:
|
||||
print(f"❌ Server error: {e}")
|
||||
finally:
|
||||
if runner:
|
||||
await runner.cleanup()
|
||||
|
||||
try:
|
||||
asyncio.run(run_server())
|
||||
except KeyboardInterrupt:
|
||||
print("\n✅ Server stopped")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user