Use Starlette, uvicorn and websockets; rename Docker files to container ones
Serve neo4j-browser with Starlette and uvicorn (the optional browser extra) instead of a hand-written HTTP server, and handle Bolt over WebSocket with the websockets library's Sans-I/O protocol. Rename Dockerfile, .dockerignore and docker/ to Containerfile, .containerignore and container/. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3e8e498b56
commit
f0fb9ddc74
18 files changed
+189
-162
No files matched your search
+44
-76
@@ -1,86 +1,54 @@
|
||||
# SPDX-FileCopyrightText: 2026 Bart Broere
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Tiny HTTP server for the neo4j-browser static files and Neo4j discovery."""
|
||||
"""HTTP server for the neo4j-browser static files and Neo4j discovery.
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlsplit
|
||||
Needs the ``browser`` extra: ``pip install 'peo[browser]'``.
|
||||
"""
|
||||
|
||||
from .backend import NEO4J_VERSION
|
||||
from starlette.applications import Starlette
|
||||
from starlette.datastructures import MutableHeaders
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.responses import RedirectResponse, Response
|
||||
from starlette.routing import Mount, Route
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
log = logging.getLogger("peo.http")
|
||||
from .discovery import discovery_document
|
||||
|
||||
|
||||
class HttpServer:
|
||||
def __init__(self, browser_dir: str, bolt_port: int):
|
||||
self.root = Path(browser_dir).resolve()
|
||||
self.bolt_port = bolt_port
|
||||
class BrowserFiles(StaticFiles):
|
||||
"""neo4j-browser's build: index.html must be revalidated, the hashed assets can be cached."""
|
||||
|
||||
async def handle(self, reader, writer):
|
||||
try:
|
||||
request = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), timeout=30)
|
||||
lines = request.decode("latin-1").split("\r\n")
|
||||
method, target, _ = (lines[0].split(" ") + ["", "", ""])[:3]
|
||||
headers = {}
|
||||
for line in lines[1:]:
|
||||
k, _, v = line.partition(":")
|
||||
headers[k.strip().lower()] = v.strip()
|
||||
status, extra, body = self.respond(method, target, headers)
|
||||
head = [f"HTTP/1.1 {status}", f"Content-Length: {len(body)}", "Connection: close",
|
||||
"X-Content-Type-Options: nosniff"]
|
||||
head += [f"{k}: {v}" for k, v in extra.items()]
|
||||
writer.write(("\r\n".join(head) + "\r\n\r\n").encode("latin-1"))
|
||||
if method != "HEAD":
|
||||
writer.write(body)
|
||||
await writer.drain()
|
||||
except (asyncio.IncompleteReadError, asyncio.LimitOverrunError, asyncio.TimeoutError,
|
||||
ConnectionResetError, BrokenPipeError):
|
||||
pass
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("HTTP request failed")
|
||||
finally:
|
||||
writer.close()
|
||||
|
||||
def respond(self, method, target, headers):
|
||||
if method not in ("GET", "HEAD"):
|
||||
return "405 Method Not Allowed", {"Allow": "GET, HEAD"}, b""
|
||||
path = unquote(urlsplit(target).path)
|
||||
|
||||
if path in ("/", ""):
|
||||
if "text/html" in headers.get("accept", ""):
|
||||
return "302 Found", {"Location": "/browser/"}, b""
|
||||
return "200 OK", {"Content-Type": "application/json"}, discovery_document(headers, self.bolt_port)
|
||||
|
||||
if path == "/browser":
|
||||
return "301 Moved Permanently", {"Location": "/browser/"}, b""
|
||||
if not path.startswith("/browser/"):
|
||||
return "404 Not Found", {"Content-Type": "text/plain"}, b"Not found\n"
|
||||
|
||||
file = (self.root / path[len("/browser/"):]).resolve()
|
||||
if file.is_dir():
|
||||
file = file / "index.html"
|
||||
if not file.is_relative_to(self.root) or not file.is_file():
|
||||
return "404 Not Found", {"Content-Type": "text/plain"}, b"Not found\n"
|
||||
ctype = mimetypes.guess_type(file.name)[0] or "application/octet-stream"
|
||||
cache = "no-cache" if file.name.endswith(".html") else "public, max-age=86400"
|
||||
return "200 OK", {"Content-Type": ctype, "Cache-Control": cache}, file.read_bytes()
|
||||
def file_response(self, full_path, *args, **kwargs):
|
||||
response = super().file_response(full_path, *args, **kwargs)
|
||||
response.headers["Cache-Control"] = ("no-cache" if str(full_path).endswith(".html")
|
||||
else "public, max-age=86400")
|
||||
return response
|
||||
|
||||
|
||||
def discovery_document(headers, bolt_port) -> bytes:
|
||||
"""The JSON Neo4j serves at ``/`` so clients can find the Bolt endpoint."""
|
||||
host = headers.get("host") or "localhost"
|
||||
if host.startswith("["): # IPv6 literal, e.g. [::1]:7474
|
||||
host = host[:host.index("]") + 1]
|
||||
else:
|
||||
host = host.split(":", 1)[0]
|
||||
port = os.environ.get("PEO_ADVERTISED_BOLT_PORT", str(bolt_port))
|
||||
return json.dumps({
|
||||
"bolt_routing": f"neo4j://{host}:{port}",
|
||||
"bolt_direct": f"bolt://{host}:{port}",
|
||||
"neo4j_version": NEO4J_VERSION,
|
||||
"neo4j_edition": "community",
|
||||
}).encode()
|
||||
def nosniff(app):
|
||||
async def wrapped(scope, receive, send):
|
||||
async def send_with_header(message):
|
||||
if message["type"] == "http.response.start":
|
||||
MutableHeaders(scope=message)["X-Content-Type-Options"] = "nosniff"
|
||||
await send(message)
|
||||
await app(scope, receive, send_with_header)
|
||||
return wrapped
|
||||
|
||||
|
||||
def create_app(browser_dir: str, bolt_port: int) -> Starlette:
|
||||
async def root(request):
|
||||
if "text/html" in request.headers.get("accept", ""):
|
||||
return RedirectResponse("/browser/", status_code=302)
|
||||
return Response(discovery_document(request.headers, bolt_port), media_type="application/json")
|
||||
|
||||
async def browser(request):
|
||||
return RedirectResponse("/browser/", status_code=301)
|
||||
|
||||
return Starlette(
|
||||
routes=[
|
||||
Route("/", root),
|
||||
Route("/browser", browser),
|
||||
Mount("/browser", BrowserFiles(directory=browser_dir, html=True)),
|
||||
],
|
||||
middleware=[Middleware(nosniff)],
|
||||
)
|
||||
Reference in new issue
Block a user