diff --git a/.dockerignore b/.containerignore similarity index 100% rename from .dockerignore rename to .containerignore diff --git a/Dockerfile b/Containerfile similarity index 83% rename from Dockerfile rename to Containerfile index d56a063..9c0288c 100644 --- a/Dockerfile +++ b/Containerfile @@ -46,11 +46,19 @@ RUN python3 -m venv /opt/pgadmin \ # License notices: see THIRD_PARTY_NOTICES.md. neo4j-browser is GPL-3.0, so its # complete source is shipped next to the license. -COPY docker/collect-python-licenses.py /tmp/ -COPY docker/licenses/ /tmp/licenses/ +COPY container/collect-python-licenses.py /tmp/ +COPY container/licenses/ /tmp/licenses/ RUN /opt/pgadmin/bin/python /tmp/collect-python-licenses.py \ - /usr/share/doc/peo/pgadmin-python-licenses /tmp/licenses \ - && rm -r /tmp/collect-python-licenses.py /tmp/licenses + /usr/share/doc/peo/pgadmin-python-licenses /tmp/licenses + +# peo itself, using Debian's psycopg and the other dependencies from PyPI. +COPY pyproject.toml README.md LICENSE /tmp/peo/ +COPY peo /tmp/peo/peo +RUN python3 -m venv --system-site-packages /opt/peo/venv \ + && /opt/peo/venv/bin/pip install --no-cache-dir "/tmp/peo[browser]" \ + && /opt/peo/venv/bin/python /tmp/collect-python-licenses.py \ + /usr/share/doc/peo/peo-python-licenses /tmp/licenses \ + && rm -r /tmp/peo /tmp/collect-python-licenses.py /tmp/licenses COPY LICENSE THIRD_PARTY_NOTICES.md /usr/share/doc/peo/ COPY --from=browser /src/LICENSE /src/NOTICE.txt /src/LICENSES.txt /neo4j-browser-source.tar.gz \ /usr/share/doc/peo/neo4j-browser/ @@ -59,15 +67,9 @@ ADD https://raw.githubusercontent.com/apache/age/${AGE_SOURCE_REF}/LICENSE \ /usr/share/doc/peo/apache-age/ COPY --from=browser /src/dist /opt/peo/browser -# peo itself, using Debian's psycopg. -COPY pyproject.toml README.md LICENSE /tmp/peo/ -COPY peo /tmp/peo/peo -RUN python3 -m venv --system-site-packages /opt/peo/venv \ - && /opt/peo/venv/bin/pip install --no-cache-dir --no-deps /tmp/peo \ - && rm -r /tmp/peo -COPY docker/initdb/ /docker-entrypoint-initdb.d/ -COPY docker/pgadmin/config_distro.py /opt/peo/pgadmin/config_distro.py -COPY docker/peo-entrypoint.sh /usr/local/bin/peo-entrypoint.sh +COPY container/initdb/ /docker-entrypoint-initdb.d/ +COPY container/pgadmin/config_distro.py /opt/peo/pgadmin/config_distro.py +COPY container/peo-entrypoint.sh /usr/local/bin/peo-entrypoint.sh ENV PEO_DEFAULT_GRAPH=neo4j \ PEO_BROWSER_DIR=/opt/peo/browser \ diff --git a/README.md b/README.md index 6cb92e4..eab8540 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,17 @@ The `binary` extra pulls in psycopg's prebuilt wheels; leave it out to use a psy built against your system's libpq. `python -m peo` works the same as `peo`. Neo4j clients can now connect to `neo4j://localhost:7687` with a PostgreSQL user name -and password. peo does not include neo4j-browser itself: build it and point -`PEO_BROWSER_DIR` at its `dist` directory to serve it on , or -use the container image. +and password. + +peo does not include neo4j-browser itself. To serve it, install the `browser` extra +([Starlette](https://www.starlette.io/) and [uvicorn](https://www.uvicorn.org/)), +build neo4j-browser and point `PEO_BROWSER_DIR` at its `dist` directory; it is then +served on . The container image does all of this for you. + +```sh +pip install 'peo[binary,browser]' +PEO_PG_HOST=localhost PEO_PG_DATABASE=postgres PEO_BROWSER_DIR=/path/to/neo4j-browser/dist peo +``` ## Configuration @@ -48,7 +56,7 @@ peo is configured through environment variables: | `PEO_DEFAULT_GRAPH` | `neo4j` | Graph used when a client does not pick a database. | | `PEO_BOLT_PORT` | `7687` | Port the Bolt server listens on (TCP and WebSocket). | | `PEO_HTTP_PORT` | `7474` | Port neo4j-browser is served on, when `PEO_BROWSER_DIR` is set. | -| `PEO_BROWSER_DIR` | (unset) | neo4j-browser `dist` directory to serve. | +| `PEO_BROWSER_DIR` | (unset) | neo4j-browser `dist` directory to serve; needs the `browser` extra. | | `PEO_ADVERTISED_BOLT_PORT` | `$PEO_BOLT_PORT` | Bolt port advertised to the browser; set it when clients reach the Bolt port on another port. | | `PEO_ADVERTISED_HOST` | `localhost` | Host reported in `SHOW DATABASES` and to clients that do not send a routing address. | | `PEO_LOG_LEVEL` | `INFO` | `DEBUG` logs every generated SQL statement. | @@ -151,7 +159,9 @@ pgAdmin comes with the bundled PostgreSQL server already registered as "peo"; it asks for the PostgreSQL password the first time you open it. AGE stores each graph as a schema of the same name, with one table per label. -`docker` works as a drop-in replacement for `podman` in the commands above. +Any OCI-compatible build tool works. With Docker, pass the file name explicitly: +`docker build -f Containerfile -t peo .`; `docker run` takes the same options as +`podman run`. Besides peo's own variables above, the image uses these: @@ -186,11 +196,12 @@ This is a first version. Things that do not work (yet): ## Development -peo's only dependency is `psycopg` 3. To work on it, install it in editable mode; -`python -m build` (or `uv build`) builds an sdist and wheel: +peo depends on `psycopg` 3 and `websockets`, plus Starlette and uvicorn for the +`browser` extra. To work on it, install it in editable mode; `python -m build` (or +`uv build`) builds an sdist and wheel: ```sh -pip install -e '.[binary]' +pip install -e '.[binary,browser]' ``` [`tests/smoke_test.py`](tests/smoke_test.py) runs end-to-end checks through the official diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index ce9d7fc..b229ae3 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,7 +1,7 @@ # Third-party notices -peo's own code (the `peo/`, `docker/` and `tests/` directories, the -`Dockerfile` and `pyproject.toml`) is licensed under the GNU General Public +peo's own code (the `peo/`, `container/` and `tests/` directories, the +`Containerfile` and `pyproject.toml`) is licensed under the GNU General Public License v3.0 or later; see [LICENSE](LICENSE). The container image built from this repository also contains the third-party @@ -11,11 +11,12 @@ the image is built. Inside the image, license texts are collected under | Component | License | Copyright | License text in the image | | --- | --- | --- | --- | -| [neo4j-browser](https://github.com/neo4j/neo4j-browser), built unmodified from the commit pinned in the `Dockerfile` | GPL-3.0-or-later | Neo4j Sweden AB | `/usr/share/doc/peo/neo4j-browser/` (`LICENSE`, `NOTICE.txt`) | +| [neo4j-browser](https://github.com/neo4j/neo4j-browser), built unmodified from the commit pinned in the `Containerfile` | GPL-3.0-or-later | Neo4j Sweden AB | `/usr/share/doc/peo/neo4j-browser/` (`LICENSE`, `NOTICE.txt`) | | JavaScript libraries bundled into neo4j-browser | various (MIT, Apache-2.0, BSD, ...) | their respective authors | `/usr/share/doc/peo/neo4j-browser/LICENSES.txt` | | [Apache AGE](https://github.com/apache/age), from the `apache/age` base image | Apache-2.0 | The Apache Software Foundation; portions Bitnine Co., Ltd. | `/usr/share/doc/peo/apache-age/` (`LICENSE`, `NOTICE`) | | [PostgreSQL](https://www.postgresql.org/), from the `apache/age` base image | PostgreSQL License | The PostgreSQL Global Development Group, The Regents of the University of California | `/usr/share/doc/postgresql-18/copyright` | | Debian GNU/Linux base system and packages, including Python 3 and `python3-psycopg` (LGPL-3.0) | various | their respective authors | `/usr/share/doc//copyright` | +| Python packages peo depends on, installed from PyPI: `websockets` (BSD-3-Clause), `starlette` (BSD-3-Clause), `uvicorn` (BSD-3-Clause) and their dependencies | various (BSD-3-Clause, MIT, PSF-2.0) | their respective authors | `*.dist-info/` under `/opt/peo/venv/lib/python3*/site-packages/`, or `/usr/share/doc/peo/peo-python-licenses/` for packages whose wheel lacks a license file | | [pgAdmin 4](https://www.pgadmin.org/), installed from PyPI | PostgreSQL License | The pgAdmin Development Team | `/opt/pgadmin/app/LICENSE` | | Python packages pgAdmin depends on, and `gunicorn` (MIT) | various | their respective authors | `*.dist-info/` under `/opt/pgadmin/lib/python3*/site-packages/`, or `/usr/share/doc/peo/pgadmin-python-licenses/` for packages whose wheel lacks a license file | @@ -26,9 +27,10 @@ the image is built. Inside the image, license texts are collected under it as `/usr/share/doc/peo/neo4j-browser/neo4j-browser-source.tar.gz`, an archive of the exact commit that was built. Its dependencies are pinned by the `yarn.lock` inside that archive, and the build steps are in this - repository's `Dockerfile`. + repository's `Containerfile`. - **peo** itself ships as Python source code in the `/opt/peo/venv/` - virtual environment. This repository has the rest. + virtual environment, next to the Python packages it depends on. This + repository has the rest. - **Debian packages:** source is available from [sources.debian.org](https://sources.debian.org/) and the Debian archive. diff --git a/docker/collect-python-licenses.py b/container/collect-python-licenses.py similarity index 89% rename from docker/collect-python-licenses.py rename to container/collect-python-licenses.py index dc94990..dc15f9b 100644 --- a/docker/collect-python-licenses.py +++ b/container/collect-python-licenses.py @@ -6,13 +6,16 @@ Most wheels include their license files in ``*.dist-info``. For the ones that do not, copy its license files to ``/-/``, taking them from ``//`` when present and otherwise from the source distribution on PyPI. Run with the interpreter of the environment to inspect: -``/opt/pgadmin/bin/python collect-python-licenses.py ``. +``/opt/pgadmin/bin/python collect-python-licenses.py ``. Only +the environment's own site-packages are inspected, not the system packages a +``--system-site-packages`` venv can see: those come with Debian copyright files. """ import io import json import re import sys +import sysconfig import tarfile import urllib.request import zipfile @@ -58,7 +61,8 @@ def vendored_files(vendored, name): def main(output, vendored): output, vendored = Path(output), Path(vendored) missing = [] - for dist in sorted(metadata.distributions(), key=lambda d: d.metadata["Name"].lower()): + site_packages = sysconfig.get_paths()["purelib"] + for dist in sorted(metadata.distributions(path=[site_packages]), key=lambda d: d.metadata["Name"].lower()): if has_license_file(dist): continue name, version = dist.metadata["Name"], dist.version diff --git a/docker/initdb/10-peo.sh b/container/initdb/10-peo.sh similarity index 100% rename from docker/initdb/10-peo.sh rename to container/initdb/10-peo.sh diff --git a/docker/licenses/Flask-Principal/LICENSE b/container/licenses/Flask-Principal/LICENSE similarity index 100% rename from docker/licenses/Flask-Principal/LICENSE rename to container/licenses/Flask-Principal/LICENSE diff --git a/docker/licenses/README.md b/container/licenses/README.md similarity index 87% rename from docker/licenses/README.md rename to container/licenses/README.md index c5039d2..b6fdc65 100644 --- a/docker/licenses/README.md +++ b/container/licenses/README.md @@ -1,7 +1,7 @@ # Vendored license texts License files for Python packages that pgAdmin depends on whose PyPI releases do -not include one. `docker/collect-python-licenses.py` copies them into the image. +not include one. `container/collect-python-licenses.py` copies them into the image. Directory names are the PyPI project names. | Package | License | Source of the text | diff --git a/docker/licenses/dnspython/LICENSE b/container/licenses/dnspython/LICENSE similarity index 100% rename from docker/licenses/dnspython/LICENSE rename to container/licenses/dnspython/LICENSE diff --git a/docker/licenses/ua-parser-builtins/LICENSE b/container/licenses/ua-parser-builtins/LICENSE similarity index 100% rename from docker/licenses/ua-parser-builtins/LICENSE rename to container/licenses/ua-parser-builtins/LICENSE diff --git a/docker/licenses/ua-parser-builtins/uap-core-LICENSE b/container/licenses/ua-parser-builtins/uap-core-LICENSE similarity index 100% rename from docker/licenses/ua-parser-builtins/uap-core-LICENSE rename to container/licenses/ua-parser-builtins/uap-core-LICENSE diff --git a/docker/peo-entrypoint.sh b/container/peo-entrypoint.sh similarity index 100% rename from docker/peo-entrypoint.sh rename to container/peo-entrypoint.sh diff --git a/docker/pgadmin/config_distro.py b/container/pgadmin/config_distro.py similarity index 100% rename from docker/pgadmin/config_distro.py rename to container/pgadmin/config_distro.py diff --git a/peo/__main__.py b/peo/__main__.py index c24a269..cdb0af5 100644 --- a/peo/__main__.py +++ b/peo/__main__.py @@ -6,10 +6,10 @@ import asyncio import functools import logging import os +import sys from .backend import Config from .bolt import handle_client -from .web import HttpServer def env(name, default): @@ -20,6 +20,17 @@ async def main(): logging.basicConfig(level=env("PEO_LOG_LEVEL", "INFO").upper(), format="%(asctime)s %(levelname)s %(name)s: %(message)s") log = logging.getLogger("peo") + logging.getLogger("websockets").setLevel(logging.WARNING) + + browser_dir = env("PEO_BROWSER_DIR", "") + if browser_dir: + try: + import uvicorn + + from .web import create_app + except ImportError as e: + sys.exit(f"peo: PEO_BROWSER_DIR is set, but serving neo4j-browser needs the browser extra " + f"(pip install 'peo[browser]'): {e}") bolt_port = int(env("PEO_BOLT_PORT", "7687")) http_port = int(env("PEO_HTTP_PORT", "7474")) @@ -33,17 +44,23 @@ async def main(): ) bolt = await asyncio.start_server(functools.partial(handle_client, config=config), "0.0.0.0", bolt_port) - servers = [bolt] + tasks = [bolt.serve_forever()] log.info("Bolt listening on :%d (PostgreSQL %s:%d/%s, default graph %r)", bolt_port, config.pg_host, config.pg_port, config.pg_database, config.default_graph) - browser_dir = env("PEO_BROWSER_DIR", "") if browser_dir: - http = HttpServer(browser_dir, bolt_port) - servers.append(await asyncio.start_server(http.handle, "0.0.0.0", http_port)) + http = uvicorn.Server(uvicorn.Config(create_app(browser_dir, bolt_port), host="0.0.0.0", + port=http_port, log_config=None, log_level="warning", + access_log=False, lifespan="off")) + tasks.append(http.serve()) log.info("neo4j-browser on http://localhost:%d/browser/", http_port) - await asyncio.gather(*(s.serve_forever() for s in servers)) + # Stop when either server stops, e.g. uvicorn after a signal. + done, pending = await asyncio.wait(map(asyncio.ensure_future, tasks), return_when=asyncio.FIRST_COMPLETED) + for task in pending: + task.cancel() + for task in done: + task.result() def run(): diff --git a/peo/bolt.py b/peo/bolt.py index 310644f..d6df905 100644 --- a/peo/bolt.py +++ b/peo/bolt.py @@ -7,16 +7,18 @@ by neo4j-browser as well as current Python/Java drivers and cypher-shell. """ import asyncio -import base64 -import hashlib import itertools import logging import struct +from websockets.frames import CloseCode, Opcode +from websockets.protocol import State +from websockets.server import ServerProtocol + from . import packstream from .backend import BoltFailure, Config, Session +from .discovery import discovery_document from .packstream import Structure -from .web import discovery_document log = logging.getLogger("peo.bolt") @@ -61,11 +63,14 @@ class RawTransport: class WebSocketTransport(RawTransport): - """Minimal RFC 6455 server: Bolt bytes are carried in binary frames.""" + """Bolt over WebSocket: Bolt bytes are carried in binary frames. - GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + Uses the Sans-I/O protocol of the websockets library, as this port also speaks raw Bolt. + """ async def handshake(self): + # Read the request head here rather than in websockets, which only accepts GET: + # neo4j-browser also sends plain HTTP requests (discovery, CORS preflight) to this port. request = bytearray(self.buffer) self.buffer.clear() while b"\r\n\r\n" not in request: @@ -73,21 +78,26 @@ class WebSocketTransport(RawTransport): if not chunk or len(request) > 65536: raise ConnectionClosed() request += chunk - head, _, rest = bytes(request).partition(b"\r\n\r\n") + head = bytes(request).partition(b"\r\n\r\n")[0] headers = {} for line in head.decode("latin-1").split("\r\n")[1:]: k, _, v = line.partition(":") headers[k.strip().lower()] = v.strip() method = head.split(b" ", 1)[0] - key = headers.get("sec-websocket-key") - if method != b"GET" or not key: + if method != b"GET" or "websocket" not in headers.get("upgrade", "").lower(): await self._plain_http(method, headers) raise ConnectionClosed() - accept = base64.b64encode(hashlib.sha1((key + self.GUID).encode()).digest()).decode() - self.writer.write(("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n" - f"Connection: Upgrade\r\nSec-WebSocket-Accept: {accept}\r\n\r\n").encode()) - await self.writer.drain() - self.raw = bytearray(rest) + + self.protocol = ServerProtocol(max_size=None) + self.protocol.receive_data(bytes(request)) + events = self.protocol.events_received() + if not events: + raise ConnectionClosed() + response = self.protocol.accept(events[0]) + self.protocol.send_response(response) + await self._flush() + if response.status_code != 101: + raise ConnectionClosed() async def _plain_http(self, method, headers): """Answer non-WebSocket requests, e.g. neo4j-browser probing for discovery data.""" @@ -104,52 +114,39 @@ class WebSocketTransport(RawTransport): self.writer.write(response) await self.writer.drain() - async def _raw(self, n): - while len(self.raw) < n: - chunk = await self.reader.read(65536) - if not chunk: - raise ConnectionClosed() - self.raw += chunk - data = bytes(self.raw[:n]) - del self.raw[:n] - return data + async def _flush(self): + for data in self.protocol.data_to_send(): + if data: + self.writer.write(data) + await self.writer.drain() async def read(self, n): while len(self.buffer) < n: - b0, b1 = await self._raw(2) - opcode, masked, length = b0 & 0x0F, b1 & 0x80, b1 & 0x7F - if length == 126: - length = struct.unpack(">H", await self._raw(2))[0] - elif length == 127: - length = struct.unpack(">Q", await self._raw(8))[0] - mask = await self._raw(4) if masked else None - payload = await self._raw(length) - if mask: - payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) - if opcode == 0x8: - await self._frame(0x8, payload[:2]) + for frame in self.protocol.events_received(): + if frame.opcode in (Opcode.CONT, Opcode.TEXT, Opcode.BINARY): + self.buffer += frame.data + await self._flush() # pongs and the reply to a close frame + if len(self.buffer) >= n: + break + if self.protocol.state is not State.OPEN: raise ConnectionClosed() - if opcode == 0x9: - await self._frame(0xA, payload) - elif opcode in (0x0, 0x1, 0x2): - self.buffer += payload + chunk = await self.reader.read(65536) + if not chunk: + raise ConnectionClosed() + self.protocol.receive_data(chunk) data = bytes(self.buffer[:n]) del self.buffer[:n] return data - async def _frame(self, opcode, payload): - n = len(payload) - if n < 126: - header = struct.pack(">BB", 0x80 | opcode, n) - elif n < 0x10000: - header = struct.pack(">BBH", 0x80 | opcode, 126, n) - else: - header = struct.pack(">BBQ", 0x80 | opcode, 127, n) - self.writer.write(header + payload) - await self.writer.drain() - async def write(self, data): - await self._frame(0x2, data) + self.protocol.send_binary(data) + await self._flush() + + async def close(self): + if self.protocol.state is State.OPEN: + self.protocol.send_close(CloseCode.NORMAL_CLOSURE) + await self._flush() + self.writer.close() # -- protocol ----------------------------------------------------------------------- @@ -364,6 +361,7 @@ async def handle_client(reader, writer, config: Config): else: return await BoltConnection(transport, config).serve() + await transport.close() except (ConnectionClosed, asyncio.IncompleteReadError, ConnectionResetError, BrokenPipeError): pass except Exception: # noqa: BLE001 diff --git a/peo/discovery.py b/peo/discovery.py new file mode 100644 index 0000000..750a2b3 --- /dev/null +++ b/peo/discovery.py @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: 2026 Bart Broere +# SPDX-License-Identifier: GPL-3.0-or-later +"""The Neo4j discovery document, served over HTTP on both the browser and the Bolt port.""" + +import json +import os + +from .backend import NEO4J_VERSION + + +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() diff --git a/peo/web.py b/peo/web.py index 0762a7b..0e96f58 100644 --- a/peo/web.py +++ b/peo/web.py @@ -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)], + ) diff --git a/pyproject.toml b/pyproject.toml index d11561e..a2501b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ license = "GPL-3.0-or-later" license-files = ["LICENSE"] authors = [{ name = "Bart Broere" }] requires-python = ">=3.11" -dependencies = ["psycopg>=3.1"] +dependencies = ["psycopg>=3.1", "websockets>=13"] keywords = ["neo4j", "bolt", "cypher", "postgresql", "apache-age", "graph"] classifiers = [ "Development Status :: 3 - Alpha", @@ -23,6 +23,7 @@ classifiers = [ [project.optional-dependencies] binary = ["psycopg[binary]>=3.1"] +browser = ["starlette>=0.37", "uvicorn>=0.29"] [project.urls] Source = "https://git.bartbroe.re/bartbroere/peo"