# CLAUDE.md

## Project

Sitedrop — self-hosted static site hosting. Single Python package that serves as both the CLI client and the FastAPI server. Upload an HTML file, get a URL. Version 0.0.5 (defined in both `pyproject.toml` and `src/sitedrop/__init__.py` — keep them in sync). MIT licensed.

## Commands

```bash
pip install -e ".[dev]"    # install with dev dependencies
pytest                     # run tests (183 tests)
ruff check src/ tests/     # lint
ruff format src/ tests/    # format
```

There is no CI/CD pipeline, Makefile, or pre-commit configuration. Linting and testing are run manually.

## Architecture

```
src/sitedrop/
  __init__.py           # __version__ = "0.0.5"
  cli.py                # Click CLI entry point, register_commands() adds all 14 commands
  client/
    api.py              # SitedropClient — HTTP client with auto token refresh (5 min before expiry)
    config.py           # ClientConfig dataclass, saved to ~/.sitedroprc (chmod 600)
  commands/
    server_cmds.py      # setup, start, stop, status, restart, logs, backup, restore (server-side CLI)
    client_cmds.py      # login, upload, list, delete, password, info (client-side CLI)
  server/
    app.py              # create_app() factory, security headers middleware, SIGHUP log rotation, mounts routes
    auth.py             # bcrypt password hashing, JWT create/verify (HS256, 24h lifetime)
    config.py           # ServerConfig dataclass with config versioning, saved to ~/.sitedrop/server.json (chmod 600)
    models.py           # Pydantic request/response models (8 models)
    routes_api.py       # /api/* endpoints (auth, health, CRUD, rate limiting, password change)
    routes_sites.py     # /{name} catch-all for serving public HTML (no auth)
    storage.py          # SiteStorage — filesystem CRUD, name validation, stores as {name}.html
tests/
  conftest.py           # Shared fixtures (app, client, auth_headers, rate limiter clear)
  test_cli.py           # CLI command tests (63 tests)
  test_client_api.py    # SitedropClient tests (20 tests)
  test_client_config.py # ClientConfig save/load tests (8 tests)
  test_integration.py   # Full client→server integration tests with real HTTP (3 tests)
  test_routes_api.py    # API endpoint tests incl. rate limiting, security headers, CSP, SIGHUP (31 tests)
  test_routes_sites.py  # Site serving tests (13 tests)
  test_server_auth.py   # bcrypt, JWT tests (5 tests)
  test_server_config.py # ServerConfig save/load tests incl. config versioning (19 tests)
  test_server_storage.py # SiteStorage CRUD tests (22 tests)
```

## Dependencies

Runtime: `click>=8.1`, `fastapi>=0.104`, `uvicorn[standard]>=0.24`, `bcrypt>=4.0`, `PyJWT>=2.8`, `httpx>=0.25`. Build system: hatchling. Dev: `pytest>=7.0`, `pytest-asyncio>=0.23`, `ruff>=0.1`. Python 3.10–3.13.

## Key Implementation Details

### Auth Flow

1. `POST /api/auth` with password → bcrypt verify → JWT (HS256, 24h) signed with server's random 256-bit hex secret
2. Client stores token + plaintext password in `~/.sitedroprc` (chmod 600)
3. All `/api/*` endpoints (except `/api/auth` and `/api/health`) require `Authorization: Bearer <token>` via `require_auth()` dependency
4. Client auto-refreshes token 5 minutes before expiry by re-authenticating with stored password
5. JWT payload contains `sub` ("admin"), `exp`, and `iat` claims

### Rate Limiting

- Global dict `_auth_attempts` maps IP → list of timestamps
- 5 failed auth attempts per 60-second sliding window per IP → HTTP 429
- Expired entries pruned on each check
- Tests clear state via autouse `_clear_rate_limit` fixture in `conftest.py`

### Site Name Validation

- Regex: `^[a-zA-Z0-9][a-zA-Z0-9_-]*$`, max 240 characters
- Must start with alphanumeric; allows hyphens and underscores after first char
- Rejects dots, slashes, spaces, special characters
- Stored as `{sites_dir}/{name}.html` on filesystem

### Upload Limits

- Max file size: 10 MB (checked in `routes_api.py`)
- Content must be valid UTF-8 (returns 400 on decode failure)
- Files served as `text/html` via `HTMLResponse`

### Security Headers

Applied to all responses via middleware in `app.py`:
- `X-Content-Type-Options: nosniff`
- `X-Frame-Options: DENY`
- `Content-Security-Policy: default-src 'none'; frame-ancestors 'none'` (API responses only, not served sites)

### Caching (Served Sites)

