Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ Post text…
| `date` | date | yes | Publish date. "Newest" is sorted by it. |
| `updated` | date | no | Last update. Shown on the post and in RSS. |
| `tags` | list | no | Tags. A single string also works. |
| `cover` | string | no | Cover image, a file inside `assets/`. |
| `cover` | string | no | Cover image, a file inside `assets/`. Shown as a full-width banner above the title, cropped to a wide strip around the center: keep the subject in the middle. |
| `slug` | string | no | URL part. Default: made from the file name. |
| `lang` | string | no | Post language, two lower-case letters (`en`, `ru`). Default: `DEFAULT_LANG`. |
| `draft` | bool | no | `true` keeps the post unpublished. Default `false`. |
Expand Down
2 changes: 1 addition & 1 deletion README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ draft: false
| `date` | дата | да | Дата публикации. По ней сортируется «Новое». |
| `updated` | дата | нет | Дата обновления. Показывается в посте и в RSS. |
| `tags` | список | нет | Теги. Одна строка тоже подходит. |
| `cover` | строка | нет | Обложка, файл внутри `assets/`. |
| `cover` | строка | нет | Обложка, файл внутри `assets/`. Показывается баннером на всю ширину над заголовком и обрезается до широкой полосы вокруг центра: держите главное посередине. |
| `slug` | строка | нет | Часть адреса. По умолчанию — из имени файла. |
| `lang` | строка | нет | Язык поста, две строчные буквы (`en`, `ru`). По умолчанию `DEFAULT_LANG`. |
| `draft` | bool | нет | `true` — пост не публикуется. По умолчанию `false`. |
Expand Down
3 changes: 2 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ The author writes in Obsidian and publishes by syncing the vault to the bucket.
### Post page

- Markdown from S3 rendered to HTML.
- Cover (`cover`), if set.
- Cover (`cover`), if set: a banner above the title, right under the top bar, full page width (not the text column). Fixed height `min(30vh, 320px)`, `min(25vh, 200px)` on narrow screens; `object-fit: cover`, centered; no rounded corners. Title, dates and tags stay below it in the text column. The `<img>` keeps `width`/`height`. No cover: no banner.
- Estimated reading time.
- View count.
- Likes and dislikes.
Expand Down Expand Up @@ -617,3 +617,4 @@ Other methods → `405`. Unknown paths → `404`.
- 2026-11-04 — Metrics, votes, limits: SQLite `DATA_DIR/hyperhack.db` (`modernc.org/sqlite` `v1.61.0`, `0600`, WAL). All-time counters per post and label (Grafana gets daily trends with `increase()`), daily views only for "Popular" (pruned after `POPULAR_DAYS`). Search queries: live requests from `search.js` (`X-Search: live`) are not stored, the settled query is; TTL 90 days, at most 10 000 rows, top 20 in `/metrics`, refreshed every 5 min. ALTCHA: `altcha-lib-go/v2` `v2.4.1`, v2 challenge with SHA-256, cost 1, `uint32` counter up to `ALTCHA_MAX_NUMBER`, key signature; own small solver `static/altcha.js` instead of the widget; signing keys derived from `ALTCHA_HMAC_KEY`; replay map capped at 100 000. Beacon: a delta on every hide and `pagehide`, sent with `fetch(…, {keepalive: true})` (JSON body), each with a random per-page-view id; the server sums time per view and clamps the sum to 4 h; time on page = average over views with reading time. Views and read depth: once per visitor per post per day; daily hashes in memory only (cap 200 000), `ip_prefix` = IPv4 address or IPv6 `/64`. Source `internal` for referrers from the site itself. Outbound hosts limited to links in the post. `/api/altcha` and `/api/vote` share `RATE_LIMIT_VOTE`; limiter tracks at most 10 000 clients. Vote cookie stored as SHA-256. "Popular" after "Newest" on page 1. `/metrics` in Prometheus text format written with the standard library.
- 2026-11-04 — Grafana, Caddy, README: dashboard `grafana/hyperhack.json` for a Prometheus-type data source on VictoriaMetrics (uses the VictoriaMetrics function `sort_by_label_numeric`); daily bars cover the day that starts at the bar, so today is shown. `Caddyfile.example` for Caddy `v2.11.7` with `caddy-ratelimit` `v0.1.0` (1200 requests per minute per client IP; `/static/*` and `/assets/*` are not counted) and `caddy-crowdsec-bouncer` `v0.14.1`; `/metrics` and `/reindex` (and their subpaths) answer `404` on the public domain; compression `zstd`/`gzip`. README: behind Caddy on the host, `TRUSTED_PROXIES` is the gateway of the Compose network. Full README in English and Russian.
- 2026-11-04 — Production Compose: image registry GHCR (confirmed). `docker-compose.yml` only pulls the released image, no `build:`, pinned to the latest release (`0.1.0`; image tags are the git tag without `v`). Local builds in `compose.dev.yml`, used only with `-f`; no `docker-compose.override.yml`. README Quick start uses the downloaded `docker-compose.yml` as is, and README documents upgrades and rollback.
- 2026-11-04 — Post cover as a full-width banner between the top bar and the text column: height `min(30vh, 320px)` (`min(25vh, 200px)` on screens up to 30rem), `object-fit: cover` centered, no rounded corners, `width`/`height` kept. Rendered from a `banner` block in the layout, outside `<main>`. Posts without cover unchanged.
3 changes: 2 additions & 1 deletion SPEC.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@
### Пост

