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, ``) {
+ t.Errorf("og:url не абсолютный: %s", metaLine(body, "og:url"))
+ }
+ if !strings.Contains(body, ``) {
+ t.Errorf("og:title не заголовок страницы: %s", metaLine(body, "og:title"))
+ }
+ if !strings.Contains(body, `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"}}