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)