Skip to content

Commit 93f7842

Browse files
committed
fix: normalize neuroglancer public urls
1 parent 30976cb commit 93f7842

6 files changed

Lines changed: 142 additions & 12 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ Optional runtime environment variables:
3434
```
3535
PYTC_AUTH_SECRET=replace-me
3636
PYTC_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,null
37+
PYTC_NEUROGLANCER_PUBLIC_BASE=http://localhost:4244
3738
```
3839

3940
If restarting after a crash or interrupted session, kill any lingering processes first:

client/src/views/Visualization.js

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,17 @@
1-
import React, { useState, useContext, useEffect } from "react";
1+
import React, { useState } from "react";
22
import { Button, Tabs, Input, Space, Typography, message } from "antd";
33
import {
44
ArrowRightOutlined,
55
InboxOutlined,
66
ReloadOutlined,
77
} from "@ant-design/icons";
8-
import { AppContext } from "../contexts/GlobalContext";
98
import { getNeuroglancerViewer } from "../api";
109
import UnifiedFileInput from "../components/UnifiedFileInput";
1110

1211
const { Title } = Typography;
1312

1413
function Visualization(props) {
1514
const { viewers, setViewers } = props;
16-
const context = useContext(AppContext);
1715
const [activeKey, setActiveKey] = useState(
1816
viewers.length > 0 ? viewers[0].key : null,
1917
);
@@ -24,9 +22,6 @@ function Visualization(props) {
2422
const [scales, setScales] = useState("30,6,6");
2523
const [isLoading, setIsLoading] = useState(false);
2624

27-
// Update file list options - No longer needed for UnifiedFileInput but keeping context access
28-
const { files } = context;
29-
3025
const handleImageChange = (value) => {
3126
console.log(`selected image:`, value);
3227
setCurrentImage(value);
@@ -84,8 +79,7 @@ function Visualization(props) {
8479
scalesArray,
8580
);
8681

87-
const newUrl = res.replace(/\/\/[^:/]+/, "//localhost");
88-
console.log("Current Viewer at ", newUrl);
82+
console.log("Current Viewer at ", res);
8983

9084
// Extract name from path for title
9185
const getImageName = (val) => {
@@ -102,7 +96,7 @@ function Visualization(props) {
10296
title:
10397
getImageName(currentImage) +
10498
(currentLabel ? " & " + getImageName(currentLabel) : ""),
105-
viewer: newUrl,
99+
viewer: res,
106100
},
107101
];
108102

runtime_settings.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import os
22
import secrets
3+
from typing import Optional
34

45
DEFAULT_ALLOWED_ORIGINS = (
56
"http://localhost:3000",
@@ -23,3 +24,10 @@ def get_allowed_origins() -> list[str]:
2324

2425
def get_auth_secret() -> str:
2526
return _AUTH_SECRET
27+
28+
29+
def get_neuroglancer_public_base() -> Optional[str]:
30+
raw_base = os.getenv("PYTC_NEUROGLANCER_PUBLIC_BASE", "").strip()
31+
if not raw_base:
32+
return None
33+
return raw_base.rstrip("/")

server_api/main.py

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,17 @@
44
import shutil
55
import tempfile
66
from typing import List, Optional
7+
from urllib.parse import urlsplit, urlunsplit
78

89
import requests
910
import uvicorn
1011
from fastapi import Depends, FastAPI, HTTPException, Request, UploadFile
1112
from fastapi.middleware.cors import CORSMiddleware
1213
from sqlalchemy.orm import Session
13-
from runtime_settings import get_allowed_origins
14+
from runtime_settings import (
15+
get_allowed_origins,
16+
get_neuroglancer_public_base,
17+
)
1418
from server_api.utils.io import readVol
1519
from server_api.utils.utils import process_path
1620
from server_api.auth import models, database, router as auth_router
@@ -94,6 +98,42 @@ def _worker_url(path: str) -> str:
9498
return f"{REACT_APP_SERVER_PROTOCOL}://{REACT_APP_SERVER_URL}{path}"
9599

96100

101+
def _derive_neuroglancer_public_base(request: Request) -> str:
102+
configured_base = get_neuroglancer_public_base()
103+
if configured_base:
104+
return configured_base
105+
106+
forwarded_proto = request.headers.get("x-forwarded-proto")
107+
scheme = (forwarded_proto.split(",")[0].strip() if forwarded_proto else "") or (
108+
request.url.scheme or "http"
109+
)
110+
111+
forwarded_host = request.headers.get("x-forwarded-host")
112+
request_host = (
113+
forwarded_host.split(",")[0].strip() if forwarded_host else request.url.netloc
114+
)
115+
hostname = request_host.split(":")[0] or "localhost"
116+
return f"{scheme}://{hostname}:4244"
117+
118+
119+
def _build_neuroglancer_public_url(viewer_url: str, request: Request) -> str:
120+
viewer_parts = urlsplit(viewer_url)
121+
base_parts = urlsplit(_derive_neuroglancer_public_base(request))
122+
base_path = base_parts.path.rstrip("/")
123+
viewer_path = viewer_parts.path or ""
124+
combined_path = f"{base_path}{viewer_path}" if base_path else viewer_path
125+
126+
return urlunsplit(
127+
(
128+
base_parts.scheme or "http",
129+
base_parts.netloc,
130+
combined_path,
131+
viewer_parts.query,
132+
viewer_parts.fragment,
133+
)
134+
)
135+
136+
97137
def _extract_upstream_payload(response: requests.Response):
98138
try:
99139
return response.json()
@@ -394,8 +434,9 @@ def ngLayer(data, res, oo=[0, 0, 0], tt="segmentation"):
394434
if gt is not None:
395435
s.layers.append(name="gt", layer=ngLayer(gt, res, tt="segmentation"))
396436

397-
print(viewer)
398-
return str(viewer)
437+
public_url = _build_neuroglancer_public_url(str(viewer), req)
438+
print(public_url)
439+
return public_url
399440
finally:
400441
for path in cleanup_paths:
401442
try:
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import unittest
2+
from unittest.mock import patch
3+
4+
from starlette.requests import Request
5+
6+
from server_api.main import _build_neuroglancer_public_url
7+
8+
9+
def make_request(*, host="localhost:4242", scheme="http", extra_headers=None):
10+
headers = [(b"host", host.encode("utf-8"))]
11+
for key, value in (extra_headers or {}).items():
12+
headers.append((key.lower().encode("utf-8"), value.encode("utf-8")))
13+
14+
scope = {
15+
"type": "http",
16+
"http_version": "1.1",
17+
"method": "POST",
18+
"scheme": scheme,
19+
"path": "/neuroglancer",
20+
"raw_path": b"/neuroglancer",
21+
"query_string": b"",
22+
"headers": headers,
23+
"server": ("localhost", 4242),
24+
"client": ("127.0.0.1", 12345),
25+
}
26+
return Request(scope)
27+
28+
29+
class NeuroglancerUrlContractTests(unittest.TestCase):
30+
def test_default_public_url_uses_request_host_with_neuroglancer_port(self):
31+
request = make_request(host="localhost:4242", scheme="http")
32+
33+
public_url = _build_neuroglancer_public_url(
34+
"http://0.0.0.0:4244/v/abc123?foo=1",
35+
request,
36+
)
37+
38+
self.assertEqual(public_url, "http://localhost:4244/v/abc123?foo=1")
39+
40+
def test_public_base_env_prefixes_viewer_path(self):
41+
request = make_request(host="localhost:4242", scheme="http")
42+
43+
with patch.dict(
44+
"os.environ",
45+
{"PYTC_NEUROGLANCER_PUBLIC_BASE": "https://viewer.example.com/ng"},
46+
clear=True,
47+
):
48+
public_url = _build_neuroglancer_public_url(
49+
"http://0.0.0.0:4244/v/demo",
50+
request,
51+
)
52+
53+
self.assertEqual(public_url, "https://viewer.example.com/ng/v/demo")
54+
55+
def test_forwarded_headers_override_request_scheme_and_host(self):
56+
request = make_request(
57+
host="localhost:4242",
58+
scheme="http",
59+
extra_headers={
60+
"x-forwarded-proto": "https",
61+
"x-forwarded-host": "viewer.internal:4242",
62+
},
63+
)
64+
65+
public_url = _build_neuroglancer_public_url(
66+
"http://0.0.0.0:4244/v/proxy",
67+
request,
68+
)
69+
70+
self.assertEqual(public_url, "https://viewer.internal:4244/v/proxy")
71+
72+
73+
if __name__ == "__main__":
74+
unittest.main()

tests/test_runtime_settings.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,18 @@ def test_auth_secret_uses_env_when_present(self):
3838

3939
self.assertEqual(module.get_auth_secret(), "test-secret")
4040

41+
def test_neuroglancer_public_base_uses_env_when_present(self):
42+
with patch.dict(
43+
os.environ,
44+
{"PYTC_NEUROGLANCER_PUBLIC_BASE": "https://viewer.example.com/ng/"},
45+
clear=True,
46+
):
47+
module = importlib.reload(runtime_settings)
48+
self.assertEqual(
49+
module.get_neuroglancer_public_base(),
50+
"https://viewer.example.com/ng",
51+
)
52+
4153

4254
if __name__ == "__main__":
4355
unittest.main()

0 commit comments

Comments
 (0)