Documentation Serving Routes

docsfy serves generated documentation files through two authenticated /docs route patterns:

Route pattern Purpose Variant selection
/docs/{project}/{provider}/{model}/{path} Serve a specific provider/model variant Explicit (provider + model)
/docs/{project}/{path} Serve the most recently generated ready variant Automatic (last_generated DESC, ready-only)

Warning: Route declaration order matters. The variant-specific route must be registered before the generic /docs/{project}/{path} route, or variant URLs can be matched by the generic handler.

```1377:1435:src/docsfy/main.py

IMPORTANT: variant-specific route MUST be defined BEFORE the generic route

so FastAPI matches it first.

@app.get("/docs/{project}/{provider}/{model}/{path:path}") async def serve_variant_docs( request: Request, project: str, provider: str, model: str, path: str = "index.html", ) -> FileResponse: if not path or path == "/": path = "index.html" project = _validate_project_name(project) proj = await _resolve_project( request, project, ai_provider=provider, ai_model=model )

proj_owner = str(proj.get("owner", ""))
site_dir = get_project_site_dir(project, provider, model, proj_owner)
file_path = site_dir / path
try:
    file_path.resolve().relative_to(site_dir.resolve())
except ValueError as exc:
    raise HTTPException(status_code=403, detail="Access denied") from exc
if not file_path.exists() or not file_path.is_file():
    raise HTTPException(status_code=404, detail="File not found")
return FileResponse(file_path)

@app.get("/docs/{project}/{path:path}") async def serve_docs( request: Request, project: str, path: str = "index.html" ) -> FileResponse: """Serve the most recently generated variant.""" if not path or path == "/": path = "index.html" project = _validate_project_name(project) if request.state.is_admin: latest = await get_latest_variant(project) else: latest = await get_latest_variant(project, owner=request.state.username) if not latest: raise HTTPException(status_code=404, detail="No docs available") await _check_ownership(request, project, latest) latest_owner = str(latest.get("owner", "")) site_dir = get_project_site_dir( project, str(latest["ai_provider"]), str(latest["ai_model"]), latest_owner, ) file_path = site_dir / path try: file_path.resolve().relative_to(site_dir.resolve()) except ValueError as exc: raise HTTPException(status_code=403, detail="Access denied") from exc if not file_path.exists() or not file_path.is_file(): raise HTTPException(status_code=404, detail="File not found") return FileResponse(file_path)

## Variant-Specific Serving (`/docs/{project}/{provider}/{model}/{path}`)

This route serves files from an explicit variant directory.

- Normalizes empty path or `/` to `index.html`.
- Resolves variant with `_resolve_project(...)`.
- Builds site directory with owner scoping (`get_project_site_dir(...)`).
- Blocks path traversal with `resolve().relative_to(...)`.
- Returns `404 File not found` if the file is missing.

Variant resolution behavior:

```210:261:src/docsfy/main.py
async def _resolve_project(
    request: Request,
    name: str,
    ai_provider: str,
    ai_model: str,
) -> dict[str, Any]:
    """Find a project variant, preferring the requesting user's owned copy.

    Raises 404 if not found or not accessible.
    """
    # 1. Try owned by requesting user
    if not request.state.is_admin:
        proj = await get_project(
            name,
            ai_provider=ai_provider,
            ai_model=ai_model,
            owner=request.state.username,
        )
        if proj:
            return proj

    # 2. For admin, disambiguate by owner
    if request.state.is_admin:
        all_variants = await list_variants(name)
        matching = [
            v
            for v in all_variants
            if v.get("ai_provider") == ai_provider and v.get("ai_model") == ai_model
        ]
        if not matching:
            raise HTTPException(status_code=404, detail="Not found")
        distinct_owners = {str(v.get("owner", "")) for v in matching}
        if len(distinct_owners) > 1:
            raise HTTPException(
                status_code=409,
                detail="Multiple owners found for this variant, please specify owner",
            )
        return matching[0]

    # 3. For non-admin, check granted access — find which owner granted access
    accessible = await get_user_accessible_projects(request.state.username)
    for proj_name, proj_owner in accessible:
        if proj_name == name and proj_owner:
            # Found a grant — look up this specific owner's variant
            proj = await get_project(
                name, ai_provider=ai_provider, ai_model=ai_model, owner=proj_owner
            )
            if proj:
                return proj

    # 4. Not found
    raise HTTPException(status_code=404, detail="Not found")

