diff --git a/SPEC.md b/SPEC.md index de7e90a..ebb1f0b 100644 --- a/SPEC.md +++ b/SPEC.md @@ -45,7 +45,7 @@ The author writes in Obsidian and publishes by syncing the vault to the bucket. | 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. | | 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. | +| 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). | | Metrics | Prometheus text format on `/metrics`, scraped by VictoriaMetrics, Grafana dashboard. | | Logs | `log/slog`, levels DEBUG / INFO / WARN / ERROR. | @@ -118,7 +118,7 @@ Proposed variables (final names set during implementation): ## Bucket layout - `assets/` — all assets: covers, images in posts. Decided. -- Proposed: the service ignores keys where any path segment starts with `.` or `_` (same rule as Remotely Save). +- The service ignores keys where any path segment starts with `.` or `_` (same rule as Remotely Save). ### Posts @@ -131,6 +131,7 @@ Proposed variables (final names set during implementation): ### Local vault mirror and assets - During sync the service mirrors the bucket to `VAULT_DIR` on disk: downloads new and changed objects, deletes removed ones. The bucket stays the source of truth. +- Mirror state (object key → `ETag`) is in `VAULT_DIR/.hyperhack.json`, mode `0600`, so a restart does not download everything again. Deleting `vault/` forces a full resync. - Compose layout (bind mounts only, no Docker volumes): ``` hyperhack/ @@ -143,6 +144,7 @@ Proposed variables (final names set during implementation): - Caddy serves only `/assets/*`. `.md` files and other vault paths are not exposed. - The Caddy user needs read access to `vault/assets/`. - Pages and images keep working when S3 is down; only new changes wait for the next successful sync. +- S3 down at startup: the index is built from the mirror and the service is ready. With no mirror yet, the first sync must succeed, else exit 1. Rejected: Redis. One instance, few posts: in-memory index, Go maps for rate limits, SQLite for counters, disk + OS page cache for files are enough. @@ -184,6 +186,8 @@ Rules: - `date` and `updated`: Obsidian "date" (`2026-11-03`) or "date & time" (`2026-11-03T20:59`). The parser accepts both. - No `title` or `date`, or invalid YAML → post skipped, WARN log. +- Invalid `date`, `updated`, `slug`, `lang` (not two lower-case letters), `cover` (not an allowed file in `assets/`) or `draft` → post skipped, WARN log. +- Slug from the file name: lower case, Russian letters transliterated (`ж` → `zh`, `х` → `kh`, `щ` → `shch`, `ъ` and `ь` dropped), any other character run → `-`. Heading anchors use the same rule. - Two posts with one `slug` → the first by object key wins, the second is skipped, ERROR log. - Unknown fields are ignored. Obsidian can add its own. - README describes every field in this table. @@ -192,7 +196,7 @@ Rules: - The blog has its own Obsidian vault. Personal notes never reach the blog bucket. - Obsidian syncs files to Garage with the Remotely Save plugin. -- Vault layout = bucket layout (plus the Remotely Save prefix, if set). +- Vault layout = bucket layout. The Remotely Save remote prefix stays empty: the blog has its own bucket. - Remotely Save encryption is off for this bucket. Otherwise the service cannot read the files. - Remotely Save does not sync files and folders whose name starts with `.` or `_`. - Remotely Save does not call `/reindex`. A new post appears after the next poll. @@ -226,6 +230,10 @@ The service finds changes in two ways: Polling is the fallback. The endpoint is the fast path. +- S3 errors: up to 3 attempts per poll (retry after 5 s, then 30 s), WARN per failed attempt, ERROR after the last. +- A failed download keeps the old copy; the next poll retries it. +- `POST /reindex` waits for the sync: `204` done, `401` bad token, `403` address not in `REINDEX_ALLOW`, `503` sync failed. + ## Post metrics Site-wide traffic is already in reverse proxy logs and Grafana. The service does not duplicate it. @@ -354,7 +362,7 @@ Rules: - Format: JSON to stdout (for Docker and Loki). Text for local development. Set by `LOG_FORMAT`. - Fields in every record: `time`, `level`, `msg`, `component` (`main`, `sync`, `http`, `render`, `votes`, `metrics`, `store`). `main`: startup, config and shutdown. - HTTP records: `request_id`, method, path, status, duration. -- Startup error (config, S3, SQLite) → `ERROR` log and exit code 1. +- Startup error (config, SQLite; S3 only when there is no local mirror yet) → `ERROR` log and exit code 1. | Level | What is logged | |---|---| @@ -423,7 +431,7 @@ Other methods → `405`. Unknown paths → `404`. ### Output and XSS - All HTML through `html/template` (context-aware escaping). Templates parsed at startup. -- Markdown: goldmark without `WithUnsafe`. By default goldmark does not render raw HTML or dangerous URLs. +- Markdown: goldmark with GFM (tables, strikethrough, autolinks), without `WithUnsafe`. By default goldmark does not render raw HTML or dangerous URLs. - Defense in depth: post HTML passes `bluemonday` (UGC policy + chroma classes + heading ids). - External links get `rel="noopener noreferrer"`. - Images only from `assets/`. External images are blocked by CSP (also keeps reader IPs private). @@ -475,7 +483,7 @@ Other methods → `405`. Unknown paths → `404`. - The service has its own Garage key with **read** permission only. Remotely Save uses a separate key with read + write. - S3 over HTTPS with certificate verification. -- Size limits: `MAX_POST_BYTES` (default 1 MiB), `MAX_ASSET_BYTES` (50 MiB), `MAX_OBJECTS` (10 000). Over limit → skip + WARN. +- Size limits: `MAX_POST_BYTES` (default 1 MiB), `MAX_ASSET_BYTES` (50 MiB), `MAX_OBJECTS` (10 000). Over limit → skip + WARN. `MAX_OBJECTS` counts every key in the bucket, ignored ones too. - Mirror writes go through `os.Root` (Go ≥ 1.24): no escape via `..` or symlinks. Keys with an absolute path, `\`, NUL or control characters are rejected. - Assets: same extension allowlist as Caddy. - Atomic writes (temp file + rename), so Caddy never serves a partial file. @@ -597,3 +605,4 @@ Other methods → `405`. Unknown paths → `404`. - 2026-11-03 — Full security design (see Security). Votes keyed by anonymous `vid` cookie, not IP. `/reindex` private like `/metrics`. Theme in a cookie, no inline JS. Images only from `assets/`. - 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`. diff --git a/SPEC.ru.md b/SPEC.ru.md index 48af416..813015a 100644 --- a/SPEC.ru.md +++ b/SPEC.ru.md @@ -45,7 +45,7 @@ | Темы | Светлая и тёмная, переключатель в верхнем баре. По умолчанию тема системы, пока читатель не выбрал свою. Выбор хранится в cookie и рендерится на сервере (без inline JS). | | Дизайн | Красивый, минималистичный, адаптивный. Делается на этапе реализации. | | Языки | Код, доки, интерфейс — английский. Интерфейс также на русском. По умолчанию — `DEFAULT_LANG` (`en` / `ru`) в `.env`. Переключатель для читателя в верхнем баре; выбор хранится в cookie (сервер должен знать язык для рендера). Текст постов не переводится. | -| Хранилище | Garage (S3 API), path-style адресация. | +| Хранилище | Garage (S3 API), path-style адресация. Клиент: `github.com/minio/minio-go/v7`. | | Формат поста | Один `.md` = один пост. Метаданные — свойства Obsidian (YAML frontmatter). | | Метрики | Формат Prometheus на `/metrics`, сбор через VictoriaMetrics, дашборд Grafana. | | Логи | `log/slog`, уровни DEBUG / INFO / WARN / ERROR. | @@ -118,7 +118,7 @@ ## Структура бакета - `assets/` — все ассеты: обложки, картинки в постах. Решено. -- Предложено: сервис игнорирует ключи, где любой сегмент пути начинается с `.` или `_` (как Remotely Save). +- Сервис игнорирует ключи, где любой сегмент пути начинается с `.` или `_` (как Remotely Save). ### Посты @@ -131,6 +131,7 @@ ### Локальное зеркало vault и ассеты - При синхронизации сервис зеркалит бакет в `VAULT_DIR` на диске: скачивает новые и изменённые объекты, удаляет исчезнувшие. Источник правды — бакет. +- Состояние зеркала (ключ объекта → `ETag`) хранится в `VAULT_DIR/.hyperhack.json`, права `0600`, поэтому после перезапуска не нужно скачивать всё заново. Удаление `vault/` запускает полную пересинхронизацию. - Структура рядом с Compose (только bind mount, без Docker-томов): ``` hyperhack/ @@ -143,6 +144,7 @@ - Caddy отдаёт только `/assets/*`. Файлы `.md` и другие пути vault наружу не видны. - Пользователю Caddy нужно право чтения `vault/assets/`. - Если S3 недоступен, страницы и картинки работают; новые изменения ждут следующей успешной синхронизации. +- S3 недоступен при старте: индекс строится из зеркала, сервис готов. Если зеркала ещё нет, первая синхронизация должна пройти, иначе выход с кодом 1. Отклонено: Redis. Один экземпляр, мало постов: хватает индекса в памяти, Go map для лимитов, SQLite для счётчиков, диска и кэша ОС для файлов. @@ -184,6 +186,8 @@ draft: false - `date` и `updated`: тип «дата» Obsidian (`2026-11-03`) или «дата и время» (`2026-11-03T20:59`). Парсер принимает оба. - Нет `title` или `date`, или YAML не парсится → пост пропускается, лог WARN. +- Неверные `date`, `updated`, `slug`, `lang` (не две строчные буквы), `cover` (не разрешённый файл в `assets/`) или `draft` → пост пропускается, лог WARN. +- Slug из имени файла: строчные буквы, русские буквы транслитерируются (`ж` → `zh`, `х` → `kh`, `щ` → `shch`, `ъ` и `ь` убираются), любая другая последовательность символов → `-`. Якоря заголовков строятся так же. - Два поста с одним `slug` → публикуется первый по ключу объекта, второй пропускается, лог ERROR. - Неизвестные поля игнорируются. Obsidian может добавлять свои. - README описывает все поля этой таблицы. @@ -192,7 +196,7 @@ draft: false - Для блога отдельный vault. Личные заметки в бакет блога не попадают. - Obsidian синхронизирует файлы с Garage плагином Remotely Save. -- Структура vault = структура бакета (плюс префикс Remotely Save, если задан). +- Структура vault = структура бакета. Префикс Remotely Save остаётся пустым: у блога свой бакет. - Шифрование Remotely Save для этого бакета выключено. Иначе сервис не прочитает файлы. - Remotely Save не синхронизирует файлы и папки, имя которых начинается с `.` или `_`. - Remotely Save не вызывает `/reindex`. Новый пост появляется после ближайшего опроса. @@ -226,6 +230,10 @@ Garage не шлёт уведомления об изменениях в бак Опрос — запасной путь. Эндпоинт — быстрый путь. +- Ошибки S3: до 3 попыток за опрос (повтор через 5 с, затем через 30 с), WARN на каждую неудачную попытку, ERROR после последней. +- Если скачать объект не удалось, остаётся старая копия; следующий опрос пробует снова. +- `POST /reindex` ждёт окончания синхронизации: `204` готово, `401` неверный токен, `403` адрес не в `REINDEX_ALLOW`, `503` синхронизация не удалась. + ## Метрики постов Общий трафик уже есть в логах reverse proxy и Grafana. Сервис его не дублирует. @@ -354,7 +362,7 @@ Garage не шлёт уведомления об изменениях в бак - Формат: JSON в stdout (для Docker и Loki). Текстовый — для локальной разработки. Задаётся `LOG_FORMAT`. - Поля каждой записи: `time`, `level`, `msg`, `component` (`main`, `sync`, `http`, `render`, `votes`, `metrics`, `store`). `main`: запуск, конфиг и остановка. - HTTP-записи: `request_id`, метод, путь, статус, длительность. -- Ошибка при старте (конфиг, S3, SQLite) → лог `ERROR` и выход с кодом 1. +- Ошибка при старте (конфиг, SQLite; S3 только если локального зеркала ещё нет) → лог `ERROR` и выход с кодом 1. | Уровень | Что пишется | |---|---| @@ -379,7 +387,7 @@ Garage не шлёт уведомления об изменениях в бак | Контент бакета | Взломанное устройство автора пишет плохие файлы | Ключие, лимиты размеров, санитайзер, проверка путей | | Секреты | Утечка через репозиторий, логи, образ | Только `.env`, никогда не логируются, secret scanning | | Цепочка поставок | Вредная зависимость или action, подмена образа | Закреплённые actions, Dependabot, `govulncheck`, attestations | -| Приватность читателей | СлежкÐ Без сырого IP, суточный HMAC-хеш, без сторонних запросов | +| Приватность читателей | Слежка, утечки IP | Без сырого IP, суточный HMAC-хеш, без сторонних запросов | Границы доверия: интернет → Caddy → сервис; бакет → сервис (доверие частичное: пишет только автор, но его устройства могут взломать); CI → GHCR → хост. @@ -391,7 +399,7 @@ Garage не шлёт уведомления об изменениях в бак - Разрешённые расширения: `png jpg jpeg webp avif gif svg mp4 webm`. Остальное → `404`. - Заголовки: `X-Content-Type-Options: nosniff` и `Content-Security-Policy: default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox`. SVG со скриптом не выполнится. - Лимит тела запроса для `/api/*`: 16 КБ. -- `Strict-Transport-Security: max-age=31536000` (без `includeSubDomainsoad по умолчанию). +- `Strict-Transport-Security: max-age=31536000` (без `includeSubDomains` и без preload по умолчанию). - Заголовок `Server` убран. - Без Cloudflare: его замедляют в России с июня 2025, а большинство читателей в России. Защиты от DDoS выше по сети нет: риск принят. - Отклонено: проверка при входе на сайт (например, Anubis). Она может блокировать поисковики и ботов превью ссылок. @@ -403,7 +411,7 @@ Garage не шлёт уведомления об изменениях в бак | Страницы, `/search`, `/tags/*`, `/rss.xml`, `/sitemap.xml`, `/robots.txt`, `/llms.txt`, `/posts/.md` | GET | публичный | `RATE_LIMIT_SEARCH` на `/search` | | `/api/altcha` | GET | публичный | `RATE_LIMIT_VOTE` | | `/api/vote` | POST | публичный | ALTCHA, cookie голоса, `RATE_LIMIT_VOTE`, проверка Origin | -| `/api/beacon` | POST | публичный | `RACON`, проверка Origin, строгая схема | +| `/api/beacon` | POST | публичный | `RATE_LIMIT_BEACON`, проверка Origin, строгая схема | | `/reindex` | POST | приватная сеть | блок в Caddy, `REINDEX_ALLOW`, `REINDEX_TOKEN` | | `/metrics` | GET | приватная сеть | блок в Caddy, `METRICS_ALLOW` | | `/healthz` | GET | публичный | постоянный ответ, без данных | @@ -412,29 +420,29 @@ Garage не шлёт уведомления об изменениях в бак ### Проверка входных данных -- Размер тела: `htttesReader`, 4 КБ для `/api/*`. +- Размер тела: `http.MaxBytesReader`, 4 КБ для `/api/*`. - JSON: обязателен `Content-Type: application/json`, `DisallowUnknownFields`, только один объект. - Slug должен быть в текущем индексе. Неизвестный slug → `404`, ничего не сохраняется. Это же ограничивает метки метрик. - Числа ограничены: время на странице 0–4 ч; глубина чтения только 0 / 25 / 50 / 75 / 100. -- Ѐечисления: голос (`like` / `dislike` / `none`), источник, устройство, тема, язык. Другие значения → `400`. +- Только перечисления: голос (`like` / `dislike` / `none`), источник, устройство, тема, язык. Другие значения → `400`. - Поисковый запрос: максимум 200 символов, максимум 10 слов. Запрос строится в коде (match + fuzzy + prefix), никогда не из синтаксиса query-string Bleve: это закрывает DoS через regex и wildcard. Опечатки ≤ 2, префикс от 2 символов. Номер страницы ограничен. - Cookie `lang` и `theme`: проверка по перечислению; неверное значение → по умолчанию. ### Вывод и XSS - Весь HTML через `html/template` (экранирование по контексту). Шаблоны парсятся при старте. -- Markdown: goldmark без `WithUnsafe`. По умолчанию goldmark не рендерит сырой HTML ные URL. +- Markdown: goldmark с GFM (таблицы, зачёркивание, автоссылки), без `WithUnsafe`. По умолчанию goldmark не рендерит сырой HTML и опасные URL. - Второй слой: HTML поста проходит `bluemonday` (политика UGC + классы chroma + id заголовков). - Внешние ссылки получают `rel="noopener noreferrer"`. - Картинки только из `assets/`. Внешние картинки блокирует CSP (заодно IP читателей не уходят наружу). - JSON-LD через `json.Marshal` (экранирует `<`, `>`, `&`). -- RSS и sitemap через `encodig/xml`. +- RSS и sitemap через `encoding/xml`. - `/posts/.md`: `Content-Type: text/markdown; charset=utf-8`, `nosniff`. - Страницы ошибок не показывают внутренние детали. ### Заголовки безопасности (ставит сервис) -- `Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; worker-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests`. worker-src` добавлять, только если этого требует сборка ALTCHA. +- `Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; worker-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests`. `blob:` в `worker-src` добавлять, только если этого требует сборка ALTCHA. - Нет inline-скриптов и inline-стилей. Тема и язык берутся из cookie и рендерятся на сервере, поэтому inline-скрипт против мигания темы не нужен. - `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, `Cross-Origin-Opener-Policy: same-origin`, `X-Frame-Options: DENY`, `Permissions-Policy: camera=(), microphone=(), geolocation=(), browsing-topics=()`. - Все JS, CSS, шрифты и виджет ALTCHA лежат на своём сервере. Без CDN, без сторонних запросов. @@ -444,7 +452,7 @@ Garage не шлёт уведомления об изменениях в бак - Заголовков CORS нет. - `/api/*` требует `Content-Type: application/json` (форма с другого сайта не отправит его без preflight) и same-origin `Origin` / `Sec-Fetch-Site`. -### Голоса и личнЏ +### Голоса и личность посетителя - Личность для голоса: анонимная случайная cookie `vid` (128 бит, `HttpOnly; Secure; SameSite=Lax; Max-Age=1 год`). Личных данных в ней нет. - Один голос на `vid` на пост. Если удалить cookie, можно проголосовать снова, но каждый голос стоит решения ALTCHA и упирается в `RATE_LIMIT_VOTE`. @@ -453,7 +461,7 @@ Garage не шлёт уведомления об изменениях в бак ### ALTCHA -- Библиотека: `github.com/altcha-ocha-lib-go`. Невидимый режим: браузер решает задачу при клике. +- Библиотека: `github.com/altcha-org/altcha-lib-go`. Невидимый режим: браузер решает задачу при клике. - `ALTCHA_HMAC_KEY` ≥ 32 случайных байт. - Срок жизни задачи `ALTCHA_EXPIRES` (по умолчанию 5 мин). Истекла → отказ. - Защита от повтора в сервисе: использованные задачи хранятся в памяти до истечения; повтор → отказ. Библиотека этого не делает. @@ -463,25 +471,25 @@ Garage не шлёт уведомления об изменениях в бак - Ключ: IP клиента через `TRUSTED_PROXIES`; IPv6 группируется по `/64`. - Token bucket в памяти с жёстким потолком числа клиентов (старые вытесняются), поэтому поток IP не съест RAM. -- Лимиты: `RATE_LIMIT_VOTE`, `RATE_LIMIT_BEACON`, `RATE_LIMIT_SEARCH`, `RATE_LIMIT_REINDEX`. Превышение → `Retry-After`, лог WARN. +- Лимиты: `RATE_LIMIT_VOTE`, `RATE_LIMIT_BEACON`, `RATE_LIMIT_SEARCH`, `RATE_LIMIT_REINDEX`. Превышение → `429` + `Retry-After`, лог WARN. ### Приватные эндпоинты - `/metrics` и `/reindex`: закрыты в Caddy и принимаются только с CIDR из `METRICS_ALLOW` / `REINDEX_ALLOW` (по умолчанию: loopback и частные диапазоны). Два независимых слоя. - `REINDEX_TOKEN` ≥ 32 байт, передаётся как `Authorization: Bearer`, сравнивается через `crypto/subtle.ConstantTimeCompare`. -- Одновременно Бт только одна переиндексация. Лишние вызовы ждут текущую. +- Одновременно идёт только одна переиндексация. Лишние вызовы ждут текущую. ### Бакет и локальное зеркало - У сервиса свой ключ Garage только с правом **read**. Remotely Save использует отдельный ключ с read + write. - S3 по HTTPS с проверкой сертификата. -- Лимиты размеров: `MAX_POST_BYTES` (по умолчанию 1 МиБ), `MAX_ASSET_BYTES` (50 МиБ), `MAX_OBCTS` (10 000). Превышение → пропуск + WARN. +- Лимиты размеров: `MAX_POST_BYTES` (по умолчанию 1 МиБ), `MAX_ASSET_BYTES` (50 МиБ), `MAX_OBJECTS` (10 000). Превышение → пропуск + WARN. `MAX_OBJECTS` считает все ключи бакета, включая игнорируемые. - Запись в зеркало через `os.Root` (Go ≥ 1.24): выйти через `..` или symlink нельзя. Ключи с абсолютным путём, `\`, NUL или управляющими символами отклоняются. - Ассеты: тот же список расширений, что в Caddy. - Атомарная запись (временный файл + rename), поэтому Caddy не отдаст недописанный файл. - Права: папки `0755`, файлы `0644` (Caddy нужно чтение). Файл SQLite `0600`. - Размер frontmatter проверяется до парсинга YAML. `cover` должен указывать внутрь `assets/`. -- Slug: `^[a-z0-9-]{1,100}$`. Имя файла с другими символами (например, кириллицей) транслитерируется. Если результат пустой и `slug` нет â¿уск + WARN. +- Slug: `^[a-z0-9-]{1,100}$`. Имя файла с другими символами (например, кириллицей) транслитерируется. Если результат пустой и `slug` нет → пропуск + WARN. ### HTTP-сервер @@ -500,7 +508,7 @@ Garage не шлёт уведомления об изменениях в бак ### Контейнер - Статический бинарник (`CGO_ENABLED=0`, `-trimpath`), драйвер SQLite на чистом Go (например, `modernc.org/sqlite`). -- Образ: distroless static non-root или `scratch`.ез shell и пакетного менеджера. +- Образ: distroless static non-root или `scratch`. Без shell и пакетного менеджера. - Пример Compose: ```yaml services: @@ -519,7 +527,7 @@ Garage не шлёт уведомления об изменениях в бак mem_limit: 256m pids_limit: 128 logging: - options: { max-size: "10m" "3" } + options: { max-size: "10m", max-file: "3" } healthcheck: test: ["CMD", "/hyperhack", "healthcheck"] restart: unless-stopped @@ -530,13 +538,14 @@ Garage не шлёт уведомления об изменениях в бак - Только в `.env`. В `.env.example` — заглушки. - Проверка при старте: обязательные секреты есть и достаточно длинные, иначе выход с кодом 1. -- Никогда не логируются, не попадают в сообщения об ошибках и README описывает ротацию: `REINDEX_TOKEN`, `ALTCHA_HMAC_KEY`, `VISITOR_SECRET`, ключи S3. +- Никогда не логируются, не попадают в сообщения об ошибках и в образ. +- README описывает ротацию: `REINDEX_TOKEN`, `ALTCHA_HMAC_KEY`, `VISITOR_SECRET`, ключи S3. ### Цепочка поставок и CI - GitHub Actions закреплены по полному SHA коммита. - Минимальные `permissions:` на job: по умолчанию `contents: read`; `packages: write`, `id-token: write`, `attestations: write` — только в release job. -- Без `pull_request_target` с checkout кода PR. PR из форков не получетов. +- Без `pull_request_target` с checkout кода PR. PR из форков не получают секретов. - Dependabot: `gomod`, `github-actions`, `docker`. - Проверки в CI: `go vet`, `staticcheck`, `govulncheck`, тесты с `-race`, CodeQL (Go). - Релиз: сборка образа, push в GHCR, attestation через `actions/attest` (`push-to-registry: true`). Проверка на хосте: `gh attestation verify oci://ghcr.io//hyperhack: -R /hyperhack`. @@ -571,15 +580,15 @@ Garage не шлёт уведомления об изменениях в бак ## Журнал решений - 2026-11-03 — Один автор, без регистрации. -- 2026-11-03 — Только S3 (Garage); Webшен. +- 2026-11-03 — Только S3 (Garage); WebDAV убран. - 2026-11-03 — Синхронизация: опрос `ListObjectsV2` + `POST /reindex` с токеном. - 2026-11-03 — Общей аналитики в сервисе нет. Только метрики постов. - 2026-11-03 — Анонимные лайки и дизлайки с проверкой на бота. -- 2026-11-03 — Безопасность: rate limit Caddy + CrowdSec, лимиты записи в сервисе, ALTCHA на голос. Без защиты от DDoS выѵз проверки при входе. +- 2026-11-03 — Безопасность: rate limit Caddy + CrowdSec, лимиты записи в сервисе, ALTCHA на голос. Без защиты от DDoS выше по цепочке. Без проверки при входе. - 2026-11-03 — Frontend: `html/template`, без фреймворка. Светлая и тёмная темы. - 2026-11-03 — Верхний бар: лого + название слева, центр пуст, тема + RSS + GitHub справа. RSS в MVP. - 2026-11-03 — Просмотры считает клиентский скрипт. -- 2026-11-03 — Расчётное время чтения публично. Измеренное время на стÑтолько метрика. `/metrics` для VictoriaMetrics + дашборд Grafana. +- 2026-11-03 — Расчётное время чтения публично. Измеренное время на странице — только метрика. `/metrics` для VictoriaMetrics + дашборд Grafana. - 2026-11-03 — `/metrics` на основном порту, закрыт в reverse proxy. - 2026-11-03 — Логи: `log/slog`, DEBUG / INFO / WARN / ERROR, JSON в stdout. - 2026-11-03 — Формат поста: свойства Obsidian `title`, `description`, `date`, `updated`, `tags`, `cover`, `slug`, `lang`, `draft`. Ассеты в `assets/`. @@ -589,10 +598,11 @@ Garage не шлёт уведомления об изменениях в бак - 2026-11-03 — Docker + Compose. Настройки и секреты в `.env`. - 2026-11-03 — Open source на GitHub, AGPL-3.0, GitHub Actions. Всё на английском, русская копия доков русский интерфейс через `DEFAULT_LANG`. - 2026-11-03 — `DATA_DIR`, домен (`SITE_URL`) и лимиты в `.env`. `Caddyfile.example` с `example.com`. Переключатель языка для читателя рядом с темой. Перенос из Ghost вне объёма. -- 2026-11-03 — Посты: любой `.md` на любой глубине, кроме `assets/`, `.` и `_`; URL `/posts/`. Сервис зеркалит бакет в `./vault`; Caddy оÑиз него `/assets/*`. Без Redis. +- 2026-11-03 — Посты: любой `.md` на любой глубине, кроме `assets/`, `.` и `_`; URL `/posts/`. Сервис зеркалит бакет в `./vault`; Caddy отдаёт из него `/assets/*`. Без Redis. - 2026-11-03 — Подсветка кода на сервере (chroma). Поиск Bleve (ru/en, опечатки, префикс, веса полей). «Популярные» за `POPULAR_DAYS`. Пагинация. Страницы тегов с цветами из хеша. RSS с полным текстом. Полный набор SEO. `/healthz`. Лимиты и размеры в `.env`. -- 2026-11-03 — Дополнительные скрытые метѸ: просмотры по дням, глубина чтения, источник трафика, клики по внешним ссылкам, копирования кода, класс устройства, язык и тема, голоса по дням, статистика поиска. +- 2026-11-03 — Дополнительные скрытые метрики: просмотры по дням, глубина чтения, источник трафика, клики по внешним ссылкам, копирования кода, класс устройства, язык и тема, голоса по дням, статистика поиска. - 2026-11-03 — Всё — один Go-бинарник. Внешнее: Garage, Caddy, VictoriaMetrics, Grafana (уже работают). -- 2026-11-03 — Полный дизайн безопасности (смзопасность»). Голоса по анонимной cookie `vid`, не по IP. `/reindex` приватный, как `/metrics`. Тема в cookie, без inline JS. Картинки только из `assets/`. +- 2026-11-03 — Полный дизайн безопасности (см. «Безопасность»). Голоса по анонимной cookie `vid`, не по IP. `/reindex` приватный, как `/metrics`. Тема в cookie, без inline JS. Картинки только из `assets/`. - 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`. diff --git a/go.mod b/go.mod index 5eae055..a9aac63 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,35 @@ module github.com/Mimic890/hyperhack go 1.27 toolchain go1.27.3 + +require ( + github.com/alecthomas/chroma/v2 v2.27.0 + github.com/microcosm-cc/bluemonday v1.0.27 + github.com/minio/minio-go/v7 v7.4.1 + github.com/yuin/goldmark v1.8.7 + github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc + go.yaml.in/yaml/v3 v3.0.6 + golang.org/x/net v0.62.0 +) + +require ( + github.com/aymerick/douceur v0.2.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dlclark/regexp2/v2 v2.2.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/klauspost/compress v1.19.2 // indirect + github.com/klauspost/cpuid/v2 v2.4.0 // indirect + github.com/klauspost/crc32 v1.3.0 // indirect + github.com/minio/crc64nvme v1.1.1 // indirect + github.com/minio/md5-simd v1.1.2 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/rs/xid v1.6.0 // indirect + github.com/tinylib/msgp v1.6.4 // indirect + github.com/zeebo/xxh3 v1.1.0 // indirect + golang.org/x/crypto v0.58.0 // indirect + golang.org/x/sys v0.49.0 // indirect + golang.org/x/text v0.43.0 // indirect + gopkg.in/ini.v1 v1.67.3 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f651301 --- /dev/null +++ b/go.sum @@ -0,0 +1,83 @@ +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs= +github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs= +github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8= +github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= +github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0= +github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= +github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= +github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= +github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= +github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.4.1 h1:PtU/zIOfQRWZ+QydXAZysc+xZYHdG+BBR4Z6TUvy73w= +github.com/minio/minio-go/v7 v7.4.1/go.mod h1:nQnQi7zTfXR4lREtYXZQWqo5aw0Rqv+ZqCGrTORygRk= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.8.7 h1:XT/tNqCIbabh0ln675w4x3dv04kQBKi8NwYqi9IJaxI= +github.com/yuin/goldmark v1.8.7/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= +github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.yaml.in/yaml/v3 v3.0.6 h1:zcDfvjXEqTQmpjniUBLc91xYJMB2c4QOFxEF/LCEckM= +go.yaml.in/yaml/v3 v3.0.6/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= +golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY= +golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw= +gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go index aeb424b..a36ee95 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -129,6 +129,9 @@ func Load(getenv func(string) string) (*Config, error) { MaxAssetBytes: int64(l.int("MAX_ASSET_BYTES", 50<<20, 1024, 1<<30)), MaxObjects: l.int("MAX_OBJECTS", 10000, 1, 1_000_000), } + if c.S3Endpoint != nil && c.S3Endpoint.Path != "" { + l.fail("S3_ENDPOINT", "must not have a path") + } if err := errors.Join(l.errs...); err != nil { return nil, err } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index dc7b6a0..929d65b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -76,6 +76,11 @@ func TestLoadErrors(t *testing.T) { } }) } + env := validEnv() + env["S3_ENDPOINT"] = "https://s3.example.com/blog" + if _, err := load(env); err == nil || !strings.Contains(err.Error(), "S3_ENDPOINT") { + t.Errorf("endpoint with path: err = %v", err) + } } func TestErrorsHideSecrets(t *testing.T) { diff --git a/internal/content/content_test.go b/internal/content/content_test.go new file mode 100644 index 0000000..e3ff52b --- /dev/null +++ b/internal/content/content_test.go @@ -0,0 +1,234 @@ +package content + +import ( + "io" + "log/slog" + "net/url" + "strings" + "testing" + "time" + + "golang.org/x/net/html" +) + +func TestParse(t *testing.T) { + r := NewRenderer("en") + src := "\ufeff---\r\ntitle: Leaving Ghost\r\ndescription: Why\r\ndate: 2026-11-03\r\nupdated: 2026-11-05T20:59\r\ntags:\r\n - go\r\n - ' selfhost '\r\ncover: assets/covers/ghost.webp\r\nslug: leaving-ghost\r\nlang: ru\r\nextra: ignored\r\n---\r\n\r\nHello **world**\r\n" + p, err := r.Parse("blog/leaving.md", []byte(src)) + if err != nil { + t.Fatal(err) + } + want := Post{ + Key: "blog/leaving.md", Slug: "leaving-ghost", Title: "Leaving Ghost", Description: "Why", + Date: time.Date(2026, 11, 3, 0, 0, 0, 0, time.UTC), + Updated: time.Date(2026, 11, 5, 20, 59, 0, 0, time.UTC), + Cover: "assets/covers/ghost.webp", Lang: "ru", + } + if p.Key != want.Key || p.Slug != want.Slug || p.Title != want.Title || p.Description != want.Description || + !p.Date.Equal(want.Date) || !p.Updated.Equal(want.Updated) || p.Cover != want.Cover || p.Lang != want.Lang || p.Draft { + t.Errorf("got %+v", p) + } + if strings.Join(p.Tags, ",") != "go,selfhost" { + t.Errorf("tags = %q", p.Tags) + } + if !strings.Contains(string(p.HTML), "world") { + t.Errorf("html = %s", p.HTML) + } +} + +func TestParseDefaults(t *testing.T) { + p, err := NewRenderer("ru").Parse("Мой Первый Пост!.md", []byte("---\ntitle: T\ndate: 2026-11-03\ntags: go\ndraft: true\n---\n")) + if err != nil { + t.Fatal(err) + } + if p.Slug != "moy-pervyy-post" || p.Lang != "ru" || !p.Draft || len(p.Tags) != 1 || p.Tags[0] != "go" { + t.Errorf("got %+v", p) + } +} + +func TestParseErrors(t *testing.T) { + tests := map[string]string{ + "no frontmatter": "# Title\n", + "not closed": "---\ntitle: T\ndate: 2026-11-03\n", + "invalid yaml": "---\ntitle: [\n---\n", + "no title": "---\ndate: 2026-11-03\n---\n", + "no date": "---\ntitle: T\n---\n", + "bad date": "---\ntitle: T\ndate: 03.11.2026\n---\n", + "bad updated": "---\ntitle: T\ndate: 2026-11-03\nupdated: soon\n---\n", + "bad slug": "---\ntitle: T\ndate: 2026-11-03\nslug: Bad Slug\n---\n", + "bad lang": "---\ntitle: T\ndate: 2026-11-03\nlang: english\n---\n", + "cover outside": "---\ntitle: T\ndate: 2026-11-03\ncover: ../secret.png\n---\n", + "cover escape": "---\ntitle: T\ndate: 2026-11-03\ncover: assets/../x.png\n---\n", + "cover wikilink": "---\ntitle: T\ndate: 2026-11-03\ncover: \"[[x.png]]\"\n---\n", + "cover ext": "---\ntitle: T\ndate: 2026-11-03\ncover: assets/x.html\n---\n", + "bad draft": "---\ntitle: T\ndate: 2026-11-03\ndraft: maybe\n---\n", + "huge": "---\ntitle: T\n" + strings.Repeat("x: y\n", maxFrontmatter/5+1) + "---\n", + } + r := NewRenderer("en") + for name, src := range tests { + if _, err := r.Parse("post.md", []byte(src)); err == nil { + t.Errorf("%s: want error", name) + } + } + if _, err := r.Parse("!!!.md", []byte("---\ntitle: T\ndate: 2026-11-03\n---\n")); err == nil { + t.Error("empty slug: want error") + } +} + +func TestSlugify(t *testing.T) { + tests := map[string]string{ + "Hello, World!": "hello-world", + " --Go 1.27-- ": "go-1-27", + "Съешь же ещё этих булок": "sesh-zhe-eshchyo-etikh-bulok", + "Подъезд": "podezd", + "日本語": "", + strings.Repeat("ab-", 60): strings.TrimRight(strings.Repeat("ab-", 34)[:100], "-"), + } + for in, want := range tests { + if got := Slugify(in); got != want { + t.Errorf("Slugify(%q) = %q, want %q", in, got, want) + } + } +} + +func TestRender(t *testing.T) { + r := NewRenderer("en") + src := "# Привет мир\n\n## Привет мир\n\n```go\nfunc main() {}\n```\n\n" + + "[ext](https://example.com) [int](/posts/x) [file](assets/doc.png) \n\n" + + "![alt](assets/a%20b.png)\n" + got, err := r.render([]byte(src)) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + `

