"""Server-rendered share pages (/p/<token>, /r/<token>).
Telegram, Jira and other link-preview scrapers do not run JavaScript, so the
SPA's index.html alone shows nothing about a project or report. Caddy proxies
these two paths to the API, which injects Open Graph / Twitter meta tags
(title, description, creation date, screenshot, video) into the panel's
index.html and returns it — real visitors get the normal SPA, scrapers get
the tags.
"""
import html
import re
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import settings
from ..db import get_session
from ..models import Attachment, Project, Report, User
router = APIRouter(tags=["share-pages"])
_DESCRIPTION_LIMIT = 300
def _base_url(request: Request) -> str:
"""Public origin of the panel: PUBLIC_BASE_URL if set, else what the
client asked for (caddy sets X-Forwarded-Proto on the proxied request)."""
if settings.public_base_url:
return settings.public_base_url.rstrip("/")
proto = request.headers.get("x-forwarded-proto", "http")
host = request.headers.get("host", "localhost")
return f"{proto}://{host}"
def _read_index() -> str | None:
path = Path(settings.web_dist_dir) / "index.html"
try:
return path.read_text(encoding="utf-8")
except OSError:
return None
def _meta(name: str, content: str, *, property_: bool = False) -> str:
attr = "property" if property_ else "name"
return f'<meta {attr}="{html.escape(name)}" content="{html.escape(content)}">'
def _page(title: str, description: str, url: str, image: str | None, video: str | None) -> HTMLResponse:
"""index.html with the SPA title and the link-preview tags swapped in.
The relative /assets/... paths in the served index.html resolve against
this origin, and caddy still serves those files statically.
"""
raw = _read_index()
metas = "\n ".join(
[
_meta("description", description),
_meta("og:title", title, property_=True),
_meta("og:description", description, property_=True),
_meta("og:url", url, property_=True),
_meta("og:site_name", "BugTrail", property_=True),
_meta("og:type", "website", property_=True),
_meta("twitter:card", "summary_large_image" if image else "summary"),
]
+ ([_meta("og:image", image, property_=True), _meta("twitter:image", image)] if image else [])
+ ([_meta("og:video", video, property_=True), _meta("og:video:type", "video/webm", property_=True)] if video else [])
)
if raw is not None:
page = re.sub(r"<title>.*?</title>", f"<title>{html.escape(title)}</title>", raw, count=1, flags=re.S)
return HTMLResponse(page.replace("<head>", f"<head>\n {metas}", 1))
# panel dist not mounted (dev setups): a self-contained stub with the same tags
return HTMLResponse(
"<!doctype html><html><head>"
f'<meta charset="utf-8"><title>{html.escape(title)}</title>\n {metas}'
f'<meta http-equiv="refresh" content="0;url={html.escape(url)}">'
"</head><body></body></html>"
)
def _clip(text: str | None) -> str | None:
if not text:
return None
text = " ".join(text.split())
return text[:_DESCRIPTION_LIMIT - 1] + "…" if len(text) > _DESCRIPTION_LIMIT else text
def _fmt_date(value: datetime) -> str:
return value.astimezone(timezone.utc).strftime("%d.%m.%Y")
@router.get("/p/{token}", response_class=HTMLResponse)
async def project_page(token: str, request: Request, db: AsyncSession = Depends(get_session)):
result = await db.execute(select(Project).where(Project.share_token == token))
project = result.scalar_one_or_none()
if project is None:
return _page("BugTrail", "Project not found", str(request.url), None, None)
url = f"{_base_url(request)}/p/{token}"
description = project.description or "Bug reports for this project, collected with BugTrail."
description += f" Created {_fmt_date(project.created_at)}."
return _page(f"{project.name} · BugTrail", description, url, None, None)
@router.get("/r/{token}", response_class=HTMLResponse)
async def report_page(token: str, request: Request, db: AsyncSession = Depends(get_session)):
result = await db.execute(select(Report).where(Report.share_token == token))
report = result.scalar_one_or_none()
if report is None:
return _page("BugTrail", "Report not found", str(request.url), None, None)
base = _base_url(request)
url = f"{base}/r/{token}"
author = await db.get(User, report.author_user_id)
parts = [_clip(report.description)]
if report.page_url or report.page_title:
parts.append(report.page_title or report.page_url)
parts.append(f"Created {_fmt_date(report.created_at)} by {author.nickname}")
description = " · ".join(p for p in parts if p) or "Bug report captured with BugTrail."
attachments = (
await db.execute(
select(Attachment)
.where(Attachment.report_id == report.id)
.order_by(Attachment.created_at.asc())
)
).scalars().all()
screenshot = next((a for a in attachments if a.mime.startswith("image/")), None)
video = next((a for a in attachments if a.mime.startswith("video/")), None)
image = f"{base}/api/reports/by-token/{token}/files/{screenshot.file_id}" if screenshot else None
video_url = f"{base}/api/reports/by-token/{token}/files/{video.file_id}" if video else None
return _page(f"{report.title} · BugTrail", description, url, image, video_url)