Note: Variant-specific serving can resolve owned variants and access-granted variants for non-admin users.

Latest-Ready Serving (/docs/{project}/{path})

This route automatically picks the latest ready variant.

  • Uses get_latest_variant(...).
  • Only considers status='ready'.
  • Orders by last_generated DESC.
  • Sets last_generated only when status becomes ready.

```295:330:src/docsfy/storage.py async def update_project_status( name: str, ai_provider: str, ai_model: str, status: str, owner: str | None = None, last_commit_sha: str | None = None, page_count: int | None = None, error_message: str | None = None, plan_json: str | None = None, current_stage: str | None | object = _UNSET, ) -> None: ... if status == "ready": fields.append("last_generated = CURRENT_TIMESTAMP") ...

```552:569:src/docsfy/storage.py
async def get_latest_variant(
    name: str, owner: str | None = None
) -> dict[str, str | int | None] | None:
    """Get the most recently generated ready variant for a repo."""
    async with aiosqlite.connect(DB_PATH) as db:
        db.row_factory = aiosqlite.Row
        if owner is not None:
            cursor = await db.execute(
                "SELECT * FROM projects WHERE name = ? AND owner = ? AND status = 'ready' ORDER BY last_generated DESC LIMIT 1",
                (name, owner),
            )
        else:
            cursor = await db.execute(
                "SELECT * FROM projects WHERE name = ? AND status = 'ready' ORDER BY last_generated DESC LIMIT 1",
                (name,),
            )
        row = await cursor.fetchone()
        return dict(row) if row else None

Ordering is explicitly tested:

```378:392:tests/test_storage.py

Manually set last_generated to ensure deterministic ordering

(CURRENT_TIMESTAMP may resolve to the same second for both rows)

async with aiosqlite.connect(DB_PATH) as db: await db.execute( "UPDATE projects SET last_generated = '2025-01-01 00:00:00' WHERE ai_provider = 'claude'" ) await db.execute( "UPDATE projects SET last_generated = '2025-01-02 00:00:00' WHERE ai_provider = 'gemini'" ) await db.commit()

latest = await get_latest_variant("repo") assert latest is not None

gemini has a later last_generated timestamp

assert latest["ai_provider"] == "gemini"

> **Warning:** For non-admin users, latest-route selection is owner-scoped (`owner=request.state.username`). If you rely on shared/access-granted projects, use the variant-specific `/docs/{project}/{provider}/{model}/...` route.

## What Files Are Served Under `/docs`

The serving routes can return any generated file in the variant site directory, including HTML pages, markdown sources, search index JSON, LLM text files, and static assets.

```215:233:src/docsfy/renderer.py
def render_site(plan: dict[str, Any], pages: dict[str, str], output_dir: Path) -> None:
    if output_dir.exists():
        shutil.rmtree(output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)
    assets_dir = output_dir / "assets"
    assets_dir.mkdir(exist_ok=True)

    # Prevent GitHub Pages from running Jekyll
    (output_dir / ".nojekyll").touch()

    ...
    if STATIC_DIR.exists():
        for static_file in STATIC_DIR.iterdir():
            if static_file.is_file():
                shutil.copy2(static_file, assets_dir / static_file.name)

```243:290:src/docsfy/renderer.py index_html = render_index(project_name, tagline, navigation, repo_url=repo_url) (output_dir / "index.html").write_text(index_html, encoding="utf-8")

... (output_dir / f"{slug}.html").write_text(page_html, encoding="utf-8") (output_dir / f"{slug}.md").write_text(md_content, encoding="utf-8")

search_index = _build_search_index(valid_pages, plan) (output_dir / "search-index.json").write_text( json.dumps(search_index), encoding="utf-8" )

Generate llms.txt files

llms_txt = _build_llms_txt(plan) (output_dir / "llms.txt").write_text(llms_txt, encoding="utf-8")

llms_full_txt = _build_llms_full_txt(plan, valid_pages) (output_dir / "llms-full.txt").write_text(llms_full_txt, encoding="utf-8")

Templates link these files with relative paths, which are compatible with `/docs/...` static serving:

```8:10:src/docsfy/templates/page.html
<link rel="stylesheet" href="#"
<link rel="alternate" type="text/plain" title="LLM Documentation Index" href="#"
<link rel="alternate" type="text/plain" title="LLM Full Documentation" href="#"