- Рендер Markdown из S3.
- Обложка (`cover`), если задана.
- Обложка (`cover`), если задана: баннер над заголовком, сразу под верхней панелью, на всю ширину страницы (не колонки текста). Высота фиксированная: `min(30vh, 320px)`, на узких экранах `min(25vh, 200px)`; `object-fit: cover`, по центру; без скруглений. Заголовок, даты и теги остаются под ним в колонке текста. У `<img>` сохраняются `width`/`height`. Без обложки баннера нет.
- Расчётное время чтения.
- Счётчик просмотров.
- Лайки и дизлайки.
Expand Down Expand Up @@ -617,3 +617,4 @@ Garage не шлёт уведомления об изменениях в бак
- 2026-11-04 — Метрики, голоса, лимиты: SQLite `DATA_DIR/hyperhack.db` (`modernc.org/sqlite` `v1.61.0`, `0600`, WAL). Счётчики за всё время по посту и метке (дневные тренды Grafana считает через `increase()`), дневные просмотры — только для «Популярных» (удаляются старше `POPULAR_DAYS`). Поисковые запросы: живые запросы `search.js` (`X-Search: live`) не сохраняются, сохраняется устоявшийся запрос; TTL 90 дней, не больше 10 000 строк, топ-20 в `/metrics`, обновляется раз в 5 минут. ALTCHA: `altcha-lib-go/v2` `v2.4.1`, задача v2 с SHA-256, cost 1, счётчик `uint32` до `ALTCHA_MAX_NUMBER`, подпись ключа; свой маленький решатель `static/altcha.js` вместо виджета; ключи подписи выводятся из `ALTCHA_HMAC_KEY`; карта повторов ограничена 100 000. Beacon: дельта при каждом скрытии и `pagehide`, отправка через `fetch(…, {keepalive: true})` (тело JSON), в каждой — случайный id просмотра страницы; сервер суммирует время по просмотру и ограничивает сумму 4 ч; время на странице — среднее по просмотрам со временем чтения. Просмотры и глубина чтения: один раз на посетителя, пост и день; суточные хеши только в памяти (не больше 200 000), `ip_prefix` — адрес IPv4 или `/64` IPv6. Источник `internal` для переходов с самого сайта. Внешние домены — только из ссылок поста. `/api/altcha` и `/api/vote` делят `RATE_LIMIT_VOTE`; лимитер помнит не больше 10 000 клиентов. Cookie голоса хранится как SHA-256. «Популярные» после «Новых» на первой странице. `/metrics` в текстовом формате Prometheus, написан на стандартной библиотеке.
- 2026-11-04 — Grafana, Caddy, README: дашборд `grafana/hyperhack.json` для источника данных типа Prometheus на VictoriaMetrics (использует функцию VictoriaMetrics `sort_by_label_numeric`); дневной столбец покрывает день, который начинается в его точке, поэтому сегодняшний день виден. `Caddyfile.example` для Caddy `v2.11.7` с `caddy-ratelimit` `v0.1.0` (1200 запросов в минуту на IP клиента; `/static/*` и `/assets/*` не учитываются) и `caddy-crowdsec-bouncer` `v0.14.1`; `/metrics` и `/reindex` (и их подпути) на публичном домене отвечают `404`; сжатие `zstd`/`gzip`. README: если Caddy на хосте, `TRUSTED_PROXIES` — шлюз сети Compose. Полный README на английском и русском.
- 2026-11-04 — Продакшен Compose: реестр образов GHCR (подтверждено). `docker-compose.yml` только скачивает выпущенный образ, без `build:`, закреплён последний релиз (`0.1.0`; тег образа — git-тег без `v`). Локальная сборка в `compose.dev.yml`, только через `-f`; без `docker-compose.override.yml`. Быстрый старт в README использует скачанный `docker-compose.yml` без правок, README описывает обновление и откат.
- 2026-11-04 — Обложка поста — баннер на всю ширину между верхней панелью и колонкой текста: высота `min(30vh, 320px)` (`min(25vh, 200px)` на экранах до 30rem), `object-fit: cover` по центру, без скруглений, `width`/`height` сохраняются. Выводится из блока `banner` в шаблоне страницы, вне `<main>`. Посты без обложки не меняются.
13 changes: 11 additions & 2 deletions internal/server/seo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,17 @@ func TestPostHead(t *testing.T) {
t.Errorf("JSON-LD = %v", ld)
}

