# AGENTS.md — Image Server Project ## Project Overview A FastAPI-based image server that serves files from directories or ZIP archives using hashed paths for secure, randomized access. Supports password-protected ZIPs via HTTP Basic Auth. Provides a full-screen image viewer with navigation controls and auto-advance. ## Language & Runtime - **Language**: Python 3.13 - **Framework**: FastAPI 0.128.0+ - **Server**: Uvicorn 0.40.0+ - **Package manager**: UV ## Build, Lint, and Test Commands ### Running the Server ```bash # Run with a directory uv run main.py /path/to/files # Run with a ZIP archive uv run main.py /path/to/archive.zip # Run with a glob pattern uv run main.py "path/to/zips/*.zip" ``` ### Testing ```bash # Run all tests uv run pytest # Run a single test uv run pytest tests/test_file_indexer.py::TestClass::test_method # Run tests matching a pattern uv run pytest -k "test_name_pattern" ``` ### Linting ```bash # Install ruff (fast linter) uv add ruff # Run linting ruff check . # Auto-fix issues ruff check --fix . ``` ### Formatting Always format code with black after implementing every feature: ```bash # Install black uv add black # Format all code uv run black . ``` ### Type Checking ```bash # Install mypy uv add mypy # Run type checking uv run mypy main.py ``` ## Project Structure ``` image_server/ ├── main.py # Main application — all server logic, routing, indexing ├── frontend.html # Single-page HTML template for the image viewer UI ├── pyproject.toml # Project configuration (dependencies, metadata) ├── uv.lock # UV lockfile for reproducible builds ├── README.md # User-facing documentation ├── AGENTS.md # This file — agent/dev documentation └── tests/ # Test suite ├── conftest.py # Pytest fixtures and configuration ├── test_file_indexer.py # Tests for FileIndexer class ├── test_zip_indexer.py # Tests for ZipFileIndexer class ├── test_encrypted_zip.py # Tests for password-protected ZIP handling ├── test_endpoints.py # Tests for API endpoints ├── test_navigation.py # Tests for navigation logic └── testfile.zip # Test fixture ZIP file ``` ## Code Architecture ### Entry Point (`main.py`) All code lives in `main.py`. The app is initialized via `initialize_server()` which is called from `__main__`. ### Global State ```python file_mapping = {} # dict[str, str] — hash -> filepath/filename (merged across all indexers) indexers = [] # list[FileIndexer] — all active indexers ``` ### Indexer Classes #### `FileIndexer` (base class) Indexes files from a directory tree. - **`__init__(path: str, salt: str | None)`** — Initializes with a path and optional salt - **`salt` (property)** — Lazily generates a random salt if not provided - **`_hash_path(filepath: str) -> str`** — SHA-256 hash of `filepath + salt` - **`_index() -> dict[str, str]`** — Walks directory tree, builds hash -> filepath mapping - **`get_file_by_hash(file_hash: str)`** — Yields file content bytes (generator) - **`get_filename_by_hash(file_hash: str) -> str | None`** — Returns filepath by hash #### `ZipFileIndexer(FileIndexer)` Indexes files from a ZIP archive. Extends `FileIndexer`. - **`_open_zip()`** — Opens the ZIP file for reading - **`_index() -> dict[str, str]`** — Indexes files within the ZIP (hash -> internal filename) - **`_needs_password(filename: str) -> bool`** — Lazy detection of password protection (cached) - **`is_file_encrypted(file_hash: str) -> bool`** — Check if a file requires a password - **`get_file_by_hash(file_hash: str, password: bytes | None)`** — Yields file content, handles encryption - **`get_filename_by_hash(file_hash: str) -> str | None`** — Returns internal ZIP filename by hash #### `INDEXER_MAP` ```python INDEXER_MAP = {".zip": ZipFileIndexer} ``` Maps file extensions to indexer classes. Extend this dict to support new archive formats. ### Helper Functions - **`initialize_server(args: argparse.Namespace)`** — Bootstraps the server: creates indexers, populates `file_mapping` - **`_find_indexer_for_hash(file_hash: str)`** — Finds which indexer owns a given hash - **`_get_zip_password(file_hash: str, request: Request) -> bytes | None`** — Extracts password from HTTP Basic Auth header - **`_raise_unauthorized()`** — Raises HTTP 401 with `WWW-Authenticate: Basic` header - **`_get_random_hash() -> str`** — Returns a random hash from the indexed files - **`_build_url(file_hash, order, delay) -> str`** — Builds a URL with optional query params - **`_get_navigation_data(file_hash, order)`** — Returns dict with `file_hash`, `next_hash`, `prev_hash`, `filename` - **`_render_page(navigation_data, ...)`** — Renders `frontend.html` template with substituted values ### Routes | Route | Handler | Description | |-------|---------|-------------| | `GET /` | `root()` | Redirect to random file | | `GET /{file_hash}` | `hash_page()` | Serve image page with navigation | | `GET /api/{file_hash}/data` | `get_file_data()` | Serve raw file data | | `GET /api/health` | `health_check()` | Health check (status + file count) | ### Frontend Template (`frontend.html`) Single HTML file using `string.Template` substitution. Placeholders: - `$img_url` — URL to fetch the image data - `$image_click_url` — URL navigated to on image click - `$next_url` / `$prev_url` — Navigation URLs - `$filename` — Display filename - `$extra_meta` — Extra `` tags (e.g., auto-refresh) - `$play_button` — Play/pause button HTML ## Code Style Guidelines ### Imports - Standard library imports first, then third-party, then local - Group imports by type: stdlib, third-party, local - Use absolute imports for local modules ### Formatting Style - Use 4 spaces for indentation (no tabs) - Maximum line length: 88 characters (black default) - Use blank lines to separate logical sections (2 blank lines between top-level definitions) - One blank line between method definitions within a class - **Always run `black .` after completing every feature** ### Types - Use type hints for all function arguments and return values - Use Python 3.13+ typing features where appropriate ### Naming Conventions - **Classes**: `PascalCase` (e.g., `FileIndexer`, `ZipFileIndexer`) - **Functions/variables**: `snake_case` (e.g., `get_file_by_hash`, `file_mapping`) - **Constants**: `UPPER_SNAKE_CASE` (e.g., `INDEXER_MAP`) - **Private methods/attributes**: Prefix with underscore (e.g., `_index`, `_salt`) - Use descriptive, full words for names (avoid abbreviations except well-known ones) ### Error Handling - Use exceptions for exceptional cases, not flow control - Raise `HTTPException` for HTTP-level errors in FastAPI endpoints - Provide meaningful error messages ### Async/Await - Use `async def` for FastAPI endpoint handlers - Use regular functions for synchronous operations (file I/O, hashing) - Do not mix sync and async improperly ### File Operations - Use `pathlib.Path` for path manipulations - Use context managers (`with` statements) for file operations - Handle both directory and archive (ZIP) file sources ### Documentation - Use docstrings for public classes and functions - Follow Google-style docstring format ### Best Practices - Keep functions small and focused (single responsibility) - Use properties for computed attributes with caching - Avoid global state where possible; use dependency injection - Use dataclasses or attrs for simple data containers - Follow PEP 8 style guidelines ## Common Tasks ### Adding a New Endpoint 1. Add the route handler in `main.py` 2. Use appropriate HTTP method decorator (`@app.get`, `@app.post`, etc.) 3. Add type hints and docstrings 4. Handle errors with `HTTPException` ### Adding a New File Indexer 1. Create a new class inheriting from `FileIndexer` 2. Override the `_index` method to populate `self._file_mapping` 3. Override `get_file_by_hash` and `get_filename_by_hash` as needed 4. Register in `INDEXER_MAP` dictionary with the file extension as key ### Modifying the Frontend 1. Edit `frontend.html` for HTML/CSS/JS changes 2. Add new `string.Template` placeholders as needed 3. Update `_render_page()` to pass new values 4. Ensure navigation URLs are built correctly with `_build_url()` ### Running the Server in Development ```bash # Direct run (rebuilds on each start) uv run main.py /path/to/files # With custom options uv run main.py /path/to/files --host 127.0.0.1 --port 3000 --salt mysecret ```