- `ETag`: SHA-256 hash of HTML content, quoted per HTTP spec
- `Cache-Control: no-cache`: browser caches but must revalidate every request
- `If-None-Match` support: returns 304 Not Modified when ETag matches
- Implemented in `routes_sites.py`

### Log Rotation

- Server log at `~/.sitedrop/server.log` rotates to `server.log.1` when exceeding 5 MB
- Rotation happens at startup (in `start()`) and on SIGHUP (handler in `app.py`)
- SIGHUP handler also reopens stdout/stderr to the new log file via `os.dup2()`
- Best-effort: rotation failures are silently ignored

### Backup and Restore

- `sitedrop backup` creates a `.tar.gz` containing `server.json` and `sites/*.html`
- `sitedrop restore <file>` extracts manually (not `extractall()`) for safety
- Path traversal protection: rejects archive members with `..` or absolute paths
- Restore warns if server is running, prompts for confirmation unless `--yes`

### Error Handling

- **Client side:** All user-facing errors go through `SitedropError` (defined in `client/api.py`). Network errors, timeouts, and HTTP status codes are mapped to human-readable messages.
- **Server side:** Uses FastAPI `HTTPException`. `InvalidNameError` (from `storage.py`) maps to 400. Auth failures → 401. Overwrite conflict → 412. Large files → 413. Rate limit → 429. Unconfigured server → 500.

### Configuration

| Config | Path | Override env var | Permissions |
|--------|------|-----------------|-------------|
| Server | `~/.sitedrop/server.json` | `SITEDROP_CONFIG` | chmod 600 |
| Client | `~/.sitedroprc` | `SITEDROP_CLIENT_CONFIG` | chmod 600 |

Both use JSON serialization. Both `load()` methods ignore unknown fields for forward compatibility. Both `load()` methods fall back to defaults on corrupted JSON (logging a warning). Server config includes `config_version` field (currently 1) with a migration framework for future schema changes.

### API Endpoints

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| GET | `/api/health` | No | Health check, returns `{"status": "ok"}` |
| POST | `/api/auth` | No | Authenticate, returns JWT |
| GET | `/api/sites` | Yes | List all sites |
| PUT | `/api/sites/{name}` | Yes | Upload/update site (body = raw HTML, `If-None-Match: *` → 412 if exists) |
| DELETE | `/api/sites/{name}` | Yes | Delete a site |
| POST | `/api/password` | Yes | Change password (rotates JWT secret, returns new token) |
| GET | `/{name}` | No | Serve public HTML site |

### CLI Commands

Server management: `setup`, `start` (with `--dev` for foreground + reload, `--ssl-certfile`/`--ssl-keyfile` for HTTPS), `stop` (graceful SIGTERM with SIGKILL fallback, PID staleness detection), `restart` (stop + start), `status` (PID staleness detection), `logs` (with `-f` follow and `-n` line count), `backup` (with `-o` output path), `restore` (with `--yes`/`-y` to skip confirmation). Client operations: `login`, `upload` (with `--name`, `--force` to skip overwrite prompt), `list`, `delete` (with `--yes`/`-y` to skip confirmation), `password` (change server password, updates saved credentials and token), `info` (shows server URL and token status). Entry point: `sitedrop.cli:cli`. Background server logs write to `~/.sitedrop/server.log`.

## Conventions

- Python 3.10+. Use `from __future__ import annotations` in all source files.
- No docstrings on functions/methods unless the logic is non-obvious.
- Errors that reach users go through `SitedropError` (client) or `HTTPException` (server).
- Config files use JSON. Both server and client configs get `chmod 600`.
- Tests use pytest fixtures from `conftest.py`. Client tests mock httpx to route requests through `TestClient`.
- One test file per source module. Test functions are prefixed `test_`.
- Version is defined in both `pyproject.toml` and `src/sitedrop/__init__.py` — keep them in sync.

## Testing Notes

- 183 tests total across 9 test files.
- Rate limiter state (`_auth_attempts`) is cleared between tests via autouse fixture.
- Permission-error tests use `tempfile.mkstemp` mocking (not chmod), so they pass as root.
- Test secrets are >= 32 bytes to avoid PyJWT `InsecureKeyLengthWarning`.
- `conftest.py` provides: `tmp_sites_dir`, `server_config` (password: "testpass"), `app`, `client` (TestClient), `auth_headers`.
- `test_integration.py` uses a real uvicorn server in a daemon thread with synchronous httpx.
- No async tests despite `pytest-asyncio` being a dev dependency — all tests use synchronous `TestClient` or httpx.

## Known Issues & Planned Work

See `ROADMAP.md` for remaining nice-to-have items.