`, + `

`, + `
`,
+		`func`,
+		`ext`,
+		`int`,
+		`file`,
+		``,
+		`alt`,
+	} {
+		if !strings.Contains(string(got), want) {
+			t.Errorf("missing %s in\n%s", want, got)
+		}
+	}
+}
+
+var xssCorpus = []string{
+	``,
+	``,
+	``,
+	``,
+	`x`,
+	`[x](javascript:alert(1))`,
+	`[x](JaVaScRiPt:alert(1))`,
+	`[x](java	script:alert(1))`,
+	`[x](vbscript:msgbox(1))`,
+	`[x](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)`,
+	`![x](javascript:alert(1))`,
+	`![x](data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=)`,
+	`![x"onerror="alert(1)](assets/a.png)`,
+	`[x](https://example.com "a\" onmouseover=\"alert(1)")`,
+	`
x
`, + ``, + ``, + `
`, + `x`, + ``, + ``, + "```\n
\n```", + "```html\n\n```", + "``", + `-->`, + `x`, + `
`, +} + +func TestRenderXSS(t *testing.T) { + r := NewRenderer("en") + for _, payload := range xssCorpus { + got, err := r.render([]byte(payload)) + if err != nil { + t.Fatal(err) + } + if bad := unsafeHTML(string(got)); bad != "" { + t.Errorf("payload %q: %s\n%s", payload, bad, got) + } + } +} + +// unsafeHTML returns a reason if s has a tag or attribute that can run code or load content. +func unsafeHTML(s string) string { + z := html.NewTokenizer(strings.NewReader(s)) + for { + switch z.Next() { + case html.ErrorToken: + if z.Err() == io.EOF { + return "" + } + return z.Err().Error() + case html.StartTagToken, html.SelfClosingTagToken: + tok := z.Token() + switch tok.Data { + case "script", "style", "iframe", "object", "embed", "form", "input", "svg", "math", "base", "meta", "link": + return "tag " + tok.Data + } + for _, a := range tok.Attr { + v := strings.ToLower(strings.TrimSpace(a.Val)) + switch { + case strings.HasPrefix(a.Key, "on"), a.Key == "style", a.Key == "srcdoc", a.Key == "target": + return "attribute " + a.Key + case a.Key == "href" || a.Key == "src": + u, err := url.Parse(v) + if err != nil || (u.Scheme != "" && u.Scheme != "http" && u.Scheme != "https" && u.Scheme != "mailto") { + return "url " + a.Val + } + } + } + } + } +} + +func FuzzParse(f *testing.F) { + f.Add([]byte("---\ntitle: T\ndate: 2026-11-03\n---\n# Hi\n")) + f.Add([]byte("---\ntags: &a [*a]\n---\n")) + for _, p := range xssCorpus { + f.Add([]byte("---\ntitle: T\ndate: 2026-11-03\n---\n" + p)) + } + r := NewRenderer("en") + f.Fuzz(func(t *testing.T, src []byte) { + p, err := r.Parse("post.md", src) + if err != nil { + return + } + if !slugRe.MatchString(p.Slug) || p.Title == "" { + t.Fatalf("bad post: %+v", p) + } + if bad := unsafeHTML(string(p.HTML)); bad != "" { + t.Fatalf("%s\n%s", bad, p.HTML) + } + }) +} + +func TestIndex(t *testing.T) { + d := func(day int) time.Time { return time.Date(2026, 11, day, 0, 0, 0, 0, time.UTC) } + posts := []*Post{ + {Key: "z.md", Slug: "dup", Date: d(5)}, + {Key: "a.md", Slug: "dup", Date: d(1)}, + {Key: "b.md", Slug: "new", Date: d(9)}, + {Key: "c.md", Slug: "draft", Date: d(10), Draft: true}, + } + ix := NewIndex(posts, slog.New(slog.DiscardHandler)) + if got := ix.Get("dup"); got == nil || got.Key != "a.md" { + t.Errorf("dup = %+v, want a.md", got) + } + if ix.Get("draft") != nil { + t.Error("draft in index") + } + if ps := ix.Posts(); len(ps) != 2 || ps[0].Slug != "new" || ps[1].Slug != "dup" { + t.Errorf("order = %v", ps) + } +} diff --git a/internal/content/index.go b/internal/content/index.go new file mode 100644 index 0000000..e6c0a91 --- /dev/null +++ b/internal/content/index.go @@ -0,0 +1,40 @@ +package content + +import ( + "log/slog" + "slices" + "strings" +) + +// Index is an immutable set of published posts. +type Index struct { + posts []*Post // newest first + bySlug map[string]*Post +} + +// NewIndex builds an index. Drafts are left out. +// On a slug conflict the first post by object key wins and the other is logged as ERROR. +func NewIndex(posts []*Post, log *slog.Logger) *Index { + posts = slices.Clone(posts) + slices.SortFunc(posts, func(a, b *Post) int { return strings.Compare(a.Key, b.Key) }) + ix := &Index{bySlug: map[string]*Post{}} + for _, p := range posts { + if p.Draft { + continue + } + if first, ok := ix.bySlug[p.Slug]; ok { + log.Error("slug conflict, post skipped", "slug", p.Slug, "key", p.Key, "kept", first.Key) + continue + } + ix.bySlug[p.Slug] = p + ix.posts = append(ix.posts, p) + } + slices.SortStableFunc(ix.posts, func(a, b *Post) int { return b.Date.Compare(a.Date) }) + return ix +} + +// Get returns the post with this slug, or nil. +func (ix *Index) Get(slug string) *Post { return ix.bySlug[slug] } + +// Posts returns published posts, newest first. Callers must not change the slice. +func (ix *Index) Posts() []*Post { return ix.posts } diff --git a/internal/content/post.go b/internal/content/post.go new file mode 100644 index 0000000..66cd9f7 --- /dev/null +++ b/internal/content/post.go @@ -0,0 +1,206 @@ +// Package content parses posts (frontmatter + Markdown) and holds the post index. +package content + +import ( + "bytes" + "errors" + "fmt" + "html/template" + "path" + "regexp" + "strings" + "time" + + "go.yaml.in/yaml/v3" + + "github.com/Mimic890/hyperhack/internal/mirror" +) + +// Post is one published or draft post. +type Post struct { + Key string // bucket key of the .md file + Slug string + Title string + Description string + Date time.Time + Updated time.Time // zero if not set + Tags []string + Cover string // key inside assets/, empty if not set + Lang string + Draft bool + HTML template.HTML // sanitized +} + +const maxFrontmatter = 64 << 10 + +var ( + slugRe = regexp.MustCompile(`^[a-z0-9-]{1,100}$`) + langRe = regexp.MustCompile(`^[a-z]{2}$`) +) + +type frontmatter struct { + Title string `yaml:"title"` + Description string `yaml:"description"` + Date string `yaml:"date"` + Updated string `yaml:"updated"` + Tags stringList `yaml:"tags"` + Cover string `yaml:"cover"` + Slug string `yaml:"slug"` + Lang string `yaml:"lang"` + Draft bool `yaml:"draft"` +} + +// stringList accepts a YAML list or a single string. +type stringList []string + +func (l *stringList) UnmarshalYAML(n *yaml.Node) error { + if n.Kind == yaml.ScalarNode { + *l = []string{n.Value} + return nil + } + var s []string + if err := n.Decode(&s); err != nil { + return err + } + *l = s + return nil +} + +// Parse reads frontmatter and renders the body. The error says why a post is skipped. +func (r *Renderer) Parse(key string, src []byte) (*Post, error) { + fm, body, err := splitFrontmatter(src) + if err != nil { + return nil, err + } + var f frontmatter + if err := yaml.Unmarshal(fm, &f); err != nil { + return nil, fmt.Errorf("invalid YAML: %w", err) + } + p := &Post{ + Key: key, + Title: strings.TrimSpace(f.Title), + Description: strings.TrimSpace(f.Description), + Lang: r.defaultLang, + Draft: f.Draft, + } + if p.Title == "" { + return nil, errors.New("missing title") + } + if f.Date == "" { + return nil, errors.New("missing date") + } + if p.Date, err = parseDate(f.Date); err != nil { + return nil, fmt.Errorf("date: %w", err) + } + if f.Updated != "" { + if p.Updated, err = parseDate(f.Updated); err != nil { + return nil, fmt.Errorf("updated: %w", err) + } + } + for _, t := range f.Tags { + if t = strings.TrimSpace(t); t != "" { + p.Tags = append(p.Tags, t) + } + } + if f.Cover != "" { + c := strings.TrimPrefix(f.Cover, "/") + if k, err := mirror.Classify(c); err != nil || k != mirror.Asset { + return nil, errors.New("cover must be a file inside assets/") + } + p.Cover = c + } + if f.Lang != "" { + if !langRe.MatchString(f.Lang) { + return nil, errors.New("lang must be a two-letter ISO 639-1 code") + } + p.Lang = f.Lang + } + switch { + case f.Slug != "": + if !slugRe.MatchString(f.Slug) { + return nil, errors.New("slug must match ^[a-z0-9-]{1,100}$") + } + p.Slug = f.Slug + default: + base := path.Base(key) + if p.Slug = Slugify(strings.TrimSuffix(base, path.Ext(base))); p.Slug == "" { + return nil, errors.New("no slug: set slug in frontmatter") + } + } + if p.HTML, err = r.render(body); err != nil { + return nil, fmt.Errorf("render: %w", err) + } + return p, nil +} + +// splitFrontmatter cuts the YAML block between the leading "---" lines. +func splitFrontmatter(src []byte) (fm, body []byte, err error) { + src = bytes.TrimPrefix(src, []byte("\ufeff")) + first, rest, _ := bytes.Cut(src, []byte("\n")) + if !isFence(first) { + return nil, nil, errors.New("no frontmatter") + } + for off := 0; off <= maxFrontmatter; { + line, next, found := bytes.Cut(rest[off:], []byte("\n")) + if isFence(line) { + return rest[:off], next, nil + } + if !found { + return nil, nil, errors.New("frontmatter not closed") + } + off += len(line) + 1 + } + return nil, nil, fmt.Errorf("frontmatter larger than %d bytes", maxFrontmatter) +} + +func isFence(line []byte) bool { return string(bytes.TrimRight(line, " \t\r")) == "---" } + +// parseDate accepts Obsidian "date" and "date & time" values. No zone means UTC. +func parseDate(s string) (time.Time, error) { + for _, layout := range []string{"2006-01-02", "2006-01-02T15:04", "2006-01-02T15:04:05", time.RFC3339} { + if t, err := time.Parse(layout, s); err == nil { + return t, nil + } + } + return time.Time{}, errors.New("want YYYY-MM-DD or YYYY-MM-DDTHH:MM") +} + +// translit maps Russian letters to Latin. An empty value drops the letter. +var translit = map[rune]string{ + 'а': "a", 'б': "b", 'в': "v", 'г': "g", 'д': "d", 'е': "e", 'ё': "yo", 'ж': "zh", + 'з': "z", 'и': "i", 'й': "y", 'к': "k", 'л': "l", 'м': "m", 'н': "n", 'о': "o", + 'п': "p", 'р': "r", 'с': "s", 'т': "t", 'у': "u", 'ф': "f", 'х': "kh", 'ц': "ts", + 'ч': "ch", 'ш': "sh", 'щ': "shch", 'ъ': "", 'ы': "y", 'ь': "", 'э': "e", 'ю': "yu", + 'я': "ya", +} + +// Slugify lowercases and transliterates s; other characters become single dashes. +// The result matches ^[a-z0-9-]{0,100}$ and may be empty. +func Slugify(s string) string { + var b strings.Builder + dash := false + for _, r := range strings.ToLower(s) { + var t string + if 'a' <= r && r <= 'z' || '0' <= r && r <= '9' { + t = string(r) + } else if tr, ok := translit[r]; ok { + t = tr + } else { + dash = b.Len() > 0 + continue + } + if t == "" { + continue + } + if dash { + b.WriteByte('-') + dash = false + } + b.WriteString(t) + } + out := b.String() + if len(out) > 100 { + out = strings.TrimRight(out[:100], "-") + } + return out +} diff --git a/internal/content/render.go b/internal/content/render.go new file mode 100644 index 0000000..2ec9262 --- /dev/null +++ b/internal/content/render.go @@ -0,0 +1,113 @@ +package content + +import ( + "bytes" + "html/template" + "net/url" + "regexp" + "strconv" + + chromahtml "github.com/alecthomas/chroma/v2/formatters/html" + "github.com/microcosm-cc/bluemonday" + "github.com/yuin/goldmark" + highlighting "github.com/yuin/goldmark-highlighting/v2" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +// Renderer turns post Markdown into sanitized HTML. It is safe for concurrent use. +type Renderer struct { + md goldmark.Markdown + policy *bluemonday.Policy + defaultLang string +} + +// NewRenderer builds the Markdown pipeline. defaultLang is used for posts without lang. +func NewRenderer(defaultLang string) *Renderer { + md := goldmark.New( + // No goldmark.WithUnsafe: raw HTML and dangerous URLs are dropped. + goldmark.WithExtensions( + extension.GFM, + highlighting.NewHighlighting(highlighting.WithFormatOptions(chromahtml.WithClasses(true))), + ), + goldmark.WithParserOptions( + parser.WithAutoHeadingID(), + parser.WithASTTransformers(util.Prioritized(linkTransformer{}, 100)), + ), + ) + p := bluemonday.UGCPolicy() + p.RequireNoFollowOnLinks(false) + p.AllowAttrs("rel").Matching(regexp.MustCompile(`^noopener noreferrer$`)).OnElements("a") + p.AllowAttrs("class").Matching(regexp.MustCompile(`^[a-z0-9 -]+$`)).OnElements("pre", "code", "span") + return &Renderer{md: md, policy: p, defaultLang: defaultLang} +} + +func (r *Renderer) render(src []byte) (template.HTML, error) { + var buf bytes.Buffer + ctx := parser.NewContext(parser.WithIDs(&headingIDs{})) + if err := r.md.Convert(src, &buf, parser.WithContext(ctx)); err != nil { + return "", err + } + return template.HTML(r.policy.SanitizeBytes(buf.Bytes())), nil // safe: sanitized above +} + +// linkTransformer maps "assets/…" to "/assets/…" and marks external links. +type linkTransformer struct{} + +func (linkTransformer) Transform(doc *ast.Document, _ text.Reader, _ parser.Context) { + _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + switch n := n.(type) { + case *ast.Image: + n.Destination = assetPath(n.Destination) + case *ast.Link: + n.Destination = assetPath(n.Destination) + if isExternal(n.Destination) { + n.SetAttributeString("rel", []byte("noopener noreferrer")) + } + case *ast.AutoLink: + n.SetAttributeString("rel", []byte("noopener noreferrer")) + } + return ast.WalkContinue, nil + }) +} + +func assetPath(dest []byte) []byte { + if bytes.HasPrefix(dest, []byte("assets/")) { + return append([]byte("/"), dest...) + } + return dest +} + +func isExternal(dest []byte) bool { + u, err := url.Parse(string(dest)) + return err == nil && u.Host != "" +} + +// headingIDs makes heading ids with Slugify, so Cyrillic headings get readable ids. +type headingIDs struct{ used map[string]bool } + +func (h *headingIDs) Generate(value []byte, _ ast.NodeKind) []byte { + base := Slugify(string(value)) + if base == "" { + base = "section" + } + id := base + for i := 1; h.used[id]; i++ { + id = base + "-" + strconv.Itoa(i) + } + h.Put([]byte(id)) + return []byte(id) +} + +func (h *headingIDs) Put(value []byte) { + if h.used == nil { + h.used = map[string]bool{} + } + h.used[string(value)] = true +} diff --git a/internal/content/testdata/fuzz/FuzzParse/8ee258c2100c107d b/internal/content/testdata/fuzz/FuzzParse/8ee258c2100c107d new file mode 100644 index 0000000..4bbdbee --- /dev/null +++ b/internal/content/testdata/fuzz/FuzzParse/8ee258c2100c107d @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("---\ntitle: 0\ndate: 0000-01-01\n---\n[](aaaaa#:0000)") diff --git a/internal/mirror/mirror.go b/internal/mirror/mirror.go new file mode 100644 index 0000000..e4942f7 --- /dev/null +++ b/internal/mirror/mirror.go @@ -0,0 +1,202 @@ +// Package mirror keeps a local copy of the bucket on disk. +// All file access goes through os.Root, so a key cannot escape the mirror directory. +package mirror + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path" + "strings" + "unicode" + "unicode/utf8" +) + +// StateFile maps object keys to ETags. A "." name never collides with a synced key. +const StateFile = ".hyperhack.json" + +// Kind says what the service does with a bucket key. +type Kind int + +const ( + Ignore Kind = iota // not mirrored + Post // .md file outside assets/ + Asset // allowlisted file inside assets/ +) + +// assetExts matches the Caddy allowlist for /assets/*. +var assetExts = map[string]bool{ + ".png": true, ".jpg": true, ".jpeg": true, ".webp": true, ".avif": true, + ".gif": true, ".svg": true, ".mp4": true, ".webm": true, +} + +// Classify checks a bucket key and says how to treat it. +// Unsafe keys (absolute, "\", NUL, control characters, empty segments) return an error. +func Classify(key string) (Kind, error) { + if key == "" || len(key) > 1024 || !utf8.ValidString(key) { + return Ignore, errors.New("invalid key") + } + if strings.HasPrefix(key, "/") { + return Ignore, errors.New("absolute key") + } + for _, r := range key { + if r == '\\' || unicode.IsControl(r) { + return Ignore, errors.New("key has a backslash or control character") + } + } + if strings.HasSuffix(key, "/") { + return Ignore, nil // folder marker + } + segs := strings.Split(key, "/") + for _, s := range segs { + if s == "" { + return Ignore, errors.New("key has an empty path segment") + } + if s[0] == '.' || s[0] == '_' { // also covers "." and ".." + return Ignore, nil + } + } + ext := strings.ToLower(path.Ext(key)) + if segs[0] == "assets" { + if len(segs) > 1 && assetExts[ext] { + return Asset, nil + } + return Ignore, nil + } + if ext == ".md" { + return Post, nil + } + return Ignore, nil +} + +// Mirror is the local copy. It is not safe for concurrent use. +type Mirror struct { + root *os.Root + state map[string]string + existed bool +} + +// Open opens dir (it must exist) and loads the state file, if any. +func Open(dir string) (*Mirror, error) { + root, err := os.OpenRoot(dir) + if err != nil { + return nil, err + } + m := &Mirror{root: root, state: map[string]string{}} + b, err := root.ReadFile(StateFile) + switch { + case errors.Is(err, fs.ErrNotExist): + return m, nil + case err != nil: + return nil, err + } + if err := json.Unmarshal(b, &m.state); err != nil { + return nil, fmt.Errorf("%s: %w", StateFile, err) + } + m.existed = true + return m, nil +} + +// Existed reports whether a state file was found on Open, i.e. an earlier sync finished. +func (m *Mirror) Existed() bool { return m.existed } + +// State returns the key → ETag map. Callers must not change it. +func (m *Mirror) State() map[string]string { return m.state } + +// Has reports whether key is on disk with this ETag. +func (m *Mirror) Has(key, etag string) bool { + if m.state[key] != etag { + return false + } + _, err := m.root.Stat(key) + return err == nil +} + +// Write stores r as key atomically (temp file + rename). More than max bytes is an error. +// shortcut: a crash can leave a ".tmp-*" file (Caddy never serves it); clean up on start if they pile up. +func (m *Mirror) Write(key, etag string, r io.Reader, max int64) error { + dir := path.Dir(key) + if err := m.root.MkdirAll(dir, 0o755); err != nil { + return err + } + tmp := path.Join(dir, ".tmp-"+randHex()) + if err := m.writeFile(tmp, r, max, 0o644); err != nil { + return err + } + if err := m.root.Rename(tmp, key); err != nil { + _ = m.root.Remove(tmp) + return err + } + m.state[key] = etag + return nil +} + +func (m *Mirror) writeFile(name string, r io.Reader, max int64, mode fs.FileMode) (err error) { + f, err := m.root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) + if err != nil { + return err + } + defer func() { + if cerr := f.Close(); err == nil { + err = cerr + } + if err != nil { + _ = m.root.Remove(name) + } + }() + // Chmod: the umask must not hide files from Caddy. + if err := f.Chmod(mode); err != nil { + return err + } + n, err := io.Copy(f, io.LimitReader(r, max+1)) + if err != nil { + return err + } + if n > max { + return fmt.Errorf("larger than %d bytes", max) + } + return f.Sync() +} + +// Remove deletes key from disk and state. +// shortcut: empty parent folders stay; remove them if they ever pile up. +func (m *Mirror) Remove(key string) error { + if err := m.root.Remove(key); err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + delete(m.state, key) + return nil +} + +// ReadFile reads a mirrored key. +func (m *Mirror) ReadFile(key string) ([]byte, error) { return m.root.ReadFile(key) } + +// Save writes the state file atomically. +func (m *Mirror) Save() error { + b, err := json.Marshal(m.state) + if err != nil { + return err + } + tmp := StateFile + ".tmp-" + randHex() + if err := m.writeFile(tmp, bytes.NewReader(b), int64(len(b)), 0o600); err != nil { + return err + } + if err := m.root.Rename(tmp, StateFile); err != nil { + _ = m.root.Remove(tmp) + return err + } + m.existed = true + return nil +} + +func randHex() string { + b := make([]byte, 8) + _, _ = rand.Read(b) // crypto/rand.Read never returns an error + return hex.EncodeToString(b) +} diff --git a/internal/mirror/mirror_test.go b/internal/mirror/mirror_test.go new file mode 100644 index 0000000..4c5b94f --- /dev/null +++ b/internal/mirror/mirror_test.go @@ -0,0 +1,136 @@ +package mirror + +import ( + "os" + "path" + "path/filepath" + "strings" + "testing" +) + +func TestClassify(t *testing.T) { + tests := []struct { + key string + want Kind + wantErr bool + }{ + {"post.md", Post, false}, + {"notes/deep/Пост.MD", Post, false}, + {"assets/a.webp", Asset, false}, + {"assets/covers/b.JPG", Asset, false}, + {"assets/x.md", Ignore, false}, + {"assets/x.html", Ignore, false}, + {"assets", Ignore, false}, + {"readme.txt", Ignore, false}, + {"folder/", Ignore, false}, + {".obsidian/app.json", Ignore, false}, + {"_templates/t.md", Ignore, false}, + {"a/.hidden.md", Ignore, false}, + {"../escape.md", Ignore, false}, + {"a/../../escape.md", Ignore, false}, + {"assets/../x.md", Ignore, false}, + {"./post.md", Ignore, false}, + {"/etc/passwd.md", Ignore, true}, + {`a\..\b.md`, Ignore, true}, + {"a\x00.md", Ignore, true}, + {"a\nb.md", Ignore, true}, + {"a\u0085b.md", Ignore, true}, + {"a//b.md", Ignore, true}, + {"", Ignore, true}, + {"\xff.md", Ignore, true}, + {strings.Repeat("a", 1025) + ".md", Ignore, true}, + } + for _, tt := range tests { + got, err := Classify(tt.key) + if got != tt.want || (err != nil) != tt.wantErr { + t.Errorf("Classify(%q) = %v, %v; want %v, err %v", tt.key, got, err, tt.want, tt.wantErr) + } + } +} + +// FuzzClassify checks that every mirrored key is a clean relative path. +func FuzzClassify(f *testing.F) { + for _, s := range []string{"post.md", "assets/a.png", "../x.md", "a/./b.md", `a\b.md`} { + f.Add(s) + } + f.Fuzz(func(t *testing.T, key string) { + k, err := Classify(key) + if err != nil || k == Ignore { + return + } + if path.Clean(key) != key || path.IsAbs(key) || strings.HasPrefix(key, "..") { + t.Fatalf("Classify(%q) = %v for an unclean path", key, k) + } + }) +} + +func TestMirror(t *testing.T) { + dir := t.TempDir() + m, err := Open(dir) + if err != nil { + t.Fatal(err) + } + if m.Existed() { + t.Fatal("Existed on empty dir") + } + if err := m.Write("a/b/post.md", "e1", strings.NewReader("hello"), 5); err != nil { + t.Fatal(err) + } + if !m.Has("a/b/post.md", "e1") || m.Has("a/b/post.md", "e2") { + t.Fatal("Has wrong") + } + if err := m.Write("big.md", "e1", strings.NewReader("123456"), 5); err == nil { + t.Fatal("want size error") + } + if _, err := os.Stat(filepath.Join(dir, "big.md")); !os.IsNotExist(err) { + t.Fatal("oversized file left on disk") + } + fi, err := os.Stat(filepath.Join(dir, "a/b/post.md")) + if err != nil || fi.Mode().Perm() != 0o644 { + t.Fatalf("mode = %v, %v", fi.Mode(), err) + } + if err := m.Save(); err != nil { + t.Fatal(err) + } + if fi, _ := os.Stat(filepath.Join(dir, StateFile)); fi.Mode().Perm() != 0o600 { + t.Fatalf("state mode = %v", fi.Mode()) + } + entries, _ := os.ReadDir(filepath.Join(dir, "a/b")) + if len(entries) != 1 { + t.Fatalf("temp files left: %v", entries) + } + + m2, err := Open(dir) + if err != nil || !m2.Existed() || m2.State()["a/b/post.md"] != "e1" { + t.Fatalf("reopen: %v %v", err, m2.State()) + } + if b, err := m2.ReadFile("a/b/post.md"); err != nil || string(b) != "hello" { + t.Fatalf("ReadFile = %q, %v", b, err) + } + if err := m2.Remove("a/b/post.md"); err != nil { + t.Fatal(err) + } + if m2.Has("a/b/post.md", "e1") { + t.Fatal("still there after Remove") + } + if err := m2.Remove("missing.md"); err != nil { + t.Fatal(err) + } +} + +func TestMirrorSymlinkEscape(t *testing.T) { + dir, outside := t.TempDir(), t.TempDir() + if err := os.Symlink(outside, filepath.Join(dir, "link")); err != nil { + t.Skip(err) + } + m, err := Open(dir) + if err != nil { + t.Fatal(err) + } + if err := m.Write("link/x.md", "e", strings.NewReader("x"), 10); err == nil { + t.Fatal("write through a symlink out of the root succeeded") + } + if entries, _ := os.ReadDir(outside); len(entries) != 0 { + t.Fatalf("file escaped the root: %v", entries) + } +} diff --git a/internal/server/reindex.go b/internal/server/reindex.go new file mode 100644 index 0000000..453c348 --- /dev/null +++ b/internal/server/reindex.go @@ -0,0 +1,81 @@ +package server + +import ( + "context" + "crypto/sha256" + "crypto/subtle" + "log/slog" + "net/http" + "net/netip" + "strings" + "time" +) + +// reindexWriteTimeout replaces the server WriteTimeout: a sync can take longer than 30 s. +const reindexWriteTimeout = 10 * time.Minute + +// reindex handles POST /reindex: private network only, Bearer token, waits for the sync. +// shortcut: no RATE_LIMIT_REINDEX yet; it comes with the rate limiter in stage 5. +func reindex(log *slog.Logger, token string, allow, trusted []netip.Prefix, sync func(context.Context) error) http.HandlerFunc { + want := sha256.Sum256([]byte(token)) + return func(w http.ResponseWriter, r *http.Request) { + if !contains(allow, clientIP(r, trusted)) { + log.Warn("reindex denied", "reason", "address not in REINDEX_ALLOW") + http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) + return + } + got, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") + // Hash both sides so the compare takes the same time for any token length. + gotSum := sha256.Sum256([]byte(got)) + if !ok || subtle.ConstantTimeCompare(gotSum[:], want[:]) != 1 { + log.Warn("reindex denied", "reason", "bad token") + w.Header().Set("WWW-Authenticate", "Bearer") + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } + log.Info("reindex") + _ = http.NewResponseController(w).SetWriteDeadline(time.Now().Add(reindexWriteTimeout)) + if err := sync(r.Context()); err != nil { + http.Error(w, "sync failed", http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +// clientIP is the TCP peer, or the last X-Forwarded-For hop before the trusted proxies. +// It returns the zero Addr (matched by no prefix) if a trusted proxy sent a bad value. +func clientIP(r *http.Request, trusted []netip.Prefix) netip.Addr { + ap, err := netip.ParseAddrPort(r.RemoteAddr) + if err != nil { + return netip.Addr{} + } + ip := ap.Addr().Unmap() + if !contains(trusted, ip) { + return ip + } + hops := strings.Split(strings.Join(r.Header.Values("X-Forwarded-For"), ","), ",") + for i := len(hops) - 1; i >= 0; i-- { + h := strings.TrimSpace(hops[i]) + if h == "" && i == 0 && len(hops) == 1 { + return ip // no header: the trusted proxy itself + } + a, err := netip.ParseAddr(h) + if err != nil { + return netip.Addr{} + } + if ip = a.Unmap(); !contains(trusted, ip) { + return ip + } + } + return ip +} + +func contains(prefixes []netip.Prefix, ip netip.Addr) bool { + for _, p := range prefixes { + if p.Contains(ip) { + return true + } + } + return false +} diff --git a/internal/server/reindex_test.go b/internal/server/reindex_test.go new file mode 100644 index 0000000..ae1088e --- /dev/null +++ b/internal/server/reindex_test.go @@ -0,0 +1,112 @@ +package server + +import ( + "context" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/Mimic890/hyperhack/internal/config" +) + +const token = "0123456789abcdef0123456789abcdef" + +func TestReindex(t *testing.T) { + allow, _ := config.ParsePrefixes("10.0.0.0/8") + trusted, _ := config.ParsePrefixes("10.0.0.5") + var calls atomic.Int32 + var fail atomic.Bool + sync := func(context.Context) error { + calls.Add(1) + if fail.Load() { + return errors.New("s3 down") + } + return nil + } + cfg := &config.Config{ReindexToken: token, ReindexAllow: allow, TrustedProxies: trusted} + h := New(slog.New(slog.DiscardHandler), cfg, new(atomic.Bool), sync) + + req := func(remote, xff, auth string) int { + r := httptest.NewRequest("POST", "/reindex", nil) + r.RemoteAddr = remote + if xff != "" { + r.Header.Set("X-Forwarded-For", xff) + } + if auth != "" { + r.Header.Set("Authorization", auth) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, r) + return rec.Code + } + tests := []struct { + name, remote, xff, auth string + want int + }{ + {"ok", "10.1.2.3:5000", "", "Bearer " + token, http.StatusNoContent}, + {"public peer", "203.0.113.9:5000", "", "Bearer " + token, http.StatusForbidden}, + {"spoofed xff from untrusted peer", "203.0.113.9:5000", "10.1.2.3", "Bearer " + token, http.StatusForbidden}, + {"public client via proxy", "10.0.0.5:5000", "203.0.113.9", "Bearer " + token, http.StatusForbidden}, + {"spoof left of real client", "10.0.0.5:5000", "10.9.9.9, 203.0.113.9", "Bearer " + token, http.StatusForbidden}, + {"garbage xff via proxy", "10.0.0.5:5000", "nonsense", "Bearer " + token, http.StatusForbidden}, + {"private client via proxy", "10.0.0.5:5000", "10.1.2.3", "Bearer " + token, http.StatusNoContent}, + {"no token", "10.1.2.3:5000", "", "", http.StatusUnauthorized}, + {"wrong token", "10.1.2.3:5000", "", "Bearer " + strings.Repeat("x", 32), http.StatusUnauthorized}, + {"token prefix", "10.1.2.3:5000", "", "Bearer " + token[:10], http.StatusUnauthorized}, + {"basic scheme", "10.1.2.3:5000", "", "Basic " + token, http.StatusUnauthorized}, + } + for _, tt := range tests { + if got := req(tt.remote, tt.xff, tt.auth); got != tt.want { + t.Errorf("%s: got %d, want %d", tt.name, got, tt.want) + } + } + if calls.Load() != 2 { + t.Errorf("sync calls = %d, want 2", calls.Load()) + } + fail.Store(true) + if got := req("10.1.2.3:5000", "", "Bearer "+token); got != http.StatusServiceUnavailable { + t.Errorf("failed sync: got %d, want 503", got) + } + r := httptest.NewRequest("GET", "/reindex", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, r) + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("GET /reindex = %d, want 405", rec.Code) + } +} + +func TestClientIP(t *testing.T) { + trusted, _ := config.ParsePrefixes("10.0.0.0/8,::1") + tests := []struct { + remote string + xff []string + want string + }{ + {"203.0.113.1:1", nil, "203.0.113.1"}, + {"203.0.113.1:1", []string{"10.0.0.1"}, "203.0.113.1"}, + {"10.0.0.1:1", nil, "10.0.0.1"}, + {"10.0.0.1:1", []string{"198.51.100.7"}, "198.51.100.7"}, + {"10.0.0.1:1", []string{"1.1.1.1, 198.51.100.7, 10.0.0.2"}, "198.51.100.7"}, + {"10.0.0.1:1", []string{"1.1.1.1", "198.51.100.7"}, "198.51.100.7"}, + {"10.0.0.1:1", []string{"10.0.0.3, 10.0.0.2"}, "10.0.0.3"}, + {"[::1]:1", []string{"2001:db8::1"}, "2001:db8::1"}, + {"[::ffff:10.0.0.1]:1", []string{"::ffff:198.51.100.7"}, "198.51.100.7"}, + {"10.0.0.1:1", []string{"bad"}, "invalid IP"}, + {"10.0.0.1:1", []string{"198.51.100.7,"}, "invalid IP"}, + {"not-an-addr", nil, "invalid IP"}, + } + for _, tt := range tests { + r := httptest.NewRequest("GET", "/", nil) + r.RemoteAddr = tt.remote + for _, v := range tt.xff { + r.Header.Add("X-Forwarded-For", v) + } + if got := clientIP(r, trusted).String(); got != tt.want { + t.Errorf("clientIP(%s, %q) = %s, want %s", tt.remote, tt.xff, got, tt.want) + } + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 30b79a0..0587121 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -2,6 +2,7 @@ package server import ( + "context" "crypto/rand" "encoding/hex" "log/slog" @@ -9,11 +10,14 @@ import ( "runtime/debug" "sync/atomic" "time" + + "github.com/Mimic890/hyperhack/internal/config" ) -// New returns the root handler. ready gates /healthz. -func New(log *slog.Logger, ready *atomic.Bool) http.Handler { +// New returns the root handler. ready gates /healthz; sync runs a bucket sync for /reindex. +func New(log *slog.Logger, cfg *config.Config, ready *atomic.Bool, sync func(context.Context) error) http.Handler { mux := http.NewServeMux() + mux.Handle("POST /reindex", reindex(log, cfg.ReindexToken, cfg.ReindexAllow, cfg.TrustedProxies, sync)) mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") if !ready.Load() { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 7224748..57b5c22 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -7,6 +7,8 @@ import ( "net/http/httptest" "sync/atomic" "testing" + + "github.com/Mimic890/hyperhack/internal/config" ) func do(h http.Handler, method, path string) *httptest.ResponseRecorder { @@ -17,7 +19,7 @@ func do(h http.Handler, method, path string) *httptest.ResponseRecorder { func TestRoutes(t *testing.T) { var ready atomic.Bool - h := New(slog.New(slog.DiscardHandler), &ready) + h := New(slog.New(slog.DiscardHandler), &config.Config{}, &ready, nil) if got := do(h, "GET", "/healthz").Code; got != http.StatusServiceUnavailable { t.Errorf("healthz before ready = %d, want 503", got) diff --git a/internal/syncer/s3.go b/internal/syncer/s3.go new file mode 100644 index 0000000..a9cf2d7 --- /dev/null +++ b/internal/syncer/s3.go @@ -0,0 +1,72 @@ +package syncer + +import ( + "context" + "io" + "net/url" + "time" + + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +// Object is one bucket entry. +type Object struct { + Key string + ETag string + Size int64 +} + +// Bucket is the read-only S3 access the syncer needs. +type Bucket interface { + // List returns up to max objects in key order; truncated is true if there are more. + List(ctx context.Context, max int) (objs []Object, truncated bool, err error) + // Get streams an object. It fails if the object no longer has this ETag. + Get(ctx context.Context, key, etag string) (io.ReadCloser, error) +} + +const listTimeout = 2 * time.Minute + +type s3Bucket struct { + c *minio.Client + bucket string +} + +// NewS3 connects to an S3 API (Garage) with path-style addressing over HTTPS. +func NewS3(endpoint *url.URL, region, bucket, accessKey, secretKey string) (Bucket, error) { + c, err := minio.New(endpoint.Host, &minio.Options{ + Creds: credentials.NewStaticV4(accessKey, secretKey, ""), + Secure: true, + Region: region, + BucketLookup: minio.BucketLookupPath, + MaxRetries: 1, // the syncer retries and logs each attempt + }) + if err != nil { + return nil, err + } + return &s3Bucket{c: c, bucket: bucket}, nil +} + +func (b *s3Bucket) List(ctx context.Context, max int) ([]Object, bool, error) { + ctx, cancel := context.WithTimeout(ctx, listTimeout) + defer cancel() // also stops the listing goroutine if we return early + var out []Object + for o := range b.c.ListObjects(ctx, b.bucket, minio.ListObjectsOptions{Recursive: true}) { + if o.Err != nil { + return nil, false, o.Err + } + if len(out) == max { + return out, true, nil + } + out = append(out, Object{Key: o.Key, ETag: o.ETag, Size: o.Size}) + } + return out, false, nil +} + +func (b *s3Bucket) Get(ctx context.Context, key, etag string) (io.ReadCloser, error) { + var opts minio.GetObjectOptions + if err := opts.SetMatchETag(etag); err != nil { + return nil, err + } + return b.c.GetObject(ctx, b.bucket, key, opts) +} diff --git a/internal/syncer/s3_test.go b/internal/syncer/s3_test.go new file mode 100644 index 0000000..6a40dfb --- /dev/null +++ b/internal/syncer/s3_test.go @@ -0,0 +1,107 @@ +package syncer + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +// fakeS3 serves ListObjectsV2 (two pages) and GetObject for bucket "blog". +func fakeS3(t *testing.T) *httptest.Server { + return httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.Header.Get("Authorization"), "AWS4-HMAC-SHA256 Credential=AKEXAMPLE/") { + http.Error(w, "unsigned", http.StatusForbidden) + return + } + switch { + case r.URL.Path == "/blog/" && r.URL.Query().Get("list-type") == "2": + w.Header().Set("Content-Type", "application/xml") + if r.URL.Query().Get("continuation-token") == "" { + fmt.Fprint(w, `blogtruet2 +a.md"e1"3 +b.md"e2"4`) + return + } + fmt.Fprint(w, `blogfalse +c.md"e3"5`) + case r.URL.Path == "/blog/a.md": + if r.Header.Get("If-Match") != `"e1"` { + w.WriteHeader(http.StatusPreconditionFailed) + fmt.Fprint(w, `PreconditionFailed`) + return + } + w.Header().Set("ETag", `"e1"`) + w.Header().Set("Last-Modified", "Tue, 03 Nov 2026 12:00:00 GMT") + fmt.Fprint(w, "abc") + default: + http.NotFound(w, r) + } + })) +} + +func testBucket(t *testing.T, ts *httptest.Server) *s3Bucket { + u, _ := url.Parse(ts.URL) + c, err := minio.New(u.Host, &minio.Options{ + Creds: credentials.NewStaticV4("AKEXAMPLE", "secret", ""), + Secure: true, + Region: "garage", + BucketLookup: minio.BucketLookupPath, + MaxRetries: 1, + Transport: ts.Client().Transport, + }) + if err != nil { + t.Fatal(err) + } + return &s3Bucket{c: c, bucket: "blog"} +} + +func TestS3Bucket(t *testing.T) { + ts := fakeS3(t) + defer ts.Close() + b := testBucket(t, ts) + ctx := context.Background() + + objs, truncated, err := b.List(ctx, 10) + if err != nil || truncated || len(objs) != 3 { + t.Fatalf("List = %v, %v, %v", objs, truncated, err) + } + if objs[0] != (Object{Key: "a.md", ETag: "e1", Size: 3}) { + t.Errorf("objs[0] = %+v", objs[0]) + } + if objs, truncated, err := b.List(ctx, 2); err != nil || !truncated || len(objs) != 2 { + t.Fatalf("List(2) = %v, %v, %v", objs, truncated, err) + } + + rc, err := b.Get(ctx, "a.md", "e1") + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(rc) + _ = rc.Close() + if err != nil || string(body) != "abc" { + t.Fatalf("Get = %q, %v", body, err) + } + rc, err = b.Get(ctx, "a.md", "stale") + if err == nil { + _, err = io.ReadAll(rc) + _ = rc.Close() + } + if err == nil { + t.Fatal("Get with a stale ETag succeeded") + } +} + +func TestNewS3(t *testing.T) { + u, _ := url.Parse("https://s3.example.com:3900") + if _, err := NewS3(u, "garage", "blog", "ak", "sk"); err != nil { + t.Fatal(err) + } +} diff --git a/internal/syncer/syncer.go b/internal/syncer/syncer.go new file mode 100644 index 0000000..d798f0f --- /dev/null +++ b/internal/syncer/syncer.go @@ -0,0 +1,250 @@ +// Package syncer mirrors the bucket to disk and rebuilds the post index. +package syncer + +import ( + "context" + "fmt" + "log/slog" + "sync" + "sync/atomic" + "time" + + "github.com/Mimic890/hyperhack/internal/content" + "github.com/Mimic890/hyperhack/internal/mirror" +) + +const fetchTimeout = 5 * time.Minute + +// Options holds the limits from the config. +type Options struct { + PollInterval time.Duration + MaxPostBytes int64 + MaxAssetBytes int64 + MaxObjects int +} + +// Syncer runs one sync at a time; Sync calls wait for the running one. +type Syncer struct { + bucket Bucket + mirror *mirror.Mirror + render *content.Renderer + opts Options + log *slog.Logger // component sync + rlog *slog.Logger // component render + retry []time.Duration + + mu sync.Mutex + cache map[string]cached // post key → last parse result + warned map[string]string // key → ETag already warned about, so polls do not repeat WARNs + maxWarned bool + index atomic.Pointer[content.Index] +} + +type cached struct { + etag string + post *content.Post // nil: skipped +} + +// New returns a syncer. log is the base logger; components are added here. +func New(b Bucket, m *mirror.Mirror, r *content.Renderer, opts Options, log *slog.Logger) *Syncer { + return &Syncer{ + bucket: b, mirror: m, render: r, opts: opts, + log: log.With("component", "sync"), + rlog: log.With("component", "render"), + retry: []time.Duration{5 * time.Second, 30 * time.Second}, + cache: map[string]cached{}, + warned: map[string]string{}, + } +} + +// Index returns the current post index, or nil before the first load. +func (s *Syncer) Index() *content.Index { return s.index.Load() } + +// Run builds the index from the local mirror, syncs, then polls until ctx ends. +// It sets ready once an index exists. It returns an error only when there is +// no local mirror and the first sync fails: there is nothing to serve. +func (s *Syncer) Run(ctx context.Context, ready *atomic.Bool) error { + if s.mirror.Existed() { + s.mu.Lock() + s.rebuild() + s.mu.Unlock() + ready.Store(true) + s.log.Info("index loaded from mirror", "posts", len(s.Index().Posts())) + } + if err := s.syncRetry(ctx); err != nil && ctx.Err() == nil && !ready.Load() { + return fmt.Errorf("first sync: %w", err) + } + if ctx.Err() == nil { + ready.Store(true) + } + t := time.NewTicker(s.opts.PollInterval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return nil + case <-t.C: + _ = s.syncRetry(ctx) + } + } +} + +func (s *Syncer) syncRetry(ctx context.Context) error { + for i := 0; ; i++ { + err := s.Sync(ctx) + if err == nil || ctx.Err() != nil { + return err + } + if i == len(s.retry) { + s.log.Error("S3 unavailable after retries", "attempts", i+1, "err", err) + return err + } + s.log.Warn("sync failed, will retry", "in", s.retry[i].String(), "err", err) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(s.retry[i]): + } + } +} + +// Sync lists the bucket, fetches new and changed objects, removes deleted ones +// and rebuilds the index if anything changed. Failed fetches are retried next poll. +func (s *Syncer) Sync(ctx context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() + + objs, truncated, err := s.bucket.List(ctx, s.opts.MaxObjects) + if err != nil { + return fmt.Errorf("list: %w", err) + } + if truncated && !s.maxWarned { + s.log.Warn("bucket has more objects than MAX_OBJECTS, the rest are skipped", "max", s.opts.MaxObjects) + } + s.maxWarned = truncated + + warned := map[string]string{} + warn := func(o Object, msg string, args ...any) { + if s.warned[o.Key] != o.ETag { + s.log.Warn(msg, append([]any{"key", o.Key}, args...)...) + } + warned[o.Key] = o.ETag + } + keep := map[string]bool{} + var added, updated, removed, failed int + for _, o := range objs { + if ctx.Err() != nil { + break + } + kind, err := mirror.Classify(o.Key) + if err != nil { + warn(o, "object skipped", "err", err) + continue + } + if kind == mirror.Ignore { + continue + } + limit := s.opts.MaxAssetBytes + if kind == mirror.Post { + limit = s.opts.MaxPostBytes + } + if o.Size > limit { + warn(o, "object skipped: too large", "size", o.Size, "max", limit) + continue + } + keep[o.Key] = true + if s.mirror.Has(o.Key, o.ETag) { + continue + } + _, existed := s.mirror.State()[o.Key] + if err := s.fetch(ctx, o, limit); err != nil { + warn(o, "fetch failed, keeping the old copy", "err", err) + failed++ + continue + } + s.log.Debug("fetched", "key", o.Key, "etag", o.ETag) + if existed { + updated++ + } else { + added++ + } + } + s.warned = warned + + if ctx.Err() == nil { // remove only after a complete pass + for key := range s.mirror.State() { + if keep[key] { + continue + } + if err := s.mirror.Remove(key); err != nil { + s.log.Warn("remove failed", "key", key, "err", err) + continue + } + s.log.Debug("removed", "key", key) + removed++ + } + } + + changed := added+updated+removed > 0 + if changed || !s.mirror.Existed() { + if err := s.mirror.Save(); err != nil { + return fmt.Errorf("save mirror state: %w", err) + } + } + if changed || s.index.Load() == nil { + s.rebuild() + } + level := slog.LevelDebug + if changed || failed > 0 { + level = slog.LevelInfo + } + s.log.Log(ctx, level, "sync done", "added", added, "updated", updated, "removed", removed, "failed", failed, "posts", len(s.Index().Posts())) + return ctx.Err() +} + +func (s *Syncer) fetch(ctx context.Context, o Object, limit int64) error { + ctx, cancel := context.WithTimeout(ctx, fetchTimeout) + defer cancel() + rc, err := s.bucket.Get(ctx, o.Key, o.ETag) + if err != nil { + return err + } + defer func() { _ = rc.Close() }() + return s.mirror.Write(o.Key, o.ETag, rc, limit) +} + +// rebuild parses new and changed posts from disk and swaps in a new index. Caller holds mu. +func (s *Syncer) rebuild() { + next := map[string]cached{} + var posts []*content.Post + for key, etag := range s.mirror.State() { + if k, _ := mirror.Classify(key); k != mirror.Post { + continue + } + c, ok := s.cache[key] + if !ok || c.etag != etag { + c = cached{etag: etag, post: s.parse(key)} + } + next[key] = c + if c.post != nil { + posts = append(posts, c.post) + } + } + s.cache = next + s.index.Store(content.NewIndex(posts, s.rlog)) +} + +func (s *Syncer) parse(key string) *content.Post { + src, err := s.mirror.ReadFile(key) + if err != nil { + s.rlog.Warn("post skipped", "key", key, "err", err) + return nil + } + p, err := s.render.Parse(key, src) + if err != nil { + s.rlog.Warn("post skipped", "key", key, "err", err) + return nil + } + s.rlog.Debug("post rendered", "key", key, "slug", p.Slug, "draft", p.Draft) + return p +} diff --git a/internal/syncer/syncer_test.go b/internal/syncer/syncer_test.go new file mode 100644 index 0000000..4ec8a5e --- /dev/null +++ b/internal/syncer/syncer_test.go @@ -0,0 +1,250 @@ +package syncer + +import ( + "context" + "errors" + "io" + "log/slog" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Mimic890/hyperhack/internal/content" + "github.com/Mimic890/hyperhack/internal/mirror" +) + +type fakeObj struct { + etag string + body string +} + +type fakeBucket struct { + mu sync.Mutex + objs map[string]fakeObj + listErr error + getErr map[string]error + gets int +} + +func (b *fakeBucket) List(_ context.Context, max int) ([]Object, bool, error) { + b.mu.Lock() + defer b.mu.Unlock() + if b.listErr != nil { + return nil, false, b.listErr + } + var out []Object + for k, o := range b.objs { + out = append(out, Object{Key: k, ETag: o.etag, Size: int64(len(o.body))}) + } + slices.SortFunc(out, func(a, b Object) int { return strings.Compare(a.Key, b.Key) }) + if len(out) > max { + return out[:max], true, nil + } + return out, false, nil +} + +func (b *fakeBucket) Get(_ context.Context, key, etag string) (io.ReadCloser, error) { + b.mu.Lock() + defer b.mu.Unlock() + b.gets++ + if err := b.getErr[key]; err != nil { + return nil, err + } + o, ok := b.objs[key] + if !ok || o.etag != etag { + return nil, errors.New("precondition failed") + } + return io.NopCloser(strings.NewReader(o.body)), nil +} + +func (b *fakeBucket) set(key, etag, body string) { + b.mu.Lock() + defer b.mu.Unlock() + b.objs[key] = fakeObj{etag, body} +} + +func post(title, slug string) string { + return "---\ntitle: " + title + "\ndate: 2026-11-03\nslug: " + slug + "\n---\nText\n" +} + +var opts = Options{PollInterval: time.Hour, MaxPostBytes: 1 << 10, MaxAssetBytes: 1 << 10, MaxObjects: 100} + +func newSyncer(t *testing.T, dir string, b Bucket) *Syncer { + t.Helper() + m, err := mirror.Open(dir) + if err != nil { + t.Fatal(err) + } + s := New(b, m, content.NewRenderer("en"), opts, slog.New(slog.DiscardHandler)) + s.retry = []time.Duration{0} + return s +} + +func slugs(s *Syncer) []string { + var out []string + for _, p := range s.Index().Posts() { + out = append(out, p.Slug) + } + slices.Sort(out) + return out +} + +func TestSync(t *testing.T) { + dir := t.TempDir() + b := &fakeBucket{objs: map[string]fakeObj{ + "one.md": {"e1", post("One", "one")}, + "deep/folder/two.md": {"e1", post("Two", "two")}, + "broken.md": {"e1", "no frontmatter"}, + "assets/img/a.png": {"e1", "png"}, + "assets/evil.html": {"e1", "