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
File renamed without changes.
+15
-13
@@ -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 \
|
||||
@@ -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 <http://localhost:7474/>, 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 <http://localhost:7474/>. 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
|
||||
|
||||
@@ -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/<package>/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.
|
||||
|
||||
|
||||
@@ -6,13 +6,16 @@ Most wheels include their license files in ``*.dist-info``. For the ones that
|
||||
do not, copy its license files to ``<output>/<name>-<version>/``, taking them
|
||||
from ``<vendored>/<name>/`` 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 <output> <vendored>``.
|
||||
``/opt/pgadmin/bin/python collect-python-licenses.py <output> <vendored>``. 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
|
||||
File renamed without changes.
File renamed without changes.
@@ -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 |
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+23
-6
@@ -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():
|
||||
|
||||
+48
-50
@@ -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
|
||||
|
||||
@@ -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()
|
||||
+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)],
|
||||
)
|
||||
+2
-1
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user