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_generatedonly when status becomesready.
```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.
/loginand/healthare 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.