diff --git a/.gitignore b/.gitignore
index 3ff9027..8636d33 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@ data/
vault/
/hyperhack
/dist/
+*:Zone.Identifier
diff --git a/SPEC.md b/SPEC.md
index ebb1f0b..8e991c5 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -42,8 +42,8 @@ The author writes in Obsidian and publishes by syncing the vault to the bucket.
| Users | One author. No registration. Public side is read-only. |
| Backend | Go 1.27, latest stable (go1.27.3 on 2026-11-03). `go.mod`: `go 1.27` + `toolchain go1.27.3`. Builder image: `golang:1.27`. Check go.dev/dl before starting; Dependabot keeps it current. |
| Frontend | Server-side rendering with `html/template`. No framework. Minimal vanilla JS: votes, ALTCHA, view and time beacon. |
-| Themes | Light and dark, toggle in the top bar. System theme by default until the visitor picks one. The choice is stored in a cookie and rendered on the server (no inline JS). |
-| Design | Beautiful, minimal, responsive. Designed during implementation. |
+| Themes | Light and dark, toggle in the top bar. System theme by default until the visitor picks one. The choice is stored in a cookie and rendered on the server (no inline JS). The toggle is a form that posts to `/prefs`, so it works without JS. |
+| Design | Beautiful, minimal, responsive. Designed during implementation. Fonts: Inter (text) and JetBrains Mono (code), OFL, self-hosted, Latin + Cyrillic subsets. |
| Languages | Code, docs, UI: English. UI also in Russian. Default: `DEFAULT_LANG` (`en` / `ru`) in `.env`. Visitor switch in the top bar; the choice is stored in a cookie (the server must know it to render the page). Post text is not translated. |
| Storage | Garage (S3 API), path-style addressing. Client: `github.com/minio/minio-go/v7`. |
| Post format | One `.md` = one post. Metadata: Obsidian properties (YAML frontmatter). |
@@ -118,6 +118,7 @@ Proposed variables (final names set during implementation):
## Bucket layout
- `assets/` — all assets: covers, images in posts. Decided.
+- `assets/logo.svg` — optional site logo in the top bar. Without it the service shows a built-in mark.
- The service ignores keys where any path segment starts with `.` or `_` (same rule as Remotely Save).
### Posts
@@ -258,7 +259,7 @@ The service stores per-post metrics only. Storage: SQLite.
### Estimated reading time
- Public: shown on the post (e.g. "5 min read").
-- Computed from the post text at render time: word count / `READING_WPM`.
+- Computed from the post text at render time: word count / `READING_WPM`, rounded up, at least 1 minute.
- Proposed: code blocks and images are counted separately.
- Not related to measured time on page.
@@ -415,6 +416,8 @@ Trust boundaries: internet → Caddy → service; bucket → service (semi-trust
| `/reindex` | POST | private network | Caddy block, `REINDEX_ALLOW`, `REINDEX_TOKEN` |
| `/metrics` | GET | private network | Caddy block, `METRICS_ALLOW` |
| `/healthz` | GET | public | constant body, no data |
+| `/prefs` | POST | public | form body ≤ 4 KB, enum check for `theme` / `lang`, redirect only to a local path |
+| `/static/*` | GET | public | embedded CSS and fonts only, no directory listing |
Other methods → `405`. Unknown paths → `404`.
@@ -606,3 +609,4 @@ Other methods → `405`. Unknown paths → `404`.
- 2026-11-03 — Go 1.27 (go1.27.3).
- 2026-11-03 — Config defaults: `POLL_INTERVAL` 60s, `READING_WPM` 200, `LOG_LEVEL` info, `LOG_FORMAT` json, `DATA_DIR` /data, `VAULT_DIR` /vault, `ALTCHA_MAX_NUMBER` 100000, `S3_REGION` garage. Log component `main`. The service listens on fixed port 8080.
- 2026-11-03 — Sync: S3 client `minio-go/v7`, frontmatter via `go.yaml.in/yaml/v3`, GFM. Mirror state in `VAULT_DIR/.hyperhack.json`. S3 down at startup: serve from the mirror; exit 1 only with no mirror. No Remotely Save prefix. Keys with a `.` or `_` segment are ignored (was proposed). `MAX_OBJECTS` counts all keys. An invalid frontmatter field skips the post. 3 S3 attempts per poll. `/reindex` waits for the sync and returns `204`.
+- 2026-11-04 — Pages: theme and language switch via a no-JS form `POST /prefs`; cookies `theme` / `lang` (`HttpOnly; SameSite=Lax; Max-Age=1 year`, `Secure` on https). Logo from `assets/logo.svg`, built-in mark otherwise. Fonts Inter + JetBrains Mono, self-hosted. Pagination `/?page=N`: page 1 is `/`, other out-of-range values → `404`. `` from the post; the top bar keeps the UI language. CSS and fonts at `/static/*`.
diff --git a/SPEC.ru.md b/SPEC.ru.md
index 813015a..ee5e937 100644
--- a/SPEC.ru.md
+++ b/SPEC.ru.md
@@ -42,8 +42,8 @@
| Пользователи | Один автор. Регистрации нет. Публичная часть только читает. |
| Backend | Go 1.27, последняя стабильная (go1.27.3 на 2026-11-03). `go.mod`: `go 1.27` + `toolchain go1.27.3`. Образ сборки: `golang:1.27`. Перед стартом проверить go.dev/dl; дальше версию обновляет Dependabot. |
| Frontend | Серверный рендер через `html/template`. Без фреймворка. Минимум vanilla JS: голоса, ALTCHA, beacon просмотров и времени. |
-| Темы | Светлая и тёмная, переключатель в верхнем баре. По умолчанию тема системы, пока читатель не выбрал свою. Выбор хранится в cookie и рендерится на сервере (без inline JS). |
-| Дизайн | Красивый, минималистичный, адаптивный. Делается на этапе реализации. |
+| Темы | Светлая и тёмная, переключатель в верхнем баре. По умолчанию тема системы, пока читатель не выбрал свою. Выбор хранится в cookie и рендерится на сервере (без inline JS). Переключатель — форма, которая отправляет `/prefs`, поэтому работает без JS. |
+| Дизайн | Красивый, минималистичный, адаптивный. Делается на этапе реализации. Шрифты: Inter (текст) и JetBrains Mono (код), OFL, свои, наборы латиница + кириллица. |
| Языки | Код, доки, интерфейс — английский. Интерфейс также на русском. По умолчанию — `DEFAULT_LANG` (`en` / `ru`) в `.env`. Переключатель для читателя в верхнем баре; выбор хранится в cookie (сервер должен знать язык для рендера). Текст постов не переводится. |
| Хранилище | Garage (S3 API), path-style адресация. Клиент: `github.com/minio/minio-go/v7`. |
| Формат поста | Один `.md` = один пост. Метаданные — свойства Obsidian (YAML frontmatter). |
@@ -118,6 +118,7 @@
## Структура бакета
- `assets/` — все ассеты: обложки, картинки в постах. Решено.
+- `assets/logo.svg` — необязательный логотип сайта в верхнем баре. Без него сервис показывает встроенный знак.
- Сервис игнорирует ключи, где любой сегмент пути начинается с `.` или `_` (как Remotely Save).
### Посты
@@ -258,7 +259,7 @@ Garage не шлёт уведомления об изменениях в бак
### Расчётное время чтения
- Публично: показывается на посте (например, «5 мин чтения»).
-- Считается из текста поста при рендере: число слов / `READING_WPM`.
+- Считается из текста поста при рендере: число слов / `READING_WPM`, с округлением вверх, минимум 1 минута.
- Предложено: блоки кода и картинки учитываются отдельно.
- Не связано с измеренным временем на странице.
@@ -415,6 +416,8 @@ Garage не шлёт уведомления об изменениях в бак
| `/reindex` | POST | приватная сеть | блок в Caddy, `REINDEX_ALLOW`, `REINDEX_TOKEN` |
| `/metrics` | GET | приватная сеть | блок в Caddy, `METRICS_ALLOW` |
| `/healthz` | GET | публичный | постоянный ответ, без данных |
+| `/prefs` | POST | публичный | тело формы ≤ 4 КБ, проверка перечислений `theme` / `lang`, редирект только на локальный путь |
+| `/static/*` | GET | публичный | только встроенные CSS и шрифты, без листинга папок |
Другие методы → `405`. Неизвестные пути → `404`.
@@ -606,3 +609,4 @@ Garage не шлёт уведомления об изменениях в бак
- 2026-11-03 — Go 1.27 (go1.27.3).
- 2026-11-03 — Значения по умолчанию: `POLL_INTERVAL` 60s, `READING_WPM` 200, `LOG_LEVEL` info, `LOG_FORMAT` json, `DATA_DIR` /data, `VAULT_DIR` /vault, `ALTCHA_MAX_NUMBER` 100000, `S3_REGION` garage. Компонент логов `main`. Сервис слушает фиксированный порт 8080.
- 2026-11-03 — Синхронизация: клиент S3 `minio-go/v7`, frontmatter через `go.yaml.in/yaml/v3`, GFM. Состояние зеркала в `VAULT_DIR/.hyperhack.json`. S3 недоступен при старте: работаем из зеркала; выход с кодом 1 только без зеркала. Без префикса Remotely Save. Ключи с сегментом на `.` или `_` игнорируются (было предложено). `MAX_OBJECTS` считает все ключи. Неверное поле frontmatter — пост пропускается. 3 попытки S3 за опрос. `/reindex` ждёт синхронизацию и возвращает `204`.
+- 2026-11-04 — Страницы: тема и язык переключаются формой без JS `POST /prefs`; cookie `theme` / `lang` (`HttpOnly; SameSite=Lax; Max-Age=1 год`, `Secure` на https). Логотип из `assets/logo.svg`, иначе встроенный знак. Шрифты Inter + JetBrains Mono, свои. Пагинация `/?page=N`: страница 1 — это `/`, другие значения вне диапазона → `404`. `` из поста; верхний бар остаётся на языке интерфейса. CSS и шрифты на `/static/*`.
diff --git a/internal/content/content_test.go b/internal/content/content_test.go
index e3ff52b..2e3ac66 100644
--- a/internal/content/content_test.go
+++ b/internal/content/content_test.go
@@ -31,6 +31,9 @@ func TestParse(t *testing.T) {
if strings.Join(p.Tags, ",") != "go,selfhost" {
t.Errorf("tags = %q", p.Tags)
}
+ if p.Words != 2 {
+ t.Errorf("words = %d, want 2", p.Words)
+ }
if !strings.Contains(string(p.HTML), "world") {
t.Errorf("html = %s", p.HTML)
}
diff --git a/internal/content/index.go b/internal/content/index.go
index e6c0a91..2564854 100644
--- a/internal/content/index.go
+++ b/internal/content/index.go
@@ -10,6 +10,7 @@ import (
type Index struct {
posts []*Post // newest first
bySlug map[string]*Post
+ Logo bool // assets/logo.svg is in the mirror
}
// NewIndex builds an index. Drafts are left out.
diff --git a/internal/content/post.go b/internal/content/post.go
index 66cd9f7..177b4f9 100644
--- a/internal/content/post.go
+++ b/internal/content/post.go
@@ -28,6 +28,7 @@ type Post struct {
Cover string // key inside assets/, empty if not set
Lang string
Draft bool
+ Words int // body word count, for reading time
HTML template.HTML // sanitized
}
@@ -127,6 +128,7 @@ func (r *Renderer) Parse(key string, src []byte) (*Post, error) {
return nil, errors.New("no slug: set slug in frontmatter")
}
}
+ p.Words = len(bytes.Fields(body))
if p.HTML, err = r.render(body); err != nil {
return nil, fmt.Errorf("render: %w", err)
}
diff --git a/internal/server/pages.go b/internal/server/pages.go
new file mode 100644
index 0000000..5a05be6
--- /dev/null
+++ b/internal/server/pages.go
@@ -0,0 +1,317 @@
+package server
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "embed"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "io/fs"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "path"
+ "slices"
+ "strconv"
+ "strings"
+ "time"
+
+ chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
+ "github.com/alecthomas/chroma/v2/styles"
+
+ "github.com/Mimic890/hyperhack/internal/config"
+ "github.com/Mimic890/hyperhack/internal/content"
+)
+
+//go:embed web
+var webFS embed.FS
+
+var (
+ langs = []string{"en", "ru"}
+ themes = []string{"light", "dark"}
+)
+
+const cookieMaxAge = 365 * 24 * 60 * 60
+
+type site struct {
+ Name string
+ GitHub string
+ Logo bool
+}
+
+// pageData is what every template gets. Fields a page does not use stay zero.
+type pageData struct {
+ Lang string // UI language
+ DocLang string // : the post language on post pages
+ Theme string // "", "light" or "dark"
+ Path string // current URI, for the prefs form
+ CSS string
+ Site site
+
+ Posts []*content.Post
+ Page, Pages int
+
+ Post *content.Post
+
+ Title, Text string // i18n keys for the error page
+}
+
+type pages struct {
+ log *slog.Logger
+ cfg *config.Config
+ index func() *content.Index
+ tmpl map[string]map[string]*template.Template // lang → page → template
+ css []byte
+ cssV string
+ fonts fs.FS
+}
+
+func newPages(log *slog.Logger, cfg *config.Config, index func() *content.Index) *pages {
+ msgs := map[string]map[string]string{}
+ for _, l := range langs {
+ b, err := webFS.ReadFile("web/i18n/" + l + ".json")
+ if err != nil {
+ panic(err)
+ }
+ m := map[string]string{}
+ if err := json.Unmarshal(b, &m); err != nil {
+ panic(fmt.Sprintf("i18n %s: %v", l, err))
+ }
+ msgs[l] = m
+ }
+
+ p := &pages{log: log, cfg: cfg, index: index, tmpl: map[string]map[string]*template.Template{}}
+ for _, l := range langs {
+ p.tmpl[l] = map[string]*template.Template{}
+ for _, name := range []string{"home", "post", "error"} {
+ t := template.Must(template.New("").Funcs(funcs(msgs[l], cfg.ReadingWPM)).
+ ParseFS(webFS, "web/templates/layout.html", "web/templates/"+name+".html"))
+ p.tmpl[l][name] = t
+ }
+ }
+
+ base, err := webFS.ReadFile("web/static/style.css")
+ if err != nil {
+ panic(err)
+ }
+ p.css = append(base, themedChromaCSS()...)
+ sum := sha256.Sum256(p.css)
+ p.cssV = "/static/style.css?v=" + hex.EncodeToString(sum[:6])
+ p.fonts, err = fs.Sub(webFS, "web/static/fonts")
+ if err != nil {
+ panic(err)
+ }
+ return p
+}
+
+func funcs(msgs map[string]string, wpm int) template.FuncMap {
+ t := func(key string, kv ...any) string {
+ s, ok := msgs[key]
+ if !ok {
+ return key
+ }
+ for i := 0; i+1 < len(kv); i += 2 {
+ s = strings.ReplaceAll(s, "{"+fmt.Sprint(kv[i])+"}", fmt.Sprint(kv[i+1]))
+ }
+ return s
+ }
+ return template.FuncMap{
+ "t": t,
+ "date": func(d time.Time) string {
+ return t("date", "day", d.Day(), "month", t("month_"+strconv.Itoa(int(d.Month()))), "year", d.Year())
+ },
+ "minutes": func(words int) int { return max(1, (words+wpm-1)/wpm) },
+ "asset": func(key string) string { return (&url.URL{Path: "/" + key}).EscapedPath() },
+ "add": func(a, b int) int { return a + b },
+ }
+}
+
+// themedChromaCSS scopes the light and dark code palettes to the effective theme,
+// the same way style.css scopes its tokens.
+func themedChromaCSS() []byte {
+ var b bytes.Buffer
+ for _, th := range []struct{ name, style, other string }{{"light", "github", "dark"}, {"dark", "github-dark", "light"}} {
+ b.WriteString(chromaCSS(th.style, `:root[data-theme="`+th.name+`"]`))
+ b.WriteString("@media (prefers-color-scheme: " + th.name + ") {\n")
+ b.WriteString(chromaCSS(th.style, `:root:not([data-theme="`+th.other+`"])`))
+ b.WriteString("}\n")
+ }
+ return b.Bytes()
+}
+
+// chromaCSS writes the token rules of a chroma style under scope.
+// The wrapper and .bg rules are dropped: style.css sets the code block background.
+func chromaCSS(style, scope string) string {
+ var buf strings.Builder
+ if err := chromahtml.New(chromahtml.WithClasses(true)).WriteCSS(&buf, styles.Get(style)); err != nil {
+ panic(err)
+ }
+ var out strings.Builder
+ for line := range strings.Lines(buf.String()) {
+ _, rule, ok := strings.Cut(line, "*/ ")
+ if !ok || !strings.HasPrefix(rule, ".chroma .") {
+ continue
+ }
+ sel, body, _ := strings.Cut(rule, "{")
+ var scoped []string
+ for s := range strings.SplitSeq(sel, ",") {
+ scoped = append(scoped, scope+" "+strings.TrimSpace(s))
+ }
+ out.WriteString(strings.Join(scoped, ", ") + " {" + body)
+ }
+ return out.String()
+}
+
+func (p *pages) data(r *http.Request) pageData {
+ d := pageData{
+ Lang: p.cfg.DefaultLang,
+ Path: r.URL.RequestURI(),
+ CSS: p.cssV,
+ Site: site{Name: p.cfg.SiteName, GitHub: p.cfg.GitHubURL},
+ }
+ if c, err := r.Cookie("lang"); err == nil && slices.Contains(langs, c.Value) {
+ d.Lang = c.Value
+ }
+ if c, err := r.Cookie("theme"); err == nil && slices.Contains(themes, c.Value) {
+ d.Theme = c.Value
+ }
+ d.DocLang = d.Lang
+ if ix := p.index(); ix != nil {
+ d.Site.Logo = ix.Logo
+ }
+ return d
+}
+
+func (p *pages) render(w http.ResponseWriter, status int, name string, d pageData) {
+ var buf bytes.Buffer
+ if err := p.tmpl[d.Lang][name].ExecuteTemplate(&buf, "layout.html", d); err != nil {
+ p.log.Error("template", "page", name, "err", err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ _, _ = w.Write(buf.Bytes())
+}
+
+func (p *pages) errorPage(w http.ResponseWriter, r *http.Request, status int) {
+ d := p.data(r)
+ d.Title, d.Text = "not_found", "not_found_text"
+ if status == http.StatusServiceUnavailable {
+ d.Title, d.Text = "not_ready", "not_ready_text"
+ w.Header().Set("Retry-After", "30")
+ }
+ p.render(w, status, "error", d)
+}
+
+func (p *pages) home(w http.ResponseWriter, r *http.Request) {
+ ix := p.index()
+ if ix == nil {
+ p.errorPage(w, r, http.StatusServiceUnavailable)
+ return
+ }
+ posts := ix.Posts()
+ size := p.cfg.PageSize
+ d := p.data(r)
+ d.Pages = max(1, (len(posts)+size-1)/size)
+ d.Page = 1
+ if s := r.URL.Query().Get("page"); s != "" {
+ n, err := strconv.Atoi(s)
+ if err != nil || n < 2 || n > d.Pages { // page 1 lives at "/"
+ p.errorPage(w, r, http.StatusNotFound)
+ return
+ }
+ d.Page = n
+ }
+ start := (d.Page - 1) * size
+ d.Posts = posts[start:min(start+size, len(posts))]
+ p.render(w, http.StatusOK, "home", d)
+}
+
+func (p *pages) post(w http.ResponseWriter, r *http.Request) {
+ ix := p.index()
+ if ix == nil {
+ p.errorPage(w, r, http.StatusServiceUnavailable)
+ return
+ }
+ post := ix.Get(r.PathValue("slug"))
+ if post == nil {
+ p.errorPage(w, r, http.StatusNotFound)
+ return
+ }
+ d := p.data(r)
+ d.Post = post
+ d.DocLang = post.Lang
+ p.render(w, http.StatusOK, "post", d)
+}
+
+func (p *pages) notFound(w http.ResponseWriter, r *http.Request) {
+ p.errorPage(w, r, http.StatusNotFound)
+}
+
+// prefs handles the top-bar form: it sets the theme or lang cookie and goes back.
+func (p *pages) prefs(w http.ResponseWriter, r *http.Request) {
+ r.Body = http.MaxBytesReader(w, r.Body, 4<<10)
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+ return
+ }
+ set := func(name string, allowed []string) bool {
+ v := r.PostForm.Get(name)
+ if v == "" {
+ return true
+ }
+ if !slices.Contains(allowed, v) {
+ return false
+ }
+ http.SetCookie(w, &http.Cookie{
+ Name: name, Value: v, Path: "/", MaxAge: cookieMaxAge,
+ HttpOnly: true, Secure: p.cfg.SiteURL.Scheme == "https", SameSite: http.SameSiteLaxMode,
+ })
+ return true
+ }
+ if !set("theme", themes) || !set("lang", langs) {
+ http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+ return
+ }
+ http.Redirect(w, r, localPath(r.PostForm.Get("back")), http.StatusSeeOther)
+}
+
+// localPath returns back if it is a path on this site, else "/". This blocks open redirects.
+func localPath(back string) string {
+ if len(back) > 2048 || !strings.HasPrefix(back, "/") || strings.HasPrefix(back, "//") || strings.HasPrefix(back, `/\`) {
+ return "/"
+ }
+ u, err := url.Parse(back)
+ if err != nil || u.Scheme != "" || u.Host != "" || u.User != nil {
+ return "/"
+ }
+ for _, c := range back {
+ if c < 0x20 || c == 0x7f {
+ return "/"
+ }
+ }
+ return back
+}
+
+func (p *pages) style(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "text/css; charset=utf-8")
+ w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") // URL carries a content hash
+ _, _ = w.Write(p.css)
+}
+
+func (p *pages) font(w http.ResponseWriter, r *http.Request) {
+ name := r.PathValue("file")
+ if ext := path.Ext(name); ext != ".woff2" && ext != ".txt" {
+ p.errorPage(w, r, http.StatusNotFound)
+ return
+ }
+ if _, err := fs.Stat(p.fonts, name); err != nil {
+ p.errorPage(w, r, http.StatusNotFound)
+ return
+ }
+ w.Header().Set("Cache-Control", "public, max-age=2592000")
+ http.ServeFileFS(w, r, p.fonts, name)
+}
diff --git a/internal/server/pages_test.go b/internal/server/pages_test.go
new file mode 100644
index 0000000..2bd298c
--- /dev/null
+++ b/internal/server/pages_test.go
@@ -0,0 +1,254 @@
+package server
+
+import (
+ "encoding/json"
+ "html/template"
+ "log/slog"
+ "maps"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "slices"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/Mimic890/hyperhack/internal/config"
+ "github.com/Mimic890/hyperhack/internal/content"
+)
+
+func testConfig() *config.Config {
+ u, _ := url.Parse("https://example.com")
+ return &config.Config{SiteName: "Test ", SiteURL: u, DefaultLang: "en", ReadingWPM: 200, PageSize: 2}
+}
+
+func testIndex(n int) *content.Index {
+ var posts []*content.Post
+ for i := range n {
+ posts = append(posts, &content.Post{
+ Key: string(rune('a'+i)) + ".md", Slug: "post-" + string(rune('a'+i)), Title: "Post " + string(rune('A'+i)),
+ Date: time.Date(2026, 11, 1+i, 0, 0, 0, 0, time.UTC), Lang: "en", Words: 450, HTML: "