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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ data/
vault/
/hyperhack
/dist/
*:Zone.Identifier
10 changes: 7 additions & 3 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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`.

Expand Down Expand Up @@ -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`. `<html lang>` from the post; the top bar keeps the UI language. CSS and fonts at `/static/*`.
10 changes: 7 additions & 3 deletions SPEC.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down Expand Up @@ -118,6 +118,7 @@
## Структура бакета

- `assets/` — все ассеты: обложки, картинки в постах. Решено.
- `assets/logo.svg` — необязательный логотип сайта в верхнем баре. Без него сервис показывает встроенный знак.
- Сервис игнорирует ключи, где любой сегмент пути начинается с `.` или `_` (как Remotely Save).

### Посты
Expand Down Expand Up @@ -258,7 +259,7 @@ Garage не шлёт уведомления об изменениях в бак
### Расчётное время чтения

- Публично: показывается на посте (например, «5 мин чтения»).
- Считается из текста поста при рендере: число слов / `READING_WPM`.
- Считается из текста поста при рендере: число слов / `READING_WPM`, с округлением вверх, минимум 1 минута.
- Предложено: блоки кода и картинки учитываются отдельно.
- Не связано с измеренным временем на странице.

Expand Down Expand Up @@ -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`.

Expand Down Expand Up @@ -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`. `<html lang>` из поста; верхний бар остаётся на языке интерфейса. CSS и шрифты на `/static/*`.
3 changes: 3 additions & 0 deletions internal/content/content_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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), "<strong>world</strong>") {
t.Errorf("html = %s", p.HTML)
}
Expand Down
1 change: 1 addition & 0 deletions internal/content/index.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions internal/content/post.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
}
Expand Down
Loading
Loading