веб-ui: знак «jb» стал единым для шапки, иконки вкладки и превью ссылки
- логотип сведён в web/static/logo.svg (буквы геометрией, оптическая центровка); прежний favicon с «play» убран - <head> шести страниц свёрнут в партиал head с og-мета; адреса абсолютные, база берётся из запроса, хост из недоверенного заголовка проверяется - растровые ассеты og-cover.jpg и apple-touch-icon.png собирает `task brand` из исходников web/*.html
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 собирает представление страницы загрузки из доменных данных
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// pageMeta — мета-шапка страницы: заголовок вкладки и адреса для превью
|
||||
// ссылки (og:*). Заполняется только на страничных путях; htmx-фрагменты
|
||||
// рендерят те же view с пустым pageMeta и <head> не отдают.
|
||||
type pageMeta struct {
|
||||
Title string // <title> и 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
|
||||
}
|
||||
@@ -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 — загрузки</title>`) {
|
||||
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, `<meta property="og:url" content="https://jellybit.example.org/">`) {
|
||||
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"><script>`,
|
||||
}).Body.String()
|
||||
|
||||
if !strings.Contains(body, `<meta property="og:image" content="/static/og-cover.jpg?v=`) {
|
||||
t.Errorf("порченый хост попал в мету: %s", metaLine(body, "og:image"))
|
||||
}
|
||||
if strings.Contains(body, "<script>") {
|
||||
t.Error("заголовок запроса дотёк в разметку неэкранированным")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrandAssets — знак один на все места: иконка вкладки и шапка ссылаются на
|
||||
// тот же /static/logo.svg, и он отдаётся.
|
||||
func TestBrandAssets(t *testing.T) {
|
||||
h := testRouter(t, stubReader{}, stubReviewer{})
|
||||
|
||||
body := get(t, h, "/").Body.String()
|
||||
if strings.Count(body, "/static/logo.svg") != 2 {
|
||||
t.Errorf("ожидались две ссылки на знак (icon + шапка), получено %d",
|
||||
strings.Count(body, "/static/logo.svg"))
|
||||
}
|
||||
if strings.Contains(body, "favicon.svg") {
|
||||
t.Error("осталась ссылка на прежний favicon.svg")
|
||||
}
|
||||
|
||||
for _, p := range []string{"/static/logo.svg", "/static/og-cover.jpg", "/static/apple-touch-icon.png"} {
|
||||
if code := get(t, h, p).Code; code != http.StatusOK {
|
||||
t.Errorf("GET %s = %d, want 200", p, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// metaLine — строка ответа с искомым мета-тегом (для внятного отчёта теста).
|
||||
func metaLine(body, prop string) string {
|
||||
for _, l := range strings.Split(body, "\n") {
|
||||
if strings.Contains(l, prop) {
|
||||
return strings.TrimSpace(l)
|
||||
}
|
||||
}
|
||||
return "(тега нет)"
|
||||
}
|
||||
@@ -10,11 +10,15 @@ import (
|
||||
"git.vakhrushev.me/av/jellybit/web"
|
||||
)
|
||||
|
||||
// assetVersion — короткий хеш изменяемых ассетов (css/js) для cache-busting.
|
||||
// Шрифты и вендор адресуются по неизменному имени, их версионировать не нужно.
|
||||
// assetVersion — короткий хеш изменяемых ассетов (css/js, знак и карточка
|
||||
// превью) для cache-busting. Шрифты и вендор адресуются по неизменному имени,
|
||||
// их версионировать не нужно.
|
||||
func assetVersion() (string, error) {
|
||||
h := sha256.New()
|
||||
for _, p := range []string{"static/css/jellybit.css", "static/js/app.js"} {
|
||||
for _, p := range []string{
|
||||
"static/css/jellybit.css", "static/js/app.js",
|
||||
"static/logo.svg", "static/og-cover.jpg",
|
||||
} {
|
||||
b, err := web.FS.ReadFile(p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -37,6 +37,7 @@ type Reviewer interface {
|
||||
// --- Представление страницы ревью ---
|
||||
|
||||
type reviewView struct {
|
||||
Meta pageMeta
|
||||
ID string
|
||||
Source string
|
||||
Context string
|
||||
@@ -99,7 +100,9 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
s.render(w, "review.html", buildReviewView(id, rd, r.URL.Query().Get("err")))
|
||||
view := buildReviewView(id, rd, r.URL.Query().Get("err"))
|
||||
view.Meta = s.pageMetaFor(r, "jellybit — ревью #"+id)
|
||||
s.render(w, "review.html", view)
|
||||
}
|
||||
|
||||
// buildReviewView собирает представление страницы ревью из доменных данных.
|
||||
|
||||
Reference in New Issue
Block a user