Authentication and Safety

Docs routes are protected by the same auth middleware as the rest of the app.

  • /login and /health are public.
  • Non-authenticated browser requests are redirected to /login.
  • Non-authenticated API requests return 401.

```108:115:src/docsfy/main.py

Paths that do not require authentication

_PUBLIC_PATHS = frozenset({"/login", "/login/", "/health"})

async def dispatch( self, request: Request, call_next: RequestResponseEndpoint ) -> Response: if request.url.path in self._PUBLIC_PATHS: return await call_next(request)

```151:155:src/docsfy/main.py
if not user and not is_admin:
    # Not authenticated
    if request.url.path.startswith("/api/"):
        return JSONResponse(status_code=401, content={"detail": "Unauthorized"})
    return RedirectResponse(url="/login", status_code=302)

Project and filesystem path safety checks:

```73:77:src/docsfy/main.py def validate_project_name(name: str) -> str: """Validate project name to prevent path traversal.""" if not _re.match(r"^[a-zA-Z0-9][a-zA-Z0-9.-]*$", name): raise HTTPException(status_code=400, detail=f"Invalid project name: '{name}'") return name

```1396:1402:src/docsfy/main.py
file_path = site_dir / path
try:
    file_path.resolve().relative_to(site_dir.resolve())
except ValueError as exc:
    raise HTTPException(status_code=403, detail="Access denied") from exc
if not file_path.exists() or not file_path.is_file():
    raise HTTPException(status_code=404, detail="File not found")

URL Construction for Provider/Model

Provider/model values should be URL-encoded in links. The UI templates already do this.

```1483:1484:src/docsfy/templates/dashboard.html View Docs Download

```1188:1190:src/docsfy/templates/status.html
var viewBtn = document.createElement('a');
viewBtn.href="#" + encodeURIComponent(PROJECT_NAME) + '/' + encodeURIComponent(PROJECT_PROVIDER) + '/' + encodeURIComponent(PROJECT_MODEL) + '/';
viewBtn.target = '_blank';

```16:22:src/docsfy/config.py admin_key: str = "" # Required — validated at startup ai_provider: str = "claude" ai_model: str = "claude-opus-4-6[1m]" # [1m] = 1 million token context window ai_cli_timeout: int = Field(default=60, gt=0) log_level: str = "INFO" data_dir: str = "/data" secure_cookies: bool = True # Set to False for local HTTP dev

> **Tip:** Keep route construction encoded, especially for model names containing characters like `[` and `]` (for example `claude-opus-4-6[1m]`).

## Verified Behavior and Ops Configuration

Integration tests cover both serving paths:

```124:136:tests/test_integration.py
# Check docs are served via variant-specific route
response = await client.get("/docs/test-repo/claude/opus/index.html")
assert response.status_code == 200
assert "test-repo" in response.text

response = await client.get("/docs/test-repo/claude/opus/introduction.html")
assert response.status_code == 200
assert "Welcome!" in response.text

# Check docs are served via latest-variant route
response = await client.get("/docs/test-repo/index.html")
assert response.status_code == 200
assert "test-repo" in response.text

Deployment and test pipeline snippets relevant to /docs serving:

```1:13:docker-compose.yaml services: docsfy: build: . ports: - "8000:8000" env_file: .env volumes: - ./data:/data healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/health"] interval: 30s timeout: 10s retries: 3

```1:7:tox.toml
skipsdist = true

envlist = ["unittests"]

[env.unittests]
deps = ["uv"]
commands = [["uv", "run", "--extra", "dev", "pytest", "-n", "auto", "tests"]]

Note: No repository-level GitHub/GitLab/Jenkins workflow files are present; automated validation in this repo is defined via local/CI-friendly tooling (tox, pytest, pre-commit) plus container health checks.