diff --git a/Taskfile.yml b/Taskfile.yml index f44b8eb..5919781 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -75,6 +75,25 @@ tasks: echo "$sum $file" | sha256sum -c - done < web/assets.manifest + brand: + desc: 'Пересборка растровых брендовых ассетов из web/*.html (нужны chromium и magick; вручную, не в сборке)' + deps: [assets] + cmds: + - | + set -e + command -v chromium >/dev/null || { echo "нет chromium — растр не собрать"; exit 1; } + command -v magick >/dev/null || { echo "нет magick — растр не сжать"; exit 1; } + mkdir -p tmp/brand + chromium --headless=new --disable-gpu --hide-scrollbars \ + --screenshot=tmp/brand/og-cover.png --window-size=1200,630 \ + "file://$PWD/web/og-cover.html" + magick tmp/brand/og-cover.png -strip -quality 90 web/static/og-cover.jpg + chromium --headless=new --disable-gpu --hide-scrollbars \ + --screenshot=tmp/brand/touch-icon.png --window-size=180,180 \ + "file://$PWD/web/touch-icon.html" + magick tmp/brand/touch-icon.png -strip web/static/apple-touch-icon.png + echo "✓ web/static/og-cover.jpg, web/static/apple-touch-icon.png" + image: desc: 'Docker-образ из готового бинаря. Тег из $BUILD_ID (по умолчанию dev; роль app_image umbar передаёт свой). См. docs/adr local-image-build' deps: [build] diff --git a/docs/conventions/web-ui.md b/docs/conventions/web-ui.md index c41cded..7afacc3 100644 --- a/docs/conventions/web-ui.md +++ b/docs/conventions/web-ui.md @@ -189,3 +189,19 @@ htmx по умолчанию **не свопит DOM на ответы 4xx/5xx** `task build`/`task run` зависят от `task assets`. - Шрифты и скрипты — **self-hosted**, без внешних хостов: бинарь самодостаточен, внешних ресурсов времени выполнения нет. + +## Знак и превью ссылки + +- Знак один на все места — `web/static/logo.svg`: иконка вкладки (`rel="icon"`), + логотип в шапке (``) и карточка превью собираются из него. + Второй картинки «для favicon» не заводим: она молча разъезжается с шапкой. +- Буквы «jb» заданы геометрией, а не текстом: favicon рендерится браузером без + наших шрифтов, и `` в нём взял бы чужой. +- Растровые ассеты (`og-cover.jpg` 1200×630 для превью ссылки, + `apple-touch-icon.png` 180×180) в PNG/JPEG не правятся руками: исходники — + `web/og-cover.html` и `web/touch-icon.html`, пересборка — `task brand` + (нужны `chromium` и `magick`, в сборку и гейт не входит). +- `` страниц собирает единственный партиал `head` (`partials/head.html`); + адреса для `og:*` абсолютные, база берётся из запроса — `pageMeta` + (`internal/httpapi/page.go`). Хост приходит из недоверенного заголовка, поэтому + форма проверяется, а на непохожем значении база пустеет до относительной. diff --git a/internal/httpapi/bulkdelete.go b/internal/httpapi/bulkdelete.go index 21163b9..aa1d0b8 100644 --- a/internal/httpapi/bulkdelete.go +++ b/internal/httpapi/bulkdelete.go @@ -64,6 +64,7 @@ type bulkRow struct { // bulkSelectView — страница выбора (`GET /delete`) и она же ответ на отказ // разбора: отметки при этом сохраняются, иначе проверка стирает всю работу. type bulkSelectView struct { + Meta pageMeta Error string Max int Rows []bulkRow @@ -71,12 +72,14 @@ type bulkSelectView struct { // bulkConfirmView — страница подтверждения: выбранные названы поимённо. type bulkConfirmView struct { + Meta pageMeta Rows []bulkRow } // bulkResultView — отчёт: обе половины исхода поимённо плюс остаток, если // проход остановлен системным отказом. type bulkResultView struct { + Meta pageMeta Deleted []bulkRow Failed []bulkRow Skipped []bulkRow @@ -114,6 +117,7 @@ func (s *server) renderBulkSelect(w http.ResponseWriter, r *http.Request, msg st LastCopy: d.State == store.StateOrphaned, }) } + view.Meta = s.pageMetaFor(r, "jellybit — удаление") s.render(w, "delete.html", view) } @@ -125,7 +129,10 @@ func (s *server) handleBulkDeleteConfirm(w http.ResponseWriter, r *http.Request) s.renderBulkSelect(w, r, bulkErrMsg(err), ids) return } - s.render(w, "delete_confirm.html", bulkConfirmView{Rows: s.bulkRows(r.Context(), ids)}) + s.render(w, "delete_confirm.html", bulkConfirmView{ + Meta: s.pageMetaFor(r, "jellybit — подтверждение удаления"), + Rows: s.bulkRows(r.Context(), ids), + }) } // handleBulkDelete — исполнение пачки. Признак подтверждения проверяется ДО @@ -204,6 +211,7 @@ func (s *server) handleBulkDelete(w http.ResponseWriter, r *http.Request) { "failed", len(res.Failed), "skipped", len(res.Skipped), "stopped", res.StopReason != "") + res.Meta = s.pageMetaFor(r, "jellybit — результат удаления") s.render(w, "delete_result.html", res) } diff --git a/internal/httpapi/download.go b/internal/httpapi/download.go index cb0e598..01ab6f5 100644 --- a/internal/httpapi/download.go +++ b/internal/httpapi/download.go @@ -14,6 +14,7 @@ import ( // --- Страница просмотра одной загрузки --- type downloadDetailView struct { + Meta pageMeta ID string Title string SourceType string // тип источника (magnet/torrent/url) — блок «Информация о торренте» @@ -107,7 +108,9 @@ func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) { http.Error(w, "внутренняя ошибка", http.StatusInternalServerError) return } - s.render(w, "download.html", s.buildDownloadView(id, rd)) + view := s.buildDownloadView(id, rd) + view.Meta = s.pageMetaFor(r, "jellybit — загрузка #"+id) + s.render(w, "download.html", view) } // buildDownloadView собирает представление страницы загрузки из доменных данных diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index ea8d56a..c8ac785 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -175,6 +175,7 @@ func handleHealthz(w http.ResponseWriter, _ *http.Request) { const pageSize = 25 type indexView struct { + Meta pageMeta Error string Downloads []downloadView @@ -318,6 +319,7 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) { for _, d := range downloads { view.Downloads = append(view.Downloads, s.buildCardView(d, now, layoutSizes[d.ID])) } + view.Meta = s.pageMetaFor(r, "jellybit — загрузки") s.render(w, "index.html", view) } diff --git a/internal/httpapi/page.go b/internal/httpapi/page.go new file mode 100644 index 0000000..9e0865f --- /dev/null +++ b/internal/httpapi/page.go @@ -0,0 +1,77 @@ +package httpapi + +import ( + "net/http" + "strings" +) + +// pageMeta — мета-шапка страницы: заголовок вкладки и адреса для превью +// ссылки (og:*). Заполняется только на страничных путях; htmx-фрагменты +// рендерят те же view с пустым pageMeta и не отдают. +type pageMeta struct { + Title string // и og:title + URL string // адрес страницы (og:url) + Image string // адрес карточки превью (og:image) +} + +// pageMetaFor собирает мету страницы. Адреса делаются абсолютными: краулер +// мессенджера не обязан разрешать относительный og:image, а без картинки +// превью ссылки вырождается в голый заголовок. +func (s *server) pageMetaFor(r *http.Request, title string) pageMeta { + base := absBase(r) + return pageMeta{ + Title: title, + URL: base + r.URL.EscapedPath(), + Image: base + "/static/og-cover.jpg?v=" + s.assetVer, + } +} + +// absBase — база адреса («https://host»), какой её видит клиент: за обратным +// прокси схему и хост несут X-Forwarded-Proto/X-Forwarded-Host, иначе берём +// соединение и Host запроса. Значение уходит только в мета-теги превью, +// доменных решений на нём не строится. Хост непохожей формы отбрасываем +// целиком: пустая база оставляет в мете корректный относительный адрес. +func absBase(r *http.Request) string { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + switch v := firstForwarded(r.Header.Get("X-Forwarded-Proto")); v { + case "http", "https": + scheme = v + } + host := r.Host + if v := firstForwarded(r.Header.Get("X-Forwarded-Host")); v != "" { + host = v + } + if !validHost(host) { + return "" + } + return scheme + "://" + host +} + +// firstForwarded берёт первое значение списка «a, b, c»: цепочка прокси +// дописывает свои значения справа, клиенту ближе левое. +func firstForwarded(v string) string { + if i := strings.IndexByte(v, ','); i >= 0 { + v = v[:i] + } + return strings.TrimSpace(v) +} + +// validHost — проверка хоста из недоверенного заголовка: допустимы только +// знаки имени, порта и скобок IPv6. +func validHost(host string) bool { + if host == "" || len(host) > 255 { + return false + } + for _, c := range host { + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9': + case c == '.', c == '-', c == '_', c == ':', c == '[', c == ']': + default: + return false + } + } + return true +} diff --git a/internal/httpapi/page_test.go b/internal/httpapi/page_test.go new file mode 100644 index 0000000..855454a --- /dev/null +++ b/internal/httpapi/page_test.go @@ -0,0 +1,106 @@ +package httpapi + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// getWith — GET с заголовками (за обратным прокси мету считает не Host). +func getWith(t *testing.T, h http.Handler, path string, hdr map[string]string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + for k, v := range hdr { + req.Header.Set(k, v) + } + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + return rr +} + +// TestPageMetaAbsolute — превью ссылки: адреса картинки и страницы абсолютные, +// иначе краулер мессенджера картинку не заберёт. +func TestPageMetaAbsolute(t *testing.T) { + h := testRouter(t, stubReader{}, stubReviewer{}) + + body := get(t, h, "/").Body.String() + if !strings.Contains(body, `<meta property="og:image" content="http://example.com/static/og-cover.jpg?v=`) { + t.Errorf("og:image не абсолютный: %s", metaLine(body, "og:image")) + } + if !strings.Contains(body, `<meta property="og:url" content="http://example.com/">`) { + t.Errorf("og:url не абсолютный: %s", metaLine(body, "og:url")) + } + if !strings.Contains(body, `<meta property="og:title" content="jellybit — загрузки">`) { + t.Errorf("og:title не заголовок страницы: %s", metaLine(body, "og:title")) + } + if !strings.Contains(body, `<title>jellybit — загрузки`) { + t.Error("заголовок вкладки потерян") + } +} + +// TestPageMetaBehindProxy — за прокси схему и хост несут X-Forwarded-*; +// из списка значений берётся первое (ближнее к клиенту). +func TestPageMetaBehindProxy(t *testing.T) { + h := testRouter(t, stubReader{}, stubReviewer{}) + + body := getWith(t, h, "/", map[string]string{ + "X-Forwarded-Proto": "https, http", + "X-Forwarded-Host": "jellybit.example.org, internal", + }).Body.String() + + if !strings.Contains(body, `content="https://jellybit.example.org/static/og-cover.jpg?v=`) { + t.Errorf("og:image мимо прокси: %s", metaLine(body, "og:image")) + } + if !strings.Contains(body, ``) { + t.Errorf("og:url мимо прокси: %s", metaLine(body, "og:url")) + } +} + +// TestPageMetaBadHost — хост непохожей формы отбрасывается целиком: в мете +// остаётся относительный адрес, чужой хост в превью не подставляется. +func TestPageMetaBadHost(t *testing.T) { + h := testRouter(t, stubReader{}, stubReviewer{}) + + body := getWith(t, h, "/", map[string]string{ + "X-Forwarded-Host": `evil.example.org"> diff --git a/web/templates/index.html b/web/templates/index.html index 91cab01..ad6e15f 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -1,11 +1,7 @@ - - - jellybit — загрузки - - +{{template "head" .Meta}} diff --git a/web/templates/partials/head.html b/web/templates/partials/head.html new file mode 100644 index 0000000..8652b8a --- /dev/null +++ b/web/templates/partials/head.html @@ -0,0 +1,22 @@ +{{define "head"}} + + + {{.Title}} + + + + + {{/* Превью ссылки в мессенджере: карточка со знаком jb. Адреса абсолютные — + краулер не обязан разрешать относительные (см. pageMeta). */}} + + + + + + + + + + + +{{end}} diff --git a/web/templates/partials/header.html b/web/templates/partials/header.html index 4a9a855..e7ada89 100644 --- a/web/templates/partials/header.html +++ b/web/templates/partials/header.html @@ -1,7 +1,7 @@ {{define "header"}}
- jellybit + jellybit