diff --git a/README.md b/README.md index 7a3d68a..30b0bd1 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ | Часть | Стек | Где | |---|---|---| | Сервер (API) | Python 3.13, FastAPI, SQLAlchemy async, Postgres 16, Alembic | `server/` (Docker) | -| Веб-панель | Vue 3, vue-router, vue-i18n (en/ru), [gnexus-ui-kit](https://git.gnexus.space/git/root/gnexus-ui-kit.git) | `packages/web` | +| Веб-панель | Vue 3, vue-router, vue-i18n (en/ru/uk), [gnexus-ui-kit](https://git.gnexus.space/git/root/gnexus-ui-kit.git) | `packages/web` | | Общий код UI | Аннотации на скриншотах, контекст элемента | `packages/ui` | | Расширение | Chrome + Firefox (MV3), Vite, closed shadow-DOM overlay | `packages/extension` | | Общие типы/клиент API | TypeScript | `packages/shared` | @@ -96,6 +96,7 @@ | `HTTP_PORT` | `8081` | порт панели на хосте | | `HTTPS_PORT` | `8443` | порт HTTPS на хосте | | `SITE_ADDRESS` | `:80` | адрес сайта для caddy | +| `PUBLIC_BASE_URL` | — | публичный origin (`https://bugtrail.gnexus.space`) для абсолютных `og:image`/`og:url` в превью ссылок; по умолчанию берётся из заголовков запроса | Домен + автоматический HTTPS (caddy сам выпустит сертификат). Релизный адрес — `https://bugtrail.gnexus.space`: @@ -129,6 +130,9 @@ - PK — UUIDv7; публичные ссылки — случайные 128-битные токены (`/p/`, `/r/`). - Токен = авторизация: страницы проекта и репорта открываются без логина. - Ротация токена: кнопка в панели (`POST .../share/rotate`). +- Превью ссылок (Telegram, Jira): `/p/*` и `/r/*` проксируются caddy на API, + который подставляет в SPA-шелл og-метатеги — заголовок, описание, дата + создания, скриншот/видео репорта. ## Структура diff --git a/deploy/Caddyfile b/deploy/Caddyfile index 5eccbdc..8e82ecd 100644 --- a/deploy/Caddyfile +++ b/deploy/Caddyfile @@ -2,6 +2,14 @@ handle /api/* { reverse_proxy server:8000 } + # share links go through the API: it injects og:* meta tags into the SPA + # shell so Telegram/Jira link previews show the project/report + handle /p/* { + reverse_proxy server:8000 + } + handle /r/* { + reverse_proxy server:8000 + } # extension zips: real files only — no SPA fallback, a missing zip is a 404 handle /ext/* { root * /srv/web diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index a5e5f90..6ee62fa 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -6,6 +6,9 @@ # HTTP_PORT — host port for the panel (default: 8081) # SITE_ADDRESS — Caddy site address; set to a DNS name for # automatic HTTPS (default: :80) +# PUBLIC_BASE_URL — optional public origin (https://bugtrail.gnexus.space) +# for absolute og:image/og:url links in share-page +# previews; derived from the request host when unset services: postgres: image: postgres:16-alpine @@ -26,11 +29,16 @@ environment: DATABASE_URL: postgresql+asyncpg://ltt:${POSTGRES_PASSWORD:-ltt}@postgres:5432/ltt FILES_DIR: /srv/data/files + WEB_DIST_DIR: /srv/web + # optional: public origin for absolute og:image/og:url links in share + # previews; by default it is derived from the request host + PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-} depends_on: postgres: condition: service_healthy volumes: - ./data:/srv/data + - ./packages/web/dist:/srv/web:ro caddy: image: caddy:2-alpine diff --git a/packages/web/src/i18n/index.ts b/packages/web/src/i18n/index.ts index 50f1c6d..74a1fdb 100644 --- a/packages/web/src/i18n/index.ts +++ b/packages/web/src/i18n/index.ts @@ -1,12 +1,24 @@ import { createI18n } from "vue-i18n"; import en from "./en.json"; import ru from "./ru.json"; +import uk from "./uk.json"; const STORAGE_KEY = "ltt.locale"; -export const SUPPORTED_LOCALES = ["en", "ru"] as const; +export const SUPPORTED_LOCALES = ["en", "ru", "uk"] as const; export type Locale = (typeof SUPPORTED_LOCALES)[number]; +/** Slavic three-form plural: one / few (2-4) / many — shared by ru and uk. */ +function slavicPluralRules(choice: number, choicesLength: number): number { + const mod10 = choice % 10; + const mod100 = choice % 100; + if (mod10 === 1 && mod100 !== 11) return 0; + if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) { + return Math.min(1, choicesLength - 1); + } + return choicesLength - 1; +} + function initialLocale(): Locale { try { const saved = localStorage.getItem(STORAGE_KEY) as Locale | null; @@ -22,19 +34,12 @@ legacy: false, locale: initialLocale(), fallbackLocale: "en", - messages: { en, ru }, - // vue-i18n's default rule is English-only; without this, Russian plurals - // like "1 проекта" come out wrong + messages: { en, ru, uk }, + // vue-i18n's default rule is English-only; without this, Russian and + // Ukrainian plurals like "1 проекта" come out wrong pluralRules: { - ru: (choice, choicesLength) => { - const mod10 = choice % 10; - const mod100 = choice % 100; - if (mod10 === 1 && mod100 !== 11) return 0; - if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) { - return Math.min(1, choicesLength - 1); - } - return choicesLength - 1; - }, + ru: slavicPluralRules, + uk: slavicPluralRules, }, }); diff --git a/packages/web/src/i18n/uk.json b/packages/web/src/i18n/uk.json new file mode 100644 index 0000000..fa9cb9b --- /dev/null +++ b/packages/web/src/i18n/uk.json @@ -0,0 +1,198 @@ +{ + "app": { + "name": "BugTrail", + "tagline": "Баг-репорти, зрозумілі розробнику" + }, + "nav": { + "projects": "Проєкти", + "settings": "Налаштування", + "download": "Розширення", + "logout": "Вийти", + "login": "Увійти", + "register": "Реєстрація" + }, + "auth": { + "loginTitle": "Вхід", + "registerTitle": "Створити акаунт", + "nickname": "Нік", + "email": "Пошта", + "password": "Пароль", + "submit": "Увійти", + "registerSubmit": "Створити акаунт", + "needAccount": "Ще немає акаунта?", + "haveAccount": "Вже є акаунт?", + "welcome": "Вітаємо, {name}", + "emailTaken": "Ця пошта вже зареєстрована", + "passwordHint": "Мінімум 8 символів", + "error": "Помилка входу. Перевірте дані." + }, + "projects": { + "title": "Проєкти", + "count": "{count} проєкт | {count} проєкти | {count} проєктів", + "empty": "Проєктів ще немає", + "emptyHint": "Створіть проєкт, щоб почати збирати баг-репорти", + "create": "Новий проєкт", + "createTitle": "Створити проєкт", + "name": "Назва проєкту", + "description": "Опис", + "cancel": "Скасувати", + "confirm": "Створити", + "reports": "{count} репортів, {open} відкритих", + "open": "Відкрити сторінку проєкту", + "share": "Копіювати посилання", + "delete": "Видалити", + "deleteConfirm": "Видалити проєкт разом з усіма репортами?", + "deleted": "Проєкт видалено", + "renamed": "Проєкт оновлено", + "search": "Пошук проєктів" + }, + "reports": { + "title": "Репорти", + "empty": "Репортів ще немає", + "emptyHint": "Встановіть розширення для браузера й зафіксуйте перший баг", + "type": { + "element_note": "Нотатка на елементі", + "recording": "Запис сценарію" + }, + "status": { + "open": "Відкритий", + "fixed": "Виправлений", + "wont_fix": "Не буде виправлений" + }, + "columns": { + "title": "Заголовок", + "type": "Тип", + "status": "Статус", + "author": "Автор", + "created": "Створено", + "page": "Сторінка" + }, + "filterType": "Тип", + "filterStatus": "Статус", + "allTypes": "Усі типи", + "allStatuses": "Усі статуси", + "notFound": "Репорт не знайдено", + "notFoundHint": "Посилання недійсне або репорт видалено" + }, + "report": { + "copyLink": "Копіювати посилання на репорт", + "rotateLink": "Оновити посилання", + "status": "Статус", + "details": "Деталі", + "steps": "Кроки", + "attachments": "Вкладення", + "description": "Опис", + "noDescription": "Опис не заповнено", + "elementContext": "Контекст елемента", + "environment": "Оточення", + "screenshot": "Скріншот", + "screenRecording": "Запис екрана", + "noAttachments": "Вкладень немає", + "editAnnotations": "Редагувати анотації", + "savingAnnotations": "Збереження…", + "openFullscreen": "Відкрити на весь екран", + "saveAnnotations": "Зберегти анотації", + "deleteReport": "Видалити репорт", + "deleteConfirm": "Видалити цей репорт назавжди?", + "backToProject": "До проєкту", + "page": "Сторінка", + "created": "Створено", + "author": "Автор", + "attachment": "Вкладення", + "download": "Завантажити", + "noSteps": "Записаних кроків немає", + "stepTypes": { + "click": "Клік", + "input": "Введення", + "url_change": "Зміна URL", + "navigation": "Перехід", + "note": "Нотатка", + "screenshot": "Скріншот", + "console": "Помилка консолі" + }, + "replay": "Почати реплей", + "replayNoExtension": "Розширення BugTrail не знайдено в цьому браузері — встановіть його й оновіть сторінку", + "replayNoResponse": "Розширення не відповіло — перевірте, що в ньому виконано вхід", + "replayDone": "Реплей завершено: {played}/{total} кроків", + "replayPartialTitle": "Реплей завершено: {played}/{total} кроків", + "replayPartialText": "{failed} кроків не виконалося — сторінка або елементи могли змінитися", + "replayFailed": "Не вдалося запустити реплей", + "aiPrompt": "Промпт для ШІ", + "aiPromptTitle": "Промпт для ШІ-агента", + "aiPromptHint": "За потреби відредагуйте текст, потім скопіюйте та передайте його ШІ-агенту.", + "aiPromptCopy": "Копіювати промпт", + "ai": { + "intro": "Ти — ШІ-агент, який виправляє баги у вебзастосунку. Нижче звіт про баг, створений тестувальником через розширення BugTrail.", + "bugSection": "Баг", + "title": "Заголовок", + "pageSection": "Сторінка", + "url": "URL", + "pageTitle": "Заголовок сторінки", + "elementSection": "Елемент", + "tag": "Тег", + "id": "ID", + "classes": "CSS-класи", + "selector": "CSS-селектор", + "uniqueSelector": "Унікальний CSS-селектор", + "text": "Текст", + "ariaLabel": "ARIA-мітка", + "rect": "Положення та розмір", + "testAttributes": "Тестові атрибути", + "envSection": "Оточення", + "browser": "Браузер", + "os": "ОС", + "viewport": "В'юпорт", + "dpr": "Device pixel ratio", + "language": "Мова", + "userAgent": "User agent", + "stepsSection": "Записані кроки", + "attachmentsSection": "Вкладення", + "screenshot": "Скріншот", + "reportLink": "Посилання на звіт", + "taskSection": "Завдання", + "taskText": "Відтвори баг за інформацією вище, знайди причину в кодовій базі та виправ її. Зміни мають бути мінімальними й точковими; наприкінці поясни, що і чому ти змінив." + } + }, + "download": { + "title": "Розширення BugTrail", + "subtitle": "Нотатки на елементах зі скріншотами та запис кроків відтворення просто на сторінці.", + "chrome": "Chrome / Chromium", + "firefox": "Firefox", + "downloadZip": "Завантажити", + "downloadHint": "Розпакуйте архів у папку.", + "chromeSteps": [ + "Завантажте та розпакуйте архів.", + "Відкрийте chrome://extensions.", + "Увімкніть режим розробника (справа зверху).", + "Натисніть «Завантажити розпаковане» та виберіть розпаковану папку." + ], + "firefoxSteps": [ + "Завантажте та розпакуйте архів.", + "Відкрийте about:debugging#/runtime/this-firefox.", + "Натисніть «Завантажити тимчасове доповнення…».", + "Виберіть manifest.json усередині розпакованої папки.", + "Доповнення живе до перезапуску Firefox — після перезапуску завантажте його заново або попросіть адміністратора підписану збірку." + ], + "setupTitle": "Налаштування", + "setupOpen": "Відкрийте налаштування розширення (сторінка розширення в браузері).", + "setupServer": "Вкажіть адресу сервера — адресу цієї панелі:", + "setupSignIn": "Увійдіть під своїм акаунтом BugTrail і виберіть проєкт за замовчуванням.", + "usageTitle": "Використання", + "usageNote": "Alt+Shift+B — нотатка на елементі (пікер → скріншот → анотації → коментар). Alt+Shift+R — старт/стоп запису: кліки, введення, переходи, скріншоти ключових кроків додаються автоматично." + }, + "settings": { + "title": "Налаштування", + "profile": "Профіль", + "language": "Мова інтерфейсу", + "languageHint": "Застосовується одразу; зберігається в цьому браузері", + "profileHint": "Редагування профілю — через розширення та API акаунта" + }, + "common": { + "loading": "Завантаження…", + "error": "Щось пішло не так", + "cancel": "Скасувати", + "close": "Закрити", + "save": "Зберегти", + "edit": "Змінити" + } +} \ No newline at end of file diff --git a/packages/web/src/pages/SettingsPage.vue b/packages/web/src/pages/SettingsPage.vue index 2ff36b4..4d70bd8 100644 --- a/packages/web/src/pages/SettingsPage.vue +++ b/packages/web/src/pages/SettingsPage.vue @@ -12,7 +12,7 @@ const language = ref(locale.value as Locale); const languageOptions = SUPPORTED_LOCALES.map((l) => ({ value: l, - label: l === "en" ? "English" : "Русский", + label: l === "en" ? "English" : l === "ru" ? "Русский" : "Українська", })); function onLanguageChange(value: string) { diff --git a/server/app/config.py b/server/app/config.py index 9e65a4c..a81dac5 100644 --- a/server/app/config.py +++ b/server/app/config.py @@ -11,6 +11,11 @@ files_dir: Path = Path("./data/files") secret_key: str = "change-me" + # share-page meta tags: where the built panel lives (index.html) and the + # public origin used for absolute og:image/og:url links + web_dist_dir: Path = Path("/srv/web") + public_base_url: str = "" + # sessions web_session_ttl_days: int = 30 extension_session_ttl_days: int = 180 diff --git a/server/app/main.py b/server/app/main.py index 67eba95..7213f03 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -2,7 +2,7 @@ from fastapi.middleware.cors import CORSMiddleware from .config import settings -from .routers import auth, projects, reports, uploads +from .routers import auth, projects, reports, share_pages, uploads def create_app() -> FastAPI: @@ -22,6 +22,9 @@ app.include_router(projects.router, prefix="/api") app.include_router(reports.router, prefix="/api") app.include_router(uploads.router, prefix="/api") + # share links are served by the API so link previews (Telegram, Jira) get + # real meta tags; caddy routes /p and /r here + app.include_router(share_pages.router) @app.get("/api/healthz") async def healthz() -> dict: diff --git a/server/app/routers/share_pages.py b/server/app/routers/share_pages.py new file mode 100644 index 0000000..362b333 --- /dev/null +++ b/server/app/routers/share_pages.py @@ -0,0 +1,135 @@ +"""Server-rendered share pages (/p/, /r/). + +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'' + + +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".*?", f"{html.escape(title)}", raw, count=1, flags=re.S) + return HTMLResponse(page.replace("", f"\n {metas}", 1)) + # panel dist not mounted (dev setups): a self-contained stub with the same tags + return HTMLResponse( + "" + f'{html.escape(title)}\n {metas}' + f'' + "" + ) + + +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) \ No newline at end of file