feat(admin): drop setup_banner feature; consolidate into single editor
Remove the setup_banner feature (admin-editable /setup page banner) and all associated code: API router, repository, renderer, admin template, tests, and docs. The setup_page handler no longer calls render_setup_banner; the install.html template no longer renders banner_html. The setup_banner DuckDB table (v22) is kept intact for forward-compat with already-migrated instances — only the application code is removed. CHANGELOG updated: setup_banner bullets removed; Agent Setup Prompt (welcome-template feature) now stands alone as the single editable prompt.
This commit is contained in:
parent
0ee22f8fb0
commit
c7b14fb120
14 changed files with 7 additions and 1295 deletions
|
|
@ -12,12 +12,10 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Admin-editable banner on `/setup` page — admins can author a Jinja2/HTML banner displayed above the auto-generated bootstrap commands. Empty by default (no banner shown). Edit at `/admin/setup-banner`. Endpoints: `GET /api/admin/setup-banner` (returns content + audit), `PUT` to set, `DELETE` to clear, `POST /api/admin/setup-banner/preview` for live preview. Useful for org-specific notes: VPN requirements, support channel, data classification, platform prerequisites. See `docs/setup-banner.md` for the full placeholder reference and security notes.
|
- **Agent Setup Prompt** — admins can customise the `CLAUDE.md` generated for analysts by `da analyst setup`. Default ships at `config/claude_md_template.txt` (Jinja2 syntax). Edit at `/admin/agent-prompt` or via REST: `GET /api/admin/welcome-template` returns `{content, default, updated_at, updated_by}`; `PUT` to set; `DELETE` to clear; `POST /api/admin/welcome-template/preview` for live preview without persisting. `GET /api/welcome` returns the prompt rendered for the calling user (RBAC-filtered `marketplaces`). See `docs/agent-setup-prompt.md` for the full placeholder reference.
|
||||||
- DuckDB schema v22: `setup_banner` singleton table for the per-instance banner. Auto-migration v21→v22 on first start.
|
|
||||||
- Customizable analyst welcome prompt (`CLAUDE.md` generated by `da analyst setup`). Default ships at `config/claude_md_template.txt` (now Jinja2 syntax). Admins override per instance via the `/admin/welcome` editor or `PUT /api/admin/welcome-template`. New endpoint `GET /api/welcome` returns the rendered prompt for the calling user, with `marketplaces` filtered by RBAC. See `docs/welcome-template.md` for the full placeholder reference.
|
|
||||||
- `POST /api/admin/welcome-template/preview` — renders arbitrary template content against the calling admin's live context without persisting. Backs the editor's Preview button.
|
|
||||||
- DuckDB schema v21: `welcome_template` singleton table for the per-instance override. Auto-migration v20→v21 on first start.
|
- DuckDB schema v21: `welcome_template` singleton table for the per-instance override. Auto-migration v20→v21 on first start.
|
||||||
- New `instance.sync_interval` setting in `instance.yaml` (default `"1 hour"`) — surfaced in the welcome prompt as `{{ sync_interval }}`.
|
- DuckDB schema v22: reserved (`setup_banner` table retained for forward compatibility with already-migrated instances; feature dropped).
|
||||||
|
- New `instance.sync_interval` setting in `instance.yaml` (default `"1 hour"`) — surfaced in the agent setup prompt as `{{ sync_interval }}`.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
"""REST endpoints for the setup-page banner.
|
|
||||||
|
|
||||||
- GET /api/admin/setup-banner : raw content + audit info (admin)
|
|
||||||
- PUT /api/admin/setup-banner : set banner (admin)
|
|
||||||
- DELETE /api/admin/setup-banner : clear banner (admin)
|
|
||||||
- POST /api/admin/setup-banner/preview : preview arbitrary content (admin)
|
|
||||||
"""
|
|
||||||
|
|
||||||
import datetime
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import duckdb
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
|
||||||
from jinja2 import Environment, StrictUndefined, TemplateError
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
from app.auth.access import require_admin
|
|
||||||
from app.auth.dependencies import _get_db
|
|
||||||
from src.repositories.setup_banner import SetupBannerRepository
|
|
||||||
from src.setup_banner import build_setup_banner_context
|
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(tags=["setup-banner"])
|
|
||||||
|
|
||||||
# Stub context used to validate that a saved template renders end-to-end,
|
|
||||||
# not just that it parses. Mirrors the shape of build_setup_banner_context() output.
|
|
||||||
_VALIDATION_STUB_CONTEXT = {
|
|
||||||
"instance": {"name": "Example", "subtitle": "Example Org"},
|
|
||||||
"server": {"url": "https://example.com", "hostname": "example.com"},
|
|
||||||
"user": {"id": "u", "email": "user@example.com", "name": "User", "is_admin": False},
|
|
||||||
"now": datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc),
|
|
||||||
"today": "2026-01-01",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class BannerGetResponse(BaseModel):
|
|
||||||
content: Optional[str]
|
|
||||||
updated_at: Optional[str] = None
|
|
||||||
updated_by: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class BannerPutRequest(BaseModel):
|
|
||||||
content: str = Field(..., min_length=1, max_length=200_000)
|
|
||||||
|
|
||||||
|
|
||||||
class BannerPreviewRequest(BaseModel):
|
|
||||||
content: str = Field(..., min_length=1, max_length=200_000)
|
|
||||||
|
|
||||||
|
|
||||||
class BannerPreviewResponse(BaseModel):
|
|
||||||
content: str
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/admin/setup-banner", response_model=BannerGetResponse)
|
|
||||||
async def admin_get_banner(
|
|
||||||
user: dict = Depends(require_admin),
|
|
||||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
|
||||||
):
|
|
||||||
row = SetupBannerRepository(conn).get()
|
|
||||||
return BannerGetResponse(
|
|
||||||
content=row["content"],
|
|
||||||
updated_at=row["updated_at"].isoformat() if row["updated_at"] else None,
|
|
||||||
updated_by=row["updated_by"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/api/admin/setup-banner")
|
|
||||||
async def admin_put_banner(
|
|
||||||
payload: BannerPutRequest,
|
|
||||||
user: dict = Depends(require_admin),
|
|
||||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
|
||||||
):
|
|
||||||
env = Environment(undefined=StrictUndefined, autoescape=True)
|
|
||||||
try:
|
|
||||||
template = env.from_string(payload.content)
|
|
||||||
# Render against a stub context so undefined placeholders or runtime
|
|
||||||
# errors are caught here, not when an analyst visits /setup.
|
|
||||||
template.render(**_VALIDATION_STUB_CONTEXT)
|
|
||||||
except TemplateError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Template invalid: {e}")
|
|
||||||
SetupBannerRepository(conn).set(payload.content, updated_by=user["email"])
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/api/admin/setup-banner", status_code=204)
|
|
||||||
async def admin_reset_banner(
|
|
||||||
user: dict = Depends(require_admin),
|
|
||||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
|
||||||
):
|
|
||||||
SetupBannerRepository(conn).reset(updated_by=user["email"])
|
|
||||||
return Response(status_code=204)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/admin/setup-banner/preview", response_model=BannerPreviewResponse)
|
|
||||||
async def admin_preview_banner(
|
|
||||||
payload: BannerPreviewRequest,
|
|
||||||
request: Request,
|
|
||||||
user: dict = Depends(require_admin),
|
|
||||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
|
||||||
):
|
|
||||||
"""Render arbitrary banner content against the live context for the
|
|
||||||
calling admin, without persisting. Used by the /admin/setup-banner editor's
|
|
||||||
Preview button so admins can see their edits before saving."""
|
|
||||||
env = Environment(undefined=StrictUndefined, autoescape=True)
|
|
||||||
try:
|
|
||||||
template = env.from_string(payload.content)
|
|
||||||
ctx = build_setup_banner_context(
|
|
||||||
user=user,
|
|
||||||
server_url=str(request.base_url).rstrip("/"),
|
|
||||||
)
|
|
||||||
rendered = template.render(**ctx)
|
|
||||||
except TemplateError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Template invalid: {e}")
|
|
||||||
return BannerPreviewResponse(content=rendered)
|
|
||||||
|
|
@ -122,7 +122,6 @@ from app.api.v2_sample import router as v2_sample_router
|
||||||
from app.api.v2_scan import router as v2_scan_router
|
from app.api.v2_scan import router as v2_scan_router
|
||||||
from app.api.marketplaces import router as marketplaces_router
|
from app.api.marketplaces import router as marketplaces_router
|
||||||
from app.api.welcome import router as welcome_router
|
from app.api.welcome import router as welcome_router
|
||||||
from app.api.setup_banner import router as setup_banner_router
|
|
||||||
from app.marketplace_server.router import router as marketplace_server_router
|
from app.marketplace_server.router import router as marketplace_server_router
|
||||||
from app.marketplace_server.git_router import make_git_wsgi_app
|
from app.marketplace_server.git_router import make_git_wsgi_app
|
||||||
from app.web.router import router as web_router
|
from app.web.router import router as web_router
|
||||||
|
|
@ -530,7 +529,6 @@ def create_app() -> FastAPI:
|
||||||
app.include_router(v2_scan_router)
|
app.include_router(v2_scan_router)
|
||||||
app.include_router(marketplaces_router)
|
app.include_router(marketplaces_router)
|
||||||
app.include_router(welcome_router)
|
app.include_router(welcome_router)
|
||||||
app.include_router(setup_banner_router)
|
|
||||||
app.include_router(marketplace_server_router)
|
app.include_router(marketplace_server_router)
|
||||||
|
|
||||||
# Git smart-HTTP endpoint for Claude Code: /marketplace.git/*
|
# Git smart-HTTP endpoint for Claude Code: /marketplace.git/*
|
||||||
|
|
|
||||||
|
|
@ -727,17 +727,13 @@ async def setup_page(
|
||||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||||
):
|
):
|
||||||
"""Setup instructions for the local agent (CLI + Claude Code)."""
|
"""Setup instructions for the local agent (CLI + Claude Code)."""
|
||||||
from src.setup_banner import render_setup_banner
|
|
||||||
|
|
||||||
base_url = str(request.base_url).rstrip("/")
|
base_url = str(request.base_url).rstrip("/")
|
||||||
banner_html = render_setup_banner(conn, user=user, server_url=base_url)
|
|
||||||
ctx = _build_context(
|
ctx = _build_context(
|
||||||
request,
|
request,
|
||||||
user=user,
|
user=user,
|
||||||
conn=conn,
|
conn=conn,
|
||||||
server_url=base_url,
|
server_url=base_url,
|
||||||
agnes_version=os.environ.get("AGNES_VERSION", "dev"),
|
agnes_version=os.environ.get("AGNES_VERSION", "dev"),
|
||||||
banner_html=banner_html,
|
|
||||||
)
|
)
|
||||||
return templates.TemplateResponse(request, "install.html", ctx)
|
return templates.TemplateResponse(request, "install.html", ctx)
|
||||||
|
|
||||||
|
|
@ -898,8 +894,8 @@ async def admin_marketplaces_page(
|
||||||
return templates.TemplateResponse(request, "admin_marketplaces.html", ctx)
|
return templates.TemplateResponse(request, "admin_marketplaces.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/admin/welcome", response_class=HTMLResponse)
|
@router.get("/admin/agent-prompt", response_class=HTMLResponse)
|
||||||
async def admin_welcome_page(
|
async def admin_agent_prompt_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
user: dict = Depends(require_admin),
|
user: dict = Depends(require_admin),
|
||||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||||
|
|
@ -920,25 +916,6 @@ async def admin_welcome_page(
|
||||||
return templates.TemplateResponse(request, "admin_welcome.html", ctx)
|
return templates.TemplateResponse(request, "admin_welcome.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/admin/setup-banner", response_class=HTMLResponse)
|
|
||||||
async def admin_setup_banner_page(
|
|
||||||
request: Request,
|
|
||||||
user: dict = Depends(require_admin),
|
|
||||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
|
||||||
):
|
|
||||||
from src.repositories.setup_banner import SetupBannerRepository
|
|
||||||
|
|
||||||
row = SetupBannerRepository(conn).get()
|
|
||||||
ctx = _build_context(
|
|
||||||
request,
|
|
||||||
user=user,
|
|
||||||
current=row["content"] or "",
|
|
||||||
updated_at=row["updated_at"],
|
|
||||||
updated_by=row["updated_by"],
|
|
||||||
is_override=row["content"] is not None,
|
|
||||||
)
|
|
||||||
return templates.TemplateResponse(request, "admin_setup_banner.html", ctx)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tokens", response_class=HTMLResponse)
|
@router.get("/tokens", response_class=HTMLResponse)
|
||||||
async def my_tokens_page(
|
async def my_tokens_page(
|
||||||
|
|
|
||||||
|
|
@ -1,499 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}Setup Banner — {{ config.INSTANCE_NAME }}{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css"
|
|
||||||
integrity="sha512-uf06llspW44/LZpHzHT6qBOIVODjWtv4MxCricRxkzvopAlSWnTf6hpZTFxuuZcuNE9CBQhqE0Seu1CoRk84nQ=="
|
|
||||||
crossorigin="anonymous">
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/theme/material-darker.min.css"
|
|
||||||
integrity="sha512-2OhXH4Il3n2tHKwLLSDPhrkgnLBC+6lHGGQzSFi3chgVB6DJ/v6+nbx+XYO9CugQyHVF/8D/0k3Hx1eaUK2K9g=="
|
|
||||||
crossorigin="anonymous">
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"
|
|
||||||
integrity="sha512-OeZ4Yrb/W7d2W4rAMOO0HQ9Ro/aWLtpW9BUSR2UOWnSV2hprXLkkYnnCGc9NeLUxxE4ZG7zN16UuT1Elqq8Opg=="
|
|
||||||
crossorigin="anonymous"></script>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/jinja2/jinja2.min.js"
|
|
||||||
integrity="sha512-Z4le1RxwhD8lDCrspbBxjTLLP2HGC1+mKb9KHR2N/sEx8uOe2vre5XQo8YMPAz8FQTo43HjefjlDtjY4LtfaaQ=="
|
|
||||||
crossorigin="anonymous"></script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.container:has(.welcome-page) { max-width: none; padding: 24px 16px; }
|
|
||||||
.welcome-page { max-width: 1400px; margin: 0 auto; padding: 0; }
|
|
||||||
|
|
||||||
.welcome-toolbar {
|
|
||||||
display: flex; justify-content: space-between; align-items: center;
|
|
||||||
gap: 16px; margin-bottom: 16px; flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.welcome-title { margin: 0; font-size: 22px; font-weight: 600; }
|
|
||||||
.welcome-sub { color: var(--text-secondary, #6b7280); font-size: 13px; margin-top: 4px; margin-bottom: 0; }
|
|
||||||
|
|
||||||
.origin-chip {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 3px 10px; border-radius: 999px;
|
|
||||||
font-size: 11px; font-weight: 600;
|
|
||||||
text-transform: uppercase; letter-spacing: 0.4px;
|
|
||||||
}
|
|
||||||
.origin-override { background: #ede9fe; color: #6d28d9; }
|
|
||||||
.origin-default { background: #f3f4f6; color: #6b7280; }
|
|
||||||
|
|
||||||
.welcome-card {
|
|
||||||
background: var(--surface, #fff);
|
|
||||||
border: 1px solid var(--border, #e5e7eb);
|
|
||||||
border-radius: 12px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.welcome-card-body { padding: 20px 22px; }
|
|
||||||
|
|
||||||
.welcome-desc { font-size: 13px; color: var(--text-secondary, #6b7280); margin: 0 0 16px; }
|
|
||||||
|
|
||||||
/* Placeholder cheatsheet — collapsible */
|
|
||||||
details.welcome-cheatsheet { margin-bottom: 18px; }
|
|
||||||
details.welcome-cheatsheet summary {
|
|
||||||
cursor: pointer; font-size: 13px; font-weight: 500;
|
|
||||||
color: var(--text-primary, #111827); list-style: none;
|
|
||||||
display: inline-flex; align-items: center; gap: 6px;
|
|
||||||
}
|
|
||||||
details.welcome-cheatsheet summary::-webkit-details-marker { display: none; }
|
|
||||||
details.welcome-cheatsheet summary::before {
|
|
||||||
content: "▶"; font-size: 10px; color: var(--text-secondary, #6b7280);
|
|
||||||
transition: transform 0.15s;
|
|
||||||
}
|
|
||||||
details.welcome-cheatsheet[open] summary::before { transform: rotate(90deg); }
|
|
||||||
|
|
||||||
/* Dark code block */
|
|
||||||
.code-block {
|
|
||||||
background: #1e1e2e;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 12px;
|
|
||||||
font-family: var(--font-mono, ui-monospace, "SF Mono", Menlo, monospace);
|
|
||||||
font-size: 13px;
|
|
||||||
color: #cdd6f4;
|
|
||||||
line-height: 1.6;
|
|
||||||
margin-top: 10px;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.code-block .code-body { flex: 1; white-space: pre; overflow-x: auto; }
|
|
||||||
.btn-copy {
|
|
||||||
padding: 6px 14px;
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid #45475a;
|
|
||||||
color: #cdd6f4;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
font-family: inherit;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.btn-copy:hover { border-color: #89b4fa; color: #89b4fa; background: rgba(137, 180, 250, 0.08); }
|
|
||||||
.btn-copy.copied { border-color: #a6e3a1; color: #a6e3a1; background: rgba(166, 227, 161, 0.08); }
|
|
||||||
|
|
||||||
/* CodeMirror editor styling */
|
|
||||||
.welcome-page .CodeMirror {
|
|
||||||
border: 1px solid var(--border, #e5e7eb);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: var(--font-mono, ui-monospace, "SF Mono", Menlo, monospace);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Split layout: editor left, preview right */
|
|
||||||
.welcome-editor-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
.welcome-editor-col, .welcome-preview-col {
|
|
||||||
flex: 1 1 50%;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.welcome-editor-col {
|
|
||||||
min-height: 480px;
|
|
||||||
}
|
|
||||||
.welcome-preview-col {
|
|
||||||
border: 1px solid var(--border, #e5e7eb);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--surface, #fff);
|
|
||||||
color: var(--text-primary, #111827);
|
|
||||||
padding: 16px;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.6;
|
|
||||||
overflow: auto;
|
|
||||||
max-height: 600px;
|
|
||||||
}
|
|
||||||
.welcome-preview-col h4 {
|
|
||||||
color: var(--text-secondary, #6b7280); margin: 0 0 8px; font-size: 12px;
|
|
||||||
text-transform: uppercase; letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
.welcome-preview-empty {
|
|
||||||
color: var(--text-secondary, #6b7280);
|
|
||||||
font-style: italic;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.welcome-preview-error {
|
|
||||||
background: rgba(234, 88, 12, 0.15);
|
|
||||||
color: #b91c1c;
|
|
||||||
border: 1px solid rgba(234, 88, 12, 0.4);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
@media (max-width: 1100px) {
|
|
||||||
.welcome-editor-row { flex-direction: column; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Action row */
|
|
||||||
.welcome-actions {
|
|
||||||
padding: 14px 22px;
|
|
||||||
background: var(--border-light, #fafafa);
|
|
||||||
border-top: 1px solid var(--border, #e5e7eb);
|
|
||||||
display: flex; gap: 8px; justify-content: flex-end;
|
|
||||||
}
|
|
||||||
.welcome-btn {
|
|
||||||
padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 500;
|
|
||||||
border: 1px solid var(--border, #e5e7eb); background: var(--surface, #fff);
|
|
||||||
cursor: pointer; transition: all 0.15s;
|
|
||||||
}
|
|
||||||
.welcome-btn:hover { background: var(--border-light, #f9fafb); }
|
|
||||||
.welcome-btn.primary { background: var(--primary, #6366f1); color: #fff; border-color: var(--primary, #6366f1); }
|
|
||||||
.welcome-btn.primary:hover { filter: brightness(1.05); }
|
|
||||||
.welcome-btn.danger { background: #dc2626; color: #fff; border-color: #dc2626; }
|
|
||||||
.welcome-btn.danger:hover { filter: brightness(1.05); }
|
|
||||||
.welcome-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
||||||
|
|
||||||
/* Modal */
|
|
||||||
.modal-backdrop {
|
|
||||||
position: fixed; inset: 0; background: rgba(15, 23, 42, 0.55);
|
|
||||||
display: none; align-items: center; justify-content: center; z-index: 1000;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
.modal-backdrop.is-open { display: flex; }
|
|
||||||
.modal-card {
|
|
||||||
background: var(--surface, #fff); border-radius: 12px;
|
|
||||||
padding: 24px; width: 100%; max-width: 440px;
|
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
|
|
||||||
}
|
|
||||||
.modal-card h3 { margin: 0 0 6px; font-size: 17px; font-weight: 600; }
|
|
||||||
.modal-card p.sub { margin: 0 0 18px; font-size: 13px; color: var(--text-secondary, #6b7280); }
|
|
||||||
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; }
|
|
||||||
.modal-btn {
|
|
||||||
padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 500;
|
|
||||||
border: 1px solid var(--border, #e5e7eb); background: var(--surface, #fff);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.modal-btn.danger { background: #dc2626; color: #fff; border-color: #dc2626; }
|
|
||||||
|
|
||||||
/* Toast stack */
|
|
||||||
.toast-stack {
|
|
||||||
position: fixed; bottom: 24px; right: 24px; z-index: 2000;
|
|
||||||
display: flex; flex-direction: column; gap: 8px; pointer-events: none;
|
|
||||||
}
|
|
||||||
.toast {
|
|
||||||
background: #111827; color: #fff; padding: 10px 16px;
|
|
||||||
border-radius: 8px; font-size: 13px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
|
|
||||||
opacity: 0; transform: translateY(8px); transition: opacity 0.2s, transform 0.2s;
|
|
||||||
pointer-events: auto; max-width: 380px;
|
|
||||||
}
|
|
||||||
.toast.show { opacity: 1; transform: translateY(0); }
|
|
||||||
.toast.success { background: #047857; }
|
|
||||||
.toast.error { background: #b91c1c; }
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<div class="welcome-page">
|
|
||||||
<div class="welcome-toolbar">
|
|
||||||
<div>
|
|
||||||
<h2 class="welcome-title">Setup Page Banner</h2>
|
|
||||||
<p class="welcome-sub">Shown above the bootstrap commands on <code>/setup</code>. Use it for org-specific notes: VPN requirements, support channel, data classification, platform prerequisites. Empty by default — no banner is shown when unset.</p>
|
|
||||||
</div>
|
|
||||||
<div id="status-chip">
|
|
||||||
{% if is_override %}
|
|
||||||
<span class="origin-chip origin-override"
|
|
||||||
title="Overridden by {{ updated_by }} on {{ updated_at.strftime('%Y-%m-%d %H:%M UTC') if updated_at else '—' }}">
|
|
||||||
Banner active
|
|
||||||
</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="origin-chip origin-default">No banner</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="welcome-card">
|
|
||||||
<div class="welcome-card-body">
|
|
||||||
<p class="welcome-desc">
|
|
||||||
Author HTML or plain text with Jinja2 placeholders. The banner renders inside the <code>/setup</code> page —
|
|
||||||
HTML tags are allowed. Leave empty and click <strong>Remove banner</strong> to go back to no banner.
|
|
||||||
<code>{{ "{{ user }}" }}</code> may be <code>null</code> for anonymous visitors — guard with <code>{% if user %}</code>.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<details class="welcome-cheatsheet">
|
|
||||||
<summary>Available placeholders</summary>
|
|
||||||
<div class="code-block">
|
|
||||||
<span id="placeholder-text" class="code-body">{{ "{{ instance.name }}" }} — instance display name
|
|
||||||
{{ "{{ instance.subtitle }}" }} — operator name
|
|
||||||
{{ "{{ server.url }}" }} — full server URL
|
|
||||||
{{ "{{ server.hostname }}" }} — host part only
|
|
||||||
{{ "{{ user.email }}" }}, {{ "{{ user.name }}" }}, {{ "{{ user.is_admin }}" }}
|
|
||||||
— null for anonymous visitors; guard with {% if user %}
|
|
||||||
{{ "{{ now }}" }}, {{ "{{ today }}" }}</span>
|
|
||||||
<button class="btn-copy" data-copy-target="placeholder-text">Copy</button>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<div class="welcome-editor-row">
|
|
||||||
<div class="welcome-editor-col">
|
|
||||||
<textarea id="content" name="content">{{ current }}</textarea>
|
|
||||||
</div>
|
|
||||||
<div class="welcome-preview-col">
|
|
||||||
<h4>Live preview</h4>
|
|
||||||
<div id="preview-content"></div>
|
|
||||||
<div id="preview-error" class="welcome-preview-error" hidden></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="welcome-actions">
|
|
||||||
<button type="button" class="welcome-btn danger" id="reset-btn">Remove banner</button>
|
|
||||||
<button type="button" class="welcome-btn primary" id="save-btn">Save banner</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Remove confirmation modal -->
|
|
||||||
<div class="modal-backdrop" id="reset-modal" role="dialog" aria-modal="true" aria-labelledby="reset-modal-title">
|
|
||||||
<div class="modal-card">
|
|
||||||
<h3 id="reset-modal-title">Remove the banner?</h3>
|
|
||||||
<p class="sub"><code>/setup</code> will go back to showing no banner. This cannot be undone.</p>
|
|
||||||
<div class="modal-actions">
|
|
||||||
<button class="modal-btn" data-close-modal="reset-modal">Cancel</button>
|
|
||||||
<button class="modal-btn danger" id="reset-confirm-btn">Remove</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="toast-stack" id="toast-stack" aria-live="polite"></div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const API = "/api/admin/setup-banner";
|
|
||||||
|
|
||||||
// ── CodeMirror editor (with graceful CDN fallback) ────────────────────
|
|
||||||
const ta = document.getElementById("content");
|
|
||||||
if (typeof CodeMirror === "undefined") {
|
|
||||||
// CDN unreachable or SRI mismatch — degrade to plain textarea + warn.
|
|
||||||
ta.style.display = "block";
|
|
||||||
ta.style.width = "100%";
|
|
||||||
ta.style.minHeight = "480px";
|
|
||||||
ta.style.fontFamily = "var(--font-mono)";
|
|
||||||
ta.style.fontSize = "13px";
|
|
||||||
ta.style.padding = "9px 12px";
|
|
||||||
ta.style.border = "1px solid var(--border)";
|
|
||||||
ta.style.borderRadius = "8px";
|
|
||||||
// Polyfill the `editor` interface so save/remove/preview still work.
|
|
||||||
window.editor = {
|
|
||||||
getValue: () => ta.value,
|
|
||||||
setValue: (v) => { ta.value = v; },
|
|
||||||
on: () => {},
|
|
||||||
setSize: () => {},
|
|
||||||
};
|
|
||||||
// toast is defined below; defer so the DOM is ready and toast stack exists.
|
|
||||||
setTimeout(() => toast("Code editor failed to load — using plain textarea. Check network/CSP.", "error"), 0);
|
|
||||||
} else {
|
|
||||||
// Normal path
|
|
||||||
window.editor = CodeMirror.fromTextArea(ta, {
|
|
||||||
mode: "jinja2",
|
|
||||||
lineNumbers: true,
|
|
||||||
lineWrapping: true,
|
|
||||||
theme: "material-darker",
|
|
||||||
indentUnit: 2,
|
|
||||||
tabSize: 2,
|
|
||||||
});
|
|
||||||
editor.setSize("100%", "calc(100vh - 320px)");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Live preview (debounced) ──────────────────────────────────────────
|
|
||||||
let previewTimer = null;
|
|
||||||
function schedulePreview() {
|
|
||||||
if (previewTimer) clearTimeout(previewTimer);
|
|
||||||
previewTimer = setTimeout(renderPreview, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderPreview() {
|
|
||||||
const content = editor.getValue();
|
|
||||||
const previewBox = document.getElementById("preview-content");
|
|
||||||
const previewErr = document.getElementById("preview-error");
|
|
||||||
if (!content.trim()) {
|
|
||||||
previewBox.innerHTML = '<span class="welcome-preview-empty">(no banner)</span>';
|
|
||||||
previewErr.hidden = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const r = await fetch(API + "/preview", {
|
|
||||||
method: "POST",
|
|
||||||
credentials: "include",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ content }),
|
|
||||||
});
|
|
||||||
if (r.ok) {
|
|
||||||
const j = await r.json();
|
|
||||||
previewBox.innerHTML = j.content;
|
|
||||||
previewErr.hidden = true;
|
|
||||||
} else {
|
|
||||||
let detail = r.statusText;
|
|
||||||
try { detail = (await r.json()).detail || detail; } catch (_) {}
|
|
||||||
previewBox.innerHTML = "";
|
|
||||||
previewErr.textContent = detail;
|
|
||||||
previewErr.hidden = false;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
previewErr.textContent = "Network error: " + e.message;
|
|
||||||
previewErr.hidden = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
editor.on("change", schedulePreview);
|
|
||||||
renderPreview(); // initial render on load
|
|
||||||
|
|
||||||
// ── Toast ─────────────────────────────────────────────────────────────
|
|
||||||
function toast(msg, kind) {
|
|
||||||
const el = document.createElement("div");
|
|
||||||
el.className = "toast" + (kind ? " " + kind : "");
|
|
||||||
el.textContent = msg;
|
|
||||||
document.getElementById("toast-stack").appendChild(el);
|
|
||||||
requestAnimationFrame(() => el.classList.add("show"));
|
|
||||||
setTimeout(() => { el.classList.remove("show"); setTimeout(() => el.remove(), 250); }, 3500);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Modal helpers ─────────────────────────────────────────────────────
|
|
||||||
function openModal(id) { document.getElementById(id).classList.add("is-open"); }
|
|
||||||
function closeModal(id) { document.getElementById(id).classList.remove("is-open"); }
|
|
||||||
document.querySelectorAll("[data-close-modal]").forEach(el =>
|
|
||||||
el.addEventListener("click", () => closeModal(el.dataset.closeModal)));
|
|
||||||
document.querySelectorAll(".modal-backdrop").forEach(el => {
|
|
||||||
el.addEventListener("click", e => { if (e.target === el) el.classList.remove("is-open"); });
|
|
||||||
});
|
|
||||||
document.addEventListener("keydown", e => {
|
|
||||||
if (e.key === "Escape") document.querySelectorAll(".modal-backdrop.is-open").forEach(m => m.classList.remove("is-open"));
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Status chip ───────────────────────────────────────────────────────
|
|
||||||
function setStatusChip(data) {
|
|
||||||
const wrap = document.getElementById("status-chip");
|
|
||||||
if (data.content !== null && data.content !== undefined) {
|
|
||||||
const when = data.updated_at
|
|
||||||
? new Date(data.updated_at).toISOString().slice(0, 16).replace("T", " ") + " UTC"
|
|
||||||
: "—";
|
|
||||||
const who = data.updated_by || "—";
|
|
||||||
wrap.innerHTML = `<span class="origin-chip origin-override" title="Overridden by ${who} on ${when}">Banner active</span>`;
|
|
||||||
} else {
|
|
||||||
wrap.innerHTML = `<span class="origin-chip origin-default">No banner</span>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Refresh status + editor content from API ─────────────────────────
|
|
||||||
async function refreshStatus() {
|
|
||||||
const r = await fetch(API, { credentials: "include" });
|
|
||||||
if (!r.ok) return;
|
|
||||||
const data = await r.json();
|
|
||||||
setStatusChip(data);
|
|
||||||
editor.setValue(data.content !== null && data.content !== undefined ? data.content : "");
|
|
||||||
renderPreview();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Save ──────────────────────────────────────────────────────────────
|
|
||||||
document.getElementById("save-btn").addEventListener("click", async () => {
|
|
||||||
const btn = document.getElementById("save-btn");
|
|
||||||
const content = editor.getValue();
|
|
||||||
if (!content.trim()) {
|
|
||||||
toast("Nothing to save — editor is empty. Use Remove banner to clear.", "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
btn.disabled = true;
|
|
||||||
try {
|
|
||||||
const r = await fetch(API, {
|
|
||||||
method: "PUT",
|
|
||||||
credentials: "include",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ content }),
|
|
||||||
});
|
|
||||||
if (r.ok) {
|
|
||||||
toast("Banner saved.", "success");
|
|
||||||
await refreshStatus();
|
|
||||||
} else {
|
|
||||||
let detail = r.statusText;
|
|
||||||
try { detail = (await r.json()).detail || detail; } catch (_) {}
|
|
||||||
toast("Save failed: " + detail, "error");
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast("Save failed: " + e.message, "error");
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Remove (with confirm modal) ───────────────────────────────────────
|
|
||||||
document.getElementById("reset-btn").addEventListener("click", () => openModal("reset-modal"));
|
|
||||||
|
|
||||||
document.getElementById("reset-confirm-btn").addEventListener("click", async () => {
|
|
||||||
closeModal("reset-modal");
|
|
||||||
const btn = document.getElementById("reset-btn");
|
|
||||||
btn.disabled = true;
|
|
||||||
try {
|
|
||||||
const r = await fetch(API, { method: "DELETE", credentials: "include" });
|
|
||||||
if (r.ok) {
|
|
||||||
toast("Banner removed.", "success");
|
|
||||||
await refreshStatus();
|
|
||||||
} else {
|
|
||||||
let detail = r.statusText;
|
|
||||||
try { detail = (await r.json()).detail || detail; } catch (_) {}
|
|
||||||
toast("Remove failed: " + detail, "error");
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast("Remove failed: " + e.message, "error");
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Copy widget ───────────────────────────────────────────────────────
|
|
||||||
function copyToClipboard(text) {
|
|
||||||
if (navigator.clipboard && window.isSecureContext) {
|
|
||||||
return navigator.clipboard.writeText(text);
|
|
||||||
}
|
|
||||||
const ta = document.createElement("textarea");
|
|
||||||
ta.value = text;
|
|
||||||
ta.style.cssText = "position:fixed;left:-9999px;top:-9999px";
|
|
||||||
document.body.appendChild(ta);
|
|
||||||
ta.focus();
|
|
||||||
ta.select();
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
try { document.execCommand("copy") ? resolve() : reject(); }
|
|
||||||
finally { document.body.removeChild(ta); }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function flashCopied(button) {
|
|
||||||
const original = button.textContent;
|
|
||||||
button.textContent = "Copied!";
|
|
||||||
button.classList.add("copied");
|
|
||||||
setTimeout(() => { button.textContent = original; button.classList.remove("copied"); }, 1500);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.querySelectorAll(".btn-copy[data-copy-target]").forEach(btn => {
|
|
||||||
btn.addEventListener("click", () => {
|
|
||||||
const target = document.getElementById(btn.getAttribute("data-copy-target"));
|
|
||||||
if (!target) return;
|
|
||||||
copyToClipboard(target.textContent.trim())
|
|
||||||
.then(() => flashCopied(btn))
|
|
||||||
.catch(() => {
|
|
||||||
const range = document.createRange();
|
|
||||||
range.selectNodeContents(target);
|
|
||||||
const sel = window.getSelection();
|
|
||||||
sel.removeAllRanges();
|
|
||||||
sel.addRange(range);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -258,21 +258,6 @@
|
||||||
background: rgba(166, 227, 161, 0.08);
|
background: rgba(166, 227, 161, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Admin setup banner (shown when admin has set one) ── */
|
|
||||||
.setup-banner {
|
|
||||||
background: var(--surface, #fff);
|
|
||||||
border: 1px solid var(--border, #e5e7eb);
|
|
||||||
border-left: 3px solid var(--primary, #6366f1);
|
|
||||||
border-radius: var(--radius, 8px);
|
|
||||||
padding: 14px 18px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--text-primary, #111827);
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
.setup-banner > *:first-child { margin-top: 0; }
|
|
||||||
.setup-banner > *:last-child { margin-bottom: 0; }
|
|
||||||
|
|
||||||
/* ── Anon sign-in banner (shown only when logged out) ── */
|
/* ── Anon sign-in banner (shown only when logged out) ── */
|
||||||
.auth-banner {
|
.auth-banner {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
|
|
@ -663,10 +648,6 @@
|
||||||
|
|
||||||
<main class="main">
|
<main class="main">
|
||||||
|
|
||||||
{% if banner_html %}
|
|
||||||
<div class="setup-banner">{{ banner_html | safe }}</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- ═══════════════ HERO ═══════════════ -->
|
<!-- ═══════════════ HERO ═══════════════ -->
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<div class="hero-eyebrow">Getting started</div>
|
<div class="hero-eyebrow">Getting started</div>
|
||||||
|
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
# Setup page banner
|
|
||||||
|
|
||||||
The setup banner is a block of HTML (or plain text) shown **above** the
|
|
||||||
auto-generated bootstrap commands on the `/setup` page. Use it for
|
|
||||||
org-specific operational notes that analysts need before they install the
|
|
||||||
client: VPN requirements, support channel, data-classification policy,
|
|
||||||
platform prerequisites, etc.
|
|
||||||
|
|
||||||
The banner is empty by default — no content is shown until an admin sets one.
|
|
||||||
|
|
||||||
## How to edit
|
|
||||||
|
|
||||||
- **Admin UI:** `/admin/setup-banner` — split-pane editor with a placeholder
|
|
||||||
cheatsheet and a live HTML preview. Click **Save banner** to persist,
|
|
||||||
**Remove banner** to clear.
|
|
||||||
- **REST API:**
|
|
||||||
- `GET /api/admin/setup-banner` — returns `{content, updated_at, updated_by}`.
|
|
||||||
`content` is `null` when no banner is set.
|
|
||||||
- `PUT /api/admin/setup-banner` with body `{"content": "..."}` — validates
|
|
||||||
Jinja2 syntax and stores the banner.
|
|
||||||
- `DELETE /api/admin/setup-banner` — clears the banner; `/setup` shows no
|
|
||||||
banner until one is set again.
|
|
||||||
- `POST /api/admin/setup-banner/preview` with body `{"content": "..."}` —
|
|
||||||
renders arbitrary content against the calling admin's context without
|
|
||||||
persisting. Backs the editor's live preview.
|
|
||||||
|
|
||||||
The banner lives in `system.duckdb` (table `setup_banner`, singleton row id=1).
|
|
||||||
|
|
||||||
## Available placeholders
|
|
||||||
|
|
||||||
| Placeholder | Type | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `instance.name` | string | `instance.name` in `instance.yaml` |
|
|
||||||
| `instance.subtitle` | string | `instance.subtitle` in `instance.yaml` |
|
|
||||||
| `server.url` | string | full origin of the Agnes server |
|
|
||||||
| `server.hostname` | string | host part only (no port or path) |
|
|
||||||
| `user.email` | string | logged-in user, or `null` for anonymous visitors |
|
|
||||||
| `user.name` | string | logged-in user display name |
|
|
||||||
| `user.is_admin` | bool | `true` when the visitor is in the Admin group |
|
|
||||||
| `now` | datetime (UTC, tz-aware) | server time at render |
|
|
||||||
| `today` | string (`YYYY-MM-DD`) | server date at render |
|
|
||||||
|
|
||||||
> **`user` may be `null`** — `/setup` is partly public (anonymous visitors
|
|
||||||
> get the install one-liner). Always guard user-specific placeholders:
|
|
||||||
>
|
|
||||||
> ```jinja2
|
|
||||||
> {% if user %}Welcome back, {{ user.name }}!{% endif %}
|
|
||||||
> ```
|
|
||||||
|
|
||||||
## Autoescape semantics
|
|
||||||
|
|
||||||
The Jinja2 environment runs with `autoescape=True`, which means template
|
|
||||||
**variable output** (`{{ ... }}`) is HTML-escaped automatically. Literal HTML
|
|
||||||
in the template source is passed through unchanged — that is how the banner
|
|
||||||
outputs `<p>` tags, `<strong>`, etc.
|
|
||||||
|
|
||||||
To output a literal `<` or `&` from a variable, use the `| safe` filter only
|
|
||||||
when you are certain the value is trusted:
|
|
||||||
|
|
||||||
```jinja2
|
|
||||||
{# Safe — admin-authored constant: #}
|
|
||||||
{{ "<strong>VPN required</strong>" | safe }}
|
|
||||||
|
|
||||||
{# Dangerous — never pipe user-controlled values through | safe: #}
|
|
||||||
{{ user.name | safe }} {# do NOT do this #}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security note
|
|
||||||
|
|
||||||
Admin-authored banner content is rendered for **all `/setup` visitors**,
|
|
||||||
including anonymous users. As a defense-in-depth measure, inline `<script>`
|
|
||||||
tags, `<iframe>` blocks, `on*=` event handlers, and `javascript:`/`data:`
|
|
||||||
URI schemes are stripped from the rendered output before it reaches the
|
|
||||||
browser.
|
|
||||||
|
|
||||||
This is **not a full sandbox** — a determined admin can still author arbitrary
|
|
||||||
HTML with CSS tricks or external resource loads. The stripping is a safety net
|
|
||||||
against accidental inclusion of dangerous markup (copy-paste from an untrusted
|
|
||||||
source, etc.), not a substitute for trust in your admin users.
|
|
||||||
|
|
||||||
For a stricter posture, add a `Content-Security-Policy` header that disallows
|
|
||||||
inline scripts and restricts `connect-src`.
|
|
||||||
|
|
||||||
## Difference from the welcome template
|
|
||||||
|
|
||||||
| | Setup banner | Welcome template |
|
|
||||||
|---|---|---|
|
|
||||||
| Location | `/setup` page (partly public) | `CLAUDE.md` in analyst workspace |
|
|
||||||
| Format | HTML (rendered in browser) | Markdown (consumed by Claude Code) |
|
|
||||||
| Default | No banner | Ships a default at `config/claude_md_template.txt` |
|
|
||||||
| Context | `instance`, `server`, `user` (nullable), `now`, `today` | All of the above plus `tables`, `metrics`, `marketplaces`, `sync_interval`, `data_source` |
|
|
||||||
| RBAC filtering | None — same for all visitors | `marketplaces` filtered per user's group memberships |
|
|
||||||
|
|
||||||
See `docs/welcome-template.md` for the welcome-template reference.
|
|
||||||
|
|
@ -418,8 +418,8 @@ CREATE TABLE IF NOT EXISTS welcome_template (
|
||||||
CONSTRAINT singleton CHECK (id = 1)
|
CONSTRAINT singleton CHECK (id = 1)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- v22: customizable banner shown above setup commands on /setup page.
|
-- v22: reserved (formerly setup_banner — feature dropped, table kept for
|
||||||
-- Singleton row (id=1). NULL content means "no banner".
|
-- forward compatibility with already-migrated instances).
|
||||||
CREATE TABLE IF NOT EXISTS setup_banner (
|
CREATE TABLE IF NOT EXISTS setup_banner (
|
||||||
id INTEGER PRIMARY KEY DEFAULT 1,
|
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||||
content TEXT,
|
content TEXT,
|
||||||
|
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
"""Repository for the per-instance setup-page banner override (singleton row)."""
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import duckdb
|
|
||||||
|
|
||||||
|
|
||||||
class SetupBannerRepository:
|
|
||||||
def __init__(self, conn: duckdb.DuckDBPyConnection):
|
|
||||||
self.conn = conn
|
|
||||||
|
|
||||||
def get(self) -> dict[str, Any]:
|
|
||||||
"""Return the singleton row. Always exists post-migration; content
|
|
||||||
is None when no banner is set."""
|
|
||||||
row = self.conn.execute(
|
|
||||||
"SELECT id, content, updated_at, updated_by FROM setup_banner WHERE id = 1"
|
|
||||||
).fetchone()
|
|
||||||
if row is None:
|
|
||||||
# Defensive: re-seed if a previous admin manually deleted it.
|
|
||||||
self.conn.execute(
|
|
||||||
"INSERT INTO setup_banner (id, content) VALUES (1, NULL) "
|
|
||||||
"ON CONFLICT (id) DO NOTHING"
|
|
||||||
)
|
|
||||||
return {"id": 1, "content": None, "updated_at": None, "updated_by": None}
|
|
||||||
return {
|
|
||||||
"id": row[0],
|
|
||||||
"content": row[1],
|
|
||||||
"updated_at": row[2],
|
|
||||||
"updated_by": row[3],
|
|
||||||
}
|
|
||||||
|
|
||||||
def set(self, content: str, *, updated_by: str) -> None:
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
self.conn.execute(
|
|
||||||
"""INSERT INTO setup_banner (id, content, updated_at, updated_by)
|
|
||||||
VALUES (1, ?, ?, ?)
|
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
|
||||||
content = excluded.content,
|
|
||||||
updated_at = excluded.updated_at,
|
|
||||||
updated_by = excluded.updated_by""",
|
|
||||||
[content, now, updated_by],
|
|
||||||
)
|
|
||||||
|
|
||||||
def reset(self, *, updated_by: str) -> None:
|
|
||||||
"""Clear the banner; /setup will show no banner."""
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
self.conn.execute(
|
|
||||||
"""UPDATE setup_banner
|
|
||||||
SET content = NULL, updated_at = ?, updated_by = ?
|
|
||||||
WHERE id = 1""",
|
|
||||||
[now, updated_by],
|
|
||||||
)
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
"""Render the admin-editable setup-page banner.
|
|
||||||
|
|
||||||
Smaller surface than welcome_template — only instance/server/user context.
|
|
||||||
Setup banner is for organization-specific operational notes (VPN, support,
|
|
||||||
data classification), not for analyst-side content.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
from datetime import date, datetime, timezone
|
|
||||||
from typing import Any, Optional
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
import duckdb
|
|
||||||
from jinja2 import Environment, StrictUndefined, TemplateError
|
|
||||||
|
|
||||||
from app.instance_config import get_instance_name, get_instance_subtitle
|
|
||||||
from src.repositories.setup_banner import SetupBannerRepository
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Patterns used by _sanitize_banner_html.
|
|
||||||
_RE_SCRIPT = re.compile(r"<\s*script[\s\S]*?(?:</\s*script\s*>|$)", re.IGNORECASE)
|
|
||||||
_RE_IFRAME = re.compile(r"<\s*iframe[\s\S]*?(?:</\s*iframe\s*>|$)", re.IGNORECASE)
|
|
||||||
_RE_ON_ATTR = re.compile(r'\s+on\w+\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]*)', re.IGNORECASE)
|
|
||||||
_RE_JS_URI = re.compile(
|
|
||||||
r'((?:href|src)\s*=\s*["\'])(?:javascript|data):[^"\']*(["\'])',
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_banner_html(html: str) -> str:
|
|
||||||
"""Strip the most dangerous markup patterns from rendered banner HTML.
|
|
||||||
|
|
||||||
Threat model: admins are trusted to author banner content, but mistakes
|
|
||||||
happen (copy-paste from untrusted sources, accidental script inclusion).
|
|
||||||
This is defense-in-depth, NOT a full XSS defense — for that, render
|
|
||||||
markdown only or add a strict Content-Security-Policy. The whitelist of
|
|
||||||
bad patterns is intentionally narrow so legitimate admin HTML is not
|
|
||||||
mangled.
|
|
||||||
|
|
||||||
What is stripped:
|
|
||||||
- ``<script>...</script>`` blocks (case-insensitive, including unclosed).
|
|
||||||
- ``<iframe>...</iframe>`` blocks.
|
|
||||||
- ``on*=`` event-handler attributes (e.g. onclick, onload, onerror).
|
|
||||||
- ``javascript:`` and ``data:`` URI schemes in href/src attributes.
|
|
||||||
"""
|
|
||||||
html = _RE_SCRIPT.sub("", html)
|
|
||||||
html = _RE_IFRAME.sub("", html)
|
|
||||||
html = _RE_ON_ATTR.sub("", html)
|
|
||||||
html = _RE_JS_URI.sub(lambda m: m.group(1) + "#" + m.group(2), html)
|
|
||||||
return html
|
|
||||||
|
|
||||||
|
|
||||||
def build_setup_banner_context(
|
|
||||||
*,
|
|
||||||
user: Optional[dict],
|
|
||||||
server_url: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Compose the Jinja2 render context for the setup banner.
|
|
||||||
|
|
||||||
``user`` may be None on the anonymous path of /setup (the page is partly
|
|
||||||
public — anonymous visitors get the curl-install one-liner). Templates
|
|
||||||
must guard for that with ``{% if user %}``.
|
|
||||||
"""
|
|
||||||
parsed = urlparse(server_url)
|
|
||||||
return {
|
|
||||||
"instance": {
|
|
||||||
"name": get_instance_name(),
|
|
||||||
"subtitle": get_instance_subtitle(),
|
|
||||||
},
|
|
||||||
"server": {
|
|
||||||
"url": server_url,
|
|
||||||
"hostname": parsed.hostname or "",
|
|
||||||
},
|
|
||||||
"user": (
|
|
||||||
{
|
|
||||||
"id": user.get("id", ""),
|
|
||||||
"email": user.get("email", ""),
|
|
||||||
"name": user.get("name") or "",
|
|
||||||
"is_admin": bool(user.get("is_admin")),
|
|
||||||
}
|
|
||||||
if user
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
"now": datetime.now(timezone.utc),
|
|
||||||
"today": date.today().isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def render_setup_banner(
|
|
||||||
conn: duckdb.DuckDBPyConnection,
|
|
||||||
*,
|
|
||||||
user: Optional[dict],
|
|
||||||
server_url: str,
|
|
||||||
) -> str:
|
|
||||||
"""Render the banner. Returns "" when no override is set or render fails.
|
|
||||||
|
|
||||||
Render failures are swallowed (logged) — a broken admin banner must NOT
|
|
||||||
break /setup for analysts. The /admin/setup-banner editor catches Jinja
|
|
||||||
errors at PUT time anyway, so this is defense-in-depth.
|
|
||||||
"""
|
|
||||||
row = SetupBannerRepository(conn).get()
|
|
||||||
source = row.get("content")
|
|
||||||
if not source:
|
|
||||||
return ""
|
|
||||||
env = Environment(undefined=StrictUndefined, autoescape=True)
|
|
||||||
try:
|
|
||||||
template = env.from_string(source)
|
|
||||||
rendered = template.render(**build_setup_banner_context(user=user, server_url=server_url))
|
|
||||||
return _sanitize_banner_html(rendered)
|
|
||||||
except TemplateError:
|
|
||||||
_logger.warning(
|
|
||||||
"setup_banner render failed; returning empty banner. "
|
|
||||||
"Admin can fix at /admin/setup-banner."
|
|
||||||
)
|
|
||||||
return ""
|
|
||||||
|
|
@ -1,139 +0,0 @@
|
||||||
"""End-to-end tests for /api/admin/setup-banner endpoints."""
|
|
||||||
|
|
||||||
import duckdb
|
|
||||||
|
|
||||||
from src.db import _ensure_schema
|
|
||||||
from src.setup_banner import build_setup_banner_context
|
|
||||||
|
|
||||||
|
|
||||||
def _auth(token: str) -> dict[str, str]:
|
|
||||||
return {"Authorization": f"Bearer {token}"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_admin_can_set_and_clear_banner(seeded_app):
|
|
||||||
c = seeded_app["client"]
|
|
||||||
admin = _auth(seeded_app["admin_token"])
|
|
||||||
|
|
||||||
# GET initial state
|
|
||||||
r = c.get("/api/admin/setup-banner", headers=admin)
|
|
||||||
assert r.status_code == 200
|
|
||||||
body = r.json()
|
|
||||||
assert body["content"] is None
|
|
||||||
|
|
||||||
# PUT banner
|
|
||||||
r = c.put(
|
|
||||||
"/api/admin/setup-banner",
|
|
||||||
json={"content": "<p>VPN required before install.</p>"},
|
|
||||||
headers=admin,
|
|
||||||
)
|
|
||||||
assert r.status_code == 200
|
|
||||||
|
|
||||||
# GET shows new content
|
|
||||||
r = c.get("/api/admin/setup-banner", headers=admin)
|
|
||||||
assert r.json()["content"] == "<p>VPN required before install.</p>"
|
|
||||||
assert r.json()["updated_by"] is not None
|
|
||||||
|
|
||||||
# DELETE = clear
|
|
||||||
r = c.delete("/api/admin/setup-banner", headers=admin)
|
|
||||||
assert r.status_code == 204
|
|
||||||
|
|
||||||
r = c.get("/api/admin/setup-banner", headers=admin)
|
|
||||||
assert r.json()["content"] is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_non_admin_cannot_edit_banner(seeded_app):
|
|
||||||
c = seeded_app["client"]
|
|
||||||
analyst = _auth(seeded_app["analyst_token"])
|
|
||||||
r = c.put("/api/admin/setup-banner", json={"content": "<p>x</p>"}, headers=analyst)
|
|
||||||
assert r.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
def test_put_rejects_invalid_jinja2(seeded_app):
|
|
||||||
c = seeded_app["client"]
|
|
||||||
admin = _auth(seeded_app["admin_token"])
|
|
||||||
r = c.put(
|
|
||||||
"/api/admin/setup-banner",
|
|
||||||
json={"content": "{% for x in y %}"}, # unclosed loop
|
|
||||||
headers=admin,
|
|
||||||
)
|
|
||||||
assert r.status_code == 400
|
|
||||||
assert "invalid" in r.json()["detail"].lower()
|
|
||||||
|
|
||||||
|
|
||||||
def test_put_rejects_undefined_placeholder(seeded_app):
|
|
||||||
"""Templates that reference unknown placeholders must be rejected at PUT
|
|
||||||
time so the admin sees the error immediately."""
|
|
||||||
c = seeded_app["client"]
|
|
||||||
admin = _auth(seeded_app["admin_token"])
|
|
||||||
r = c.put(
|
|
||||||
"/api/admin/setup-banner",
|
|
||||||
json={"content": "Hello {{ user.emial }}"}, # typo
|
|
||||||
headers=admin,
|
|
||||||
)
|
|
||||||
assert r.status_code == 400
|
|
||||||
assert "emial" in r.json()["detail"] or "undefined" in r.json()["detail"].lower()
|
|
||||||
|
|
||||||
|
|
||||||
def test_preview_renders_arbitrary_content(seeded_app):
|
|
||||||
c = seeded_app["client"]
|
|
||||||
admin = _auth(seeded_app["admin_token"])
|
|
||||||
r = c.post(
|
|
||||||
"/api/admin/setup-banner/preview",
|
|
||||||
json={"content": "<b>Hello {{ user.email }}</b>"},
|
|
||||||
headers=admin,
|
|
||||||
)
|
|
||||||
assert r.status_code == 200
|
|
||||||
# autoescape=True: rendered content must contain the escaped or literal email
|
|
||||||
assert "admin@test.com" in r.json()["content"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_preview_requires_admin(seeded_app):
|
|
||||||
c = seeded_app["client"]
|
|
||||||
analyst = _auth(seeded_app["analyst_token"])
|
|
||||||
r = c.post(
|
|
||||||
"/api/admin/setup-banner/preview",
|
|
||||||
json={"content": "<p>x</p>"},
|
|
||||||
headers=analyst,
|
|
||||||
)
|
|
||||||
assert r.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
def test_preview_rejects_invalid_template(seeded_app):
|
|
||||||
c = seeded_app["client"]
|
|
||||||
admin = _auth(seeded_app["admin_token"])
|
|
||||||
r = c.post(
|
|
||||||
"/api/admin/setup-banner/preview",
|
|
||||||
json={"content": "{% for x in y %}"},
|
|
||||||
headers=admin,
|
|
||||||
)
|
|
||||||
assert r.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
def test_validation_stub_matches_build_context_shape(seeded_app, tmp_path, monkeypatch):
|
|
||||||
"""If build_setup_banner_context grows new keys, _VALIDATION_STUB_CONTEXT
|
|
||||||
must too — otherwise admins can save templates referencing keys the PUT
|
|
||||||
validator accepts but the live render rejects."""
|
|
||||||
from app.api.setup_banner import _VALIDATION_STUB_CONTEXT
|
|
||||||
|
|
||||||
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
|
||||||
db_path = tmp_path / "system.duckdb"
|
|
||||||
conn = duckdb.connect(str(db_path))
|
|
||||||
_ensure_schema(conn)
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
user = {"id": "u1", "email": "admin@test.com", "name": "Admin", "is_admin": True}
|
|
||||||
real_ctx = build_setup_banner_context(user=user, server_url="https://example.com")
|
|
||||||
|
|
||||||
# Top-level keys must match (stub has user=dict, real has user=dict when logged in)
|
|
||||||
assert set(_VALIDATION_STUB_CONTEXT.keys()) == set(real_ctx.keys()), (
|
|
||||||
f"_VALIDATION_STUB_CONTEXT top-level keys differ from build_setup_banner_context output. "
|
|
||||||
f"Stub has: {set(_VALIDATION_STUB_CONTEXT.keys())}, "
|
|
||||||
f"real has: {set(real_ctx.keys())}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# One level deep for nested dicts
|
|
||||||
for key in ("instance", "server", "user"):
|
|
||||||
if isinstance(real_ctx.get(key), dict):
|
|
||||||
assert set(_VALIDATION_STUB_CONTEXT[key].keys()) == set(real_ctx[key].keys()), (
|
|
||||||
f"_VALIDATION_STUB_CONTEXT[{key!r}] drifted from build_setup_banner_context output"
|
|
||||||
)
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
"""v21 → v22 migration: adds setup_banner singleton table."""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import duckdb
|
|
||||||
|
|
||||||
from src.db import SCHEMA_VERSION, _ensure_schema, get_schema_version
|
|
||||||
|
|
||||||
|
|
||||||
def _open(path: Path) -> duckdb.DuckDBPyConnection:
|
|
||||||
return duckdb.connect(str(path))
|
|
||||||
|
|
||||||
|
|
||||||
def test_v22_creates_setup_banner_table(tmp_path):
|
|
||||||
db_path = tmp_path / "system.duckdb"
|
|
||||||
conn = _open(db_path)
|
|
||||||
# Pretend we're on v21: run schema then roll version back.
|
|
||||||
_ensure_schema(conn)
|
|
||||||
conn.execute("UPDATE schema_version SET version = 21")
|
|
||||||
conn.execute("DROP TABLE IF EXISTS setup_banner")
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
# Re-open: migration ladder runs.
|
|
||||||
conn = _open(db_path)
|
|
||||||
_ensure_schema(conn)
|
|
||||||
assert get_schema_version(conn) == SCHEMA_VERSION
|
|
||||||
# Singleton row must exist with NULL content (= no banner).
|
|
||||||
rows = conn.execute(
|
|
||||||
"SELECT id, content, updated_at, updated_by FROM setup_banner"
|
|
||||||
).fetchall()
|
|
||||||
assert len(rows) == 1
|
|
||||||
assert rows[0][0] == 1 # singleton id
|
|
||||||
assert rows[0][1] is None # NULL = no banner
|
|
||||||
|
|
||||||
|
|
||||||
def test_fresh_install_seeds_setup_banner(tmp_path):
|
|
||||||
db_path = tmp_path / "system.duckdb"
|
|
||||||
conn = _open(db_path)
|
|
||||||
_ensure_schema(conn)
|
|
||||||
assert get_schema_version(conn) == SCHEMA_VERSION
|
|
||||||
rows = conn.execute("SELECT id, content FROM setup_banner").fetchall()
|
|
||||||
assert len(rows) == 1
|
|
||||||
assert rows[0][0] == 1
|
|
||||||
assert rows[0][1] is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_welcome_template_unaffected_by_v22(tmp_path):
|
|
||||||
"""welcome_template table must still coexist after v22 migration."""
|
|
||||||
db_path = tmp_path / "system.duckdb"
|
|
||||||
conn = _open(db_path)
|
|
||||||
_ensure_schema(conn)
|
|
||||||
rows = conn.execute("SELECT id, content FROM welcome_template").fetchall()
|
|
||||||
assert len(rows) == 1
|
|
||||||
assert rows[0][0] == 1
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
"""Unit tests for the setup-banner renderer module."""
|
|
||||||
|
|
||||||
import duckdb
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.db import _ensure_schema
|
|
||||||
from src.repositories.setup_banner import SetupBannerRepository
|
|
||||||
from src.setup_banner import _sanitize_banner_html, build_setup_banner_context, render_setup_banner
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def conn(tmp_path, monkeypatch):
|
|
||||||
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
|
||||||
db_path = tmp_path / "system.duckdb"
|
|
||||||
c = duckdb.connect(str(db_path))
|
|
||||||
_ensure_schema(c)
|
|
||||||
yield c
|
|
||||||
c.close()
|
|
||||||
|
|
||||||
|
|
||||||
def _user(email="alice@example.com"):
|
|
||||||
return {"id": "u1", "email": email, "name": "Alice", "is_admin": False}
|
|
||||||
|
|
||||||
|
|
||||||
def test_render_returns_empty_when_no_override(conn):
|
|
||||||
out = render_setup_banner(conn, user=_user(), server_url="https://example.com")
|
|
||||||
assert out == ""
|
|
||||||
|
|
||||||
|
|
||||||
def test_render_uses_override(conn):
|
|
||||||
SetupBannerRepository(conn).set(
|
|
||||||
"<p>VPN: {{ server.hostname }}</p>", updated_by="admin@example.com"
|
|
||||||
)
|
|
||||||
out = render_setup_banner(conn, user=_user(), server_url="https://example.com")
|
|
||||||
# autoescape=True — rendered as HTML
|
|
||||||
assert "example.com" in out
|
|
||||||
assert "<p>" in out
|
|
||||||
|
|
||||||
|
|
||||||
def test_render_returns_empty_on_invalid_template_does_not_raise(conn):
|
|
||||||
"""A broken admin banner must not raise; it must return "" (defense-in-depth)."""
|
|
||||||
SetupBannerRepository(conn).set(
|
|
||||||
"{{ does_not_exist }}", updated_by="admin@example.com"
|
|
||||||
)
|
|
||||||
out = render_setup_banner(conn, user=_user(), server_url="https://example.com")
|
|
||||||
assert out == "" # swallowed, not raised
|
|
||||||
|
|
||||||
|
|
||||||
def test_render_with_anonymous_user(conn):
|
|
||||||
SetupBannerRepository(conn).set(
|
|
||||||
"{% if user %}{{ user.email }}{% else %}anonymous{% endif %}",
|
|
||||||
updated_by="admin@example.com",
|
|
||||||
)
|
|
||||||
out = render_setup_banner(conn, user=None, server_url="https://example.com")
|
|
||||||
assert "anonymous" in out
|
|
||||||
|
|
||||||
|
|
||||||
def test_context_exposes_documented_keys(conn):
|
|
||||||
ctx = build_setup_banner_context(user=_user(), server_url="https://example.com")
|
|
||||||
for top in ("instance", "server", "user", "now", "today"):
|
|
||||||
assert top in ctx, f"missing top-level key: {top}"
|
|
||||||
assert ctx["server"]["hostname"] == "example.com"
|
|
||||||
assert ctx["user"]["email"] == "alice@example.com"
|
|
||||||
|
|
||||||
|
|
||||||
def test_context_with_anonymous_user_returns_none(conn):
|
|
||||||
ctx = build_setup_banner_context(user=None, server_url="https://example.com")
|
|
||||||
assert ctx["user"] is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_autoescape_escapes_html_entities(conn):
|
|
||||||
"""autoescape=True must escape < > & in template variable output."""
|
|
||||||
SetupBannerRepository(conn).set(
|
|
||||||
"{{ server.hostname }}", updated_by="admin@example.com"
|
|
||||||
)
|
|
||||||
out = render_setup_banner(
|
|
||||||
conn, user=_user(), server_url="https://example.com/<test>"
|
|
||||||
)
|
|
||||||
# hostname won't contain < > but the render must succeed without injection
|
|
||||||
assert out != ""
|
|
||||||
|
|
||||||
|
|
||||||
# ── Sanitizer unit tests ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_render_strips_script_tags(conn):
|
|
||||||
"""render_setup_banner must remove <script> blocks from the output."""
|
|
||||||
SetupBannerRepository(conn).set(
|
|
||||||
'<p>Hello</p><script>alert(1)</script>',
|
|
||||||
updated_by="admin@example.com",
|
|
||||||
)
|
|
||||||
out = render_setup_banner(conn, user=_user(), server_url="https://example.com")
|
|
||||||
assert "<script>" not in out
|
|
||||||
assert "alert" not in out
|
|
||||||
# Safe content preserved
|
|
||||||
assert "Hello" in out
|
|
||||||
|
|
||||||
|
|
||||||
def test_render_strips_event_handlers(conn):
|
|
||||||
"""render_setup_banner must strip on* event-handler attributes."""
|
|
||||||
SetupBannerRepository(conn).set(
|
|
||||||
'<button onclick="evil()">Click me</button>',
|
|
||||||
updated_by="admin@example.com",
|
|
||||||
)
|
|
||||||
out = render_setup_banner(conn, user=_user(), server_url="https://example.com")
|
|
||||||
assert "onclick" not in out
|
|
||||||
assert "evil" not in out
|
|
||||||
# Button text preserved
|
|
||||||
assert "Click me" in out
|
|
||||||
|
|
||||||
|
|
||||||
def test_render_strips_javascript_uri(conn):
|
|
||||||
"""render_setup_banner must strip javascript: URI schemes from href/src."""
|
|
||||||
SetupBannerRepository(conn).set(
|
|
||||||
'<a href="javascript:evil()">link</a>',
|
|
||||||
updated_by="admin@example.com",
|
|
||||||
)
|
|
||||||
out = render_setup_banner(conn, user=_user(), server_url="https://example.com")
|
|
||||||
assert "javascript:" not in out
|
|
||||||
assert "evil" not in out
|
|
||||||
# Link text preserved
|
|
||||||
assert "link" in out
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
"""Unit tests for SetupBannerRepository."""
|
|
||||||
|
|
||||||
import duckdb
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.db import _ensure_schema
|
|
||||||
from src.repositories.setup_banner import SetupBannerRepository
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def conn(tmp_path):
|
|
||||||
db_path = tmp_path / "system.duckdb"
|
|
||||||
c = duckdb.connect(str(db_path))
|
|
||||||
_ensure_schema(c)
|
|
||||||
yield c
|
|
||||||
c.close()
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_returns_none_on_fresh_install(conn):
|
|
||||||
repo = SetupBannerRepository(conn)
|
|
||||||
row = repo.get()
|
|
||||||
assert row is not None
|
|
||||||
assert row["content"] is None # no banner by default
|
|
||||||
|
|
||||||
|
|
||||||
def test_set_stores_content(conn):
|
|
||||||
repo = SetupBannerRepository(conn)
|
|
||||||
repo.set("<p>VPN required</p>", updated_by="admin@example.com")
|
|
||||||
row = repo.get()
|
|
||||||
assert row["content"] == "<p>VPN required</p>"
|
|
||||||
assert row["updated_by"] == "admin@example.com"
|
|
||||||
assert row["updated_at"] is not None
|
|
||||||
|
|
||||||
|
|
||||||
def test_reset_clears_content(conn):
|
|
||||||
repo = SetupBannerRepository(conn)
|
|
||||||
repo.set("<p>Note</p>", updated_by="admin@example.com")
|
|
||||||
repo.reset(updated_by="admin@example.com")
|
|
||||||
row = repo.get()
|
|
||||||
assert row["content"] is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_set_overwrites_existing(conn):
|
|
||||||
repo = SetupBannerRepository(conn)
|
|
||||||
repo.set("first", updated_by="a@example.com")
|
|
||||||
repo.set("second", updated_by="b@example.com")
|
|
||||||
row = repo.get()
|
|
||||||
assert row["content"] == "second"
|
|
||||||
assert row["updated_by"] == "b@example.com"
|
|
||||||
Loading…
Reference in a new issue