// No cover: DEFAULT_OG_IMAGE.
mustContain(t, s.get("/posts/zametka").Body.String(), `<meta property="og:image" content="https://example.com/assets/og.png">`, `<html lang="ru"`)
// The cover is a banner between the top bar and main, outside the text column.
if c, h, m := strings.Index(body, `class="cover"`), strings.Index(body, "</header>"), strings.Index(body, `<main id="main">`); !(h < c && c < m) {
t.Errorf("cover at %d, want between top bar (%d) and main (%d)", c, h, m)
}

// No cover: DEFAULT_OG_IMAGE, no banner.
plain := s.get("/posts/zametka").Body.String()
mustContain(t, plain, `<meta property="og:image" content="https://example.com/assets/og.png">`, `<html lang="ru"`)
if strings.Contains(plain, `class="cover"`) {
t.Error("banner on a post without cover")
}
}

func TestMarkdownSource(t *testing.T) {
Expand Down
4 changes: 3 additions & 1 deletion internal/server/web/static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,8 @@ main { max-width: 44rem; margin: 0 auto; padding: 2.5rem 1.25rem 4rem; }
/* Post page */
.post > header h1 { margin: 0; font-size: clamp(1.875rem, 4vw + 1rem, 2.625rem); line-height: 1.15; font-weight: 700; letter-spacing: -0.025em; }
.post > header .meta { margin-top: 0.75rem; }
.cover { display: block; width: 100%; margin: 2rem 0 0; border-radius: 12px; }
/* Banner between the top bar and main, full page width. The fixed height keeps the page from jumping. */
.cover { display: block; width: 100%; height: min(30vh, 320px); object-fit: cover; object-position: center; background: var(--surface); }
.prose { position: relative; margin-top: 2rem; overflow-wrap: break-word; }
/* Read-depth markers for the post script (IntersectionObserver). */
.depth { position: absolute; left: 0; width: 1px; height: 1px; pointer-events: none; }
Expand Down Expand Up @@ -204,6 +205,7 @@ code, pre { font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo,
@media (max-width: 30rem) {
.topbar { padding: 0.75rem 1rem; }
main { padding: 1.75rem 1rem 3rem; }
.cover { height: min(25vh, 200px); }
.brand span { font-size: 1rem; }
.search input { flex-basis: 100%; }
.search select { flex: 1; max-width: none; }
Expand Down
1 change: 1 addition & 0 deletions internal/server/web/templates/layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
{{- end}}
</nav>
</header>
{{- block "banner" .}}{{end}}
<main id="main">
{{block "main" .}}{{end}}
</main>
Expand Down
6 changes: 3 additions & 3 deletions internal/server/web/templates/post.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
{{define "title"}}{{.Post.Title}} — {{.Site.Name}}{{end}}
{{define "banner"}}{{with .Post}}{{if .Cover}}
<img class="cover" src="{{asset .Cover}}" alt=""{{if .CoverW}} width="{{.CoverW}}" height="{{.CoverH}}"{{end}}>
{{- end}}{{end}}{{end}}
{{define "main"}}
{{- $d := .}}
{{- with .Post}}
Expand All @@ -10,9 +13,6 @@ <h1>{{.Title}}</h1>
· {{t "min_read" "n" (minutes .Words)}} · {{t "views" "n" $d.Views}}</p>
{{- template "tags" .Tags}}
</header>
{{- if .Cover}}
<img class="cover" src="{{asset .Cover}}" alt=""{{if .CoverW}} width="{{.CoverW}}" height="{{.CoverH}}"{{end}}>
{{- end}}
<div class="prose">
{{.HTML}}
<span class="depth d25" data-depth="25" aria-hidden="true"></span><span class="depth d50" data-depth="50" aria-hidden="true"></span><span class="depth d75" data-depth="75" aria-hidden="true"></span><span class="depth d100" data-depth="100" aria-hidden="true"></span>
Expand Down
Loading