Newer
Older
bugtrail / server / app / routers / share_pages.py
"""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)