diff --git a/frontend.html b/frontend.html
index 49fc0c8..93f48a5 100644
--- a/frontend.html
+++ b/frontend.html
@@ -105,7 +105,10 @@
window.location.href = buildUrl(params.hash, newOrder, params.delay);
}
} else if (e.key.toLowerCase() === 'n') {
- document.getElementById('toggle-link').click();
+ var link = document.getElementById('toggle-link');
+ if (link && link.getAttribute('href') !== '#') {
+ link.click();
+ }
}
});
diff --git a/main.py b/main.py
index c66eb90..5b4b483 100644
--- a/main.py
+++ b/main.py
@@ -22,6 +22,7 @@ app = FastAPI()
file_mapping: dict[str, str] = {}
indexers = []
sorted_hashes: list[str] = []
+navigate_enabled = False
class FileIndexer:
@@ -165,7 +166,8 @@ INDEXER_MAP = {".zip": ZipFileIndexer}
def initialize_server(args: argparse.Namespace):
"""Initialize the server with directory or glob indexing"""
- global file_mapping, indexers, sorted_hashes
+ global file_mapping, indexers, sorted_hashes, navigate_enabled
+ navigate_enabled = args.navigate
src_path = Path(args.source)
@@ -373,18 +375,21 @@ def _render_page(
# Build the toggle URL (alternate view mode)
file_path = navigation_data.get("filename", "")
- if use_navigate_urls:
- toggle_url = _build_url(
- navigation_data["file_hash"],
- order=current_order,
- delay=current_delay,
- )
+ if navigate_enabled:
+ if use_navigate_urls:
+ toggle_url = _build_url(
+ navigation_data["file_hash"],
+ order=current_order,
+ delay=current_delay,
+ )
+ else:
+ toggle_url = _build_navigate_url(
+ file_path,
+ order=current_order,
+ delay=current_delay,
+ )
else:
- toggle_url = _build_navigate_url(
- file_path,
- order=current_order,
- delay=current_delay,
- )
+ toggle_url = "#"
content = template.substitute(
img_url="/api/{file_hash}/data".format(file_hash=navigation_data["file_hash"]),
@@ -393,7 +398,7 @@ def _render_page(
prev_url=prev_url,
filename=navigation_data["filename"],
file_hash=navigation_data["file_hash"],
- file_path=file_path,
+ file_path=file_path if navigate_enabled else "",
toggle_url=toggle_url,
extra_meta=extra_meta,
play_button=play_button,
@@ -646,6 +651,9 @@ async def navigate_page(
order: Navigation order - 'next' for sequential, 'random' for random.
delay: Delay in seconds before auto-navigating to next file.
"""
+ if not navigate_enabled:
+ raise HTTPException(status_code=404, detail="File not found")
+
if order is not None and order not in ("next", "random"):
raise HTTPException(
status_code=400, detail="Invalid order. Must be 'next' or 'random'"
@@ -723,6 +731,11 @@ if __name__ == "__main__":
parser.add_argument(
"--salt", type=str, default=None, help="Salt for hashing file paths"
)
+ parser.add_argument(
+ "--navigate",
+ action="store_true",
+ help="Enable path-based navigation (/navigate/{path})",
+ )
args = parser.parse_args()
initialize_server(args)
diff --git a/tests/conftest.py b/tests/conftest.py
index 5d2d8b9..1335adb 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -17,6 +17,7 @@ def _reset_state() -> None:
main.file_mapping.clear()
main.indexers.clear()
main.sorted_hashes.clear()
+ main.navigate_enabled = False
@pytest.fixture(autouse=True)
@@ -84,6 +85,7 @@ def args_directory(sample_files: dict[str, Path], tmp_path: Path) -> argparse.Na
host="127.0.0.1",
port=0,
salt="test-salt",
+ navigate=False,
)
@@ -95,6 +97,21 @@ def args_zip(sample_zip: dict[str, Path], tmp_path: Path) -> argparse.Namespace:
host="127.0.0.1",
port=0,
salt="test-salt",
+ navigate=False,
+ )
+
+
+@pytest.fixture
+def args_zip_navigate(
+ sample_zip: dict[str, Path], tmp_path: Path
+) -> argparse.Namespace:
+ """Argparse namespace pointing at the sample zip with navigate enabled."""
+ return argparse.Namespace(
+ source=str(tmp_path / "test_archive.zip"),
+ host="127.0.0.1",
+ port=0,
+ salt="test-salt",
+ navigate=True,
)
@@ -126,6 +143,22 @@ async def client_zip(initialized_zip: None) -> Generator[AsyncClient]:
yield ac
+@pytest.fixture
+def initialized_zip_navigate(args_zip_navigate: argparse.Namespace) -> None:
+ """Initialize the server with sample zip files and navigate enabled."""
+ main.initialize_server(args_zip_navigate)
+
+
+@pytest.fixture
+async def client_zip_navigate(
+ initialized_zip_navigate: None,
+) -> Generator[AsyncClient]:
+ """Async HTTP client against the app initialized with zip + navigate."""
+ transport = ASGITransport(app=main.app)
+ async with AsyncClient(transport=transport, base_url="http://test") as ac:
+ yield ac
+
+
@pytest.fixture
def sample_encrypted_zip(tmp_path: Path) -> tuple[Path, dict[str, str], str]:
"""Copy the pre-existing testfile.zip to a temp location.
@@ -156,6 +189,7 @@ def args_encrypted_zip(
host="127.0.0.1",
port=0,
salt="test-salt",
+ navigate=False,
)
diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py
index 7cc7018..056a4e3 100644
--- a/tests/test_endpoints.py
+++ b/tests/test_endpoints.py
@@ -161,13 +161,13 @@ class TestHashPage:
assert response.status_code == 404
async def test_toggle_url_points_to_navigate_mode(
- self, client_zip: AsyncClient
+ self, client_zip_navigate: AsyncClient
) -> None:
"""Hash page toggle URL points to navigate-based URL."""
import re
file_hash = list(main.file_mapping.keys())[0]
- response = await client_zip.get(f"/{file_hash}")
+ response = await client_zip_navigate.get(f"/{file_hash}")
match = re.search(r'id="toggle-link" href="(/[^"]*?)"', response.text)
assert match is not None
toggle_href = match.group(1)
diff --git a/tests/test_navigate.py b/tests/test_navigate.py
index a01e63a..d72b04e 100644
--- a/tests/test_navigate.py
+++ b/tests/test_navigate.py
@@ -8,56 +8,62 @@ import main
class TestNavigatePage:
"""Tests for GET /navigate/{path}."""
- async def test_returns_html_page(self, client_zip: AsyncClient) -> None:
+ async def test_returns_html_page(self, client_zip_navigate: AsyncClient) -> None:
"""Returns an HTML page for a valid internal ZIP path."""
- response = await client_zip.get("/navigate/top.txt")
+ response = await client_zip_navigate.get("/navigate/top.txt")
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]
- async def test_page_contains_image_url(self, client_zip: AsyncClient) -> None:
+ async def test_page_contains_image_url(
+ self, client_zip_navigate: AsyncClient
+ ) -> None:
"""HTML page contains the image URL using hash-based /api endpoint."""
- response = await client_zip.get("/navigate/top.txt")
+ response = await client_zip_navigate.get("/navigate/top.txt")
# The image URL should use the hash-based /api endpoint
assert "/api/" in response.text
assert "/data" in response.text
async def test_page_contains_navigate_nav_links(
- self, client_zip: AsyncClient
+ self, client_zip_navigate: AsyncClient
) -> None:
"""HTML page contains /navigate/ URLs for prev and next navigation."""
- response = await client_zip.get("/navigate/top.txt")
+ response = await client_zip_navigate.get("/navigate/top.txt")
assert "/navigate/" in response.text
async def test_page_contains_prev_next_buttons(
- self, client_zip: AsyncClient
+ self, client_zip_navigate: AsyncClient
) -> None:
"""HTML page contains prev and next navigation buttons."""
- response = await client_zip.get("/navigate/top.txt")
+ response = await client_zip_navigate.get("/navigate/top.txt")
assert 'class="chevron left"' in response.text
assert 'class="chevron right"' in response.text
- async def test_page_contains_play_button(self, client_zip: AsyncClient) -> None:
+ async def test_page_contains_play_button(
+ self, client_zip_navigate: AsyncClient
+ ) -> None:
"""HTML page contains play button with /navigate query param URL."""
- response = await client_zip.get("/navigate/top.txt")
+ response = await client_zip_navigate.get("/navigate/top.txt")
assert "/navigate/" in response.text
assert "?order=next&delay=5" in response.text
- async def test_subdirectory_path(self, client_zip: AsyncClient) -> None:
+ async def test_subdirectory_path(self, client_zip_navigate: AsyncClient) -> None:
"""Paths with subdirectories work correctly."""
- response = await client_zip.get("/navigate/folder/deep.txt")
+ response = await client_zip_navigate.get("/navigate/folder/deep.txt")
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]
- async def test_returns_404_for_invalid_path(self, client_zip: AsyncClient) -> None:
+ async def test_returns_404_for_invalid_path(
+ self, client_zip_navigate: AsyncClient
+ ) -> None:
"""Returns 404 for a path that doesn't exist in the zip."""
- response = await client_zip.get("/navigate/nonexistent.txt")
+ response = await client_zip_navigate.get("/navigate/nonexistent.txt")
assert response.status_code == 404
async def test_returns_404_for_directory_path(
- self, client_zip: AsyncClient
+ self, client_zip_navigate: AsyncClient
) -> None:
"""Returns 404 for a directory path (not a file)."""
- response = await client_zip.get("/navigate/folder/")
+ response = await client_zip_navigate.get("/navigate/folder/")
assert response.status_code == 404
async def test_returns_404_on_directory_indexer(
@@ -67,35 +73,46 @@ class TestNavigatePage:
response = await client_dir.get("/navigate/some/path.txt")
assert response.status_code == 404
- async def test_page_has_data_hash_attribute(self, client_zip: AsyncClient) -> None:
- """HTML page has data-hash attribute on image for toggle support."""
+ async def test_returns_404_when_navigate_disabled(
+ self, client_zip: AsyncClient
+ ) -> None:
+ """Returns 404 when navigate flag is not enabled."""
response = await client_zip.get("/navigate/top.txt")
+ assert response.status_code == 404
+
+ async def test_page_has_data_hash_attribute(
+ self, client_zip_navigate: AsyncClient
+ ) -> None:
+ """HTML page has data-hash attribute on image for toggle support."""
+ response = await client_zip_navigate.get("/navigate/top.txt")
assert "data-hash=" in response.text
- async def test_page_has_data_path_attribute(self, client_zip: AsyncClient) -> None:
+ async def test_page_has_data_path_attribute(
+ self, client_zip_navigate: AsyncClient
+ ) -> None:
"""HTML page has data-path attribute on image for toggle support."""
- response = await client_zip.get("/navigate/top.txt")
+ response = await client_zip_navigate.get("/navigate/top.txt")
assert 'data-path="top.txt"' in response.text
async def test_toggle_url_points_to_hash_mode(
- self, client_zip: AsyncClient
+ self, client_zip_navigate: AsyncClient
) -> None:
"""Navigate page toggle URL points to hash-based URL."""
import re
- response = await client_zip.get("/navigate/top.txt")
+ response = await client_zip_navigate.get("/navigate/top.txt")
match = re.search(r'id="toggle-link" href="(/[^/][^"]*?)"', response.text)
assert match is not None
toggle_href = match.group(1)
assert not toggle_href.startswith("/navigate/")
async def test_toggle_url_preserves_query_params(
- self, client_zip: AsyncClient
+ self, client_zip_navigate: AsyncClient
) -> None:
"""Navigate page toggle URL preserves order/delay query params."""
import re
- response = await client_zip.get(
+ response = await client_zip_navigate.get(
"/navigate/top.txt", params={"order": "next", "delay": 5}
)
match = re.search(r'id="toggle-link" href="(/[^"]*?)"', response.text)
@@ -108,41 +125,49 @@ class TestNavigatePage:
class TestNavigatePageWithRefresh:
"""Tests for GET /navigate/{path}?order=...&delay=... (auto-refresh mode)."""
- async def test_next_order_returns_html(self, client_zip: AsyncClient) -> None:
+ async def test_next_order_returns_html(
+ self, client_zip_navigate: AsyncClient
+ ) -> None:
"""Next order returns HTML with refresh meta tag."""
- response = await client_zip.get(
+ response = await client_zip_navigate.get(
"/navigate/top.txt", params={"order": "next", "delay": 5}
)
assert response.status_code == 200
assert 'http-equiv="refresh"' in response.text
- async def test_random_order_returns_html(self, client_zip: AsyncClient) -> None:
+ async def test_random_order_returns_html(
+ self, client_zip_navigate: AsyncClient
+ ) -> None:
"""Random order returns HTML with refresh meta tag."""
- response = await client_zip.get(
+ response = await client_zip_navigate.get(
"/navigate/top.txt", params={"order": "random", "delay": 3}
)
assert response.status_code == 200
assert 'http-equiv="refresh"' in response.text
- async def test_invalid_order_returns_400(self, client_zip: AsyncClient) -> None:
+ async def test_invalid_order_returns_400(
+ self, client_zip_navigate: AsyncClient
+ ) -> None:
"""Invalid order parameter returns 400."""
- response = await client_zip.get(
+ response = await client_zip_navigate.get(
"/navigate/top.txt", params={"order": "shuffle", "delay": 5}
)
assert response.status_code == 400
- async def test_returns_404_for_invalid_path(self, client_zip: AsyncClient) -> None:
+ async def test_returns_404_for_invalid_path(
+ self, client_zip_navigate: AsyncClient
+ ) -> None:
"""Returns 404 for a path that doesn't exist."""
- response = await client_zip.get(
+ response = await client_zip_navigate.get(
"/navigate/nonexistent.txt", params={"order": "next", "delay": 5}
)
assert response.status_code == 404
async def test_refresh_url_uses_navigate_path(
- self, client_zip: AsyncClient
+ self, client_zip_navigate: AsyncClient
) -> None:
"""Refresh meta tag uses /navigate/ URLs with paths."""
- response = await client_zip.get(
+ response = await client_zip_navigate.get(
"/navigate/top.txt", params={"order": "next", "delay": 5}
)
assert "/navigate/" in response.text
@@ -150,10 +175,10 @@ class TestNavigatePageWithRefresh:
assert "delay=5" in response.text
async def test_pause_button_uses_navigate_url(
- self, client_zip: AsyncClient
+ self, client_zip_navigate: AsyncClient
) -> None:
"""Pause button links to /navigate/{path} without query params."""
- response = await client_zip.get(
+ response = await client_zip_navigate.get(
"/navigate/top.txt", params={"order": "next", "delay": 5}
)
assert '/navigate/top.txt" class="play-btn"' in response.text
diff --git a/tests/test_navigation.py b/tests/test_navigation.py
index c2e6ece..af98f11 100644
--- a/tests/test_navigation.py
+++ b/tests/test_navigation.py
@@ -16,6 +16,7 @@ def seeded_indexers(sample_files: dict[str, Path], tmp_path: Path) -> None:
host="127.0.0.1",
port=0,
salt="nav-test-salt",
+ navigate=False,
)
main.initialize_